mirror of
https://github.com/jayanezj/traccar-client-macos.git
synced 2026-11-05 00:00:45 +01:00
Merge pull request #24 from jayanezj/feature/3-configuration-persistence
Persist receiver configuration and device identity
This commit is contained in:
23 files changed
+1442
-42
No files matched your search
@@ -60,6 +60,8 @@ jobs:
|
||||
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
|
||||
@@ -79,6 +81,10 @@ jobs:
|
||||
run: |
|
||||
scripts/build.sh "$RUNNER_TEMP/traccar-ci" Release \
|
||||
2>&1 | tee "$RUNNER_TEMP/ci-diagnostics/build-release.log"
|
||||
- name: Test configuration and persistence
|
||||
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
|
||||
|
||||
@@ -35,6 +35,7 @@ xcuserdata/
|
||||
*~
|
||||
|
||||
# Local secrets and signing material (keep example configuration)
|
||||
/Configuration/Signing.local.xcconfig
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
included:
|
||||
- TraccarClient
|
||||
- TraccarClientTests
|
||||
|
||||
# Match Swift's multiline collection style without disabling the default rules.
|
||||
trailing_comma:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Copy to Signing.local.xcconfig (ignored by Git) and use your own Personal Team ID.
|
||||
DEVELOPMENT_TEAM = YOUR_TEAM_ID
|
||||
|
||||
// Optional: use a certificate's SHA-1 identifier if Apple Development is ambiguous.
|
||||
// CODE_SIGN_IDENTITY = YOUR_CERTIFICATE_SHA1
|
||||
@@ -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.
|
||||
|
||||
**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:** local configuration and identity persistence for [issue #3](https://github.com/jayanezj/traccar-client-macos/issues/3). This build stays paused. Location access, tracking and position/SOS delivery are not implemented; their menu actions are disabled.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -12,6 +12,10 @@ An independent, unofficial native Traccar client for Apple Silicon Macs.
|
||||
|
||||
## 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.
|
||||
|
||||
For a terminal build, supply an absolute build directory on that volume:
|
||||
@@ -22,18 +26,39 @@ 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.
|
||||
|
||||
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
|
||||
|
||||
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 or About is open and disappears after the last window closes.
|
||||
2. Confirm Start tracking, Send location now and Send SOS are 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.
|
||||
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.
|
||||
|
||||
## 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 no location permission request or network request occurs. Check real Keychain access and Settings reopening separately from the isolated automated suite.
|
||||
|
||||
## Continuous integration
|
||||
|
||||
The workflow in `.github/workflows/ci.yml` runs on pull requests, pushes to `main` and manual dispatch. It exposes three checks:
|
||||
@@ -41,12 +66,14 @@ The workflow in `.github/workflows/ci.yml` runs on pull requests, pushes to `mai
|
||||
| Check | Runner | Toolchain | Work |
|
||||
| --- | --- | --- | --- |
|
||||
| SwiftLint | `macos-15` arm64 | Xcode 26.3; SwiftLint 0.65.1 | Strict lint, including warnings |
|
||||
| Build (primary) | `xcode-27` arm64 | Xcode 27.1, build 27A9275; macOS SDK 27.0 | Debug and Release |
|
||||
| Build (macOS 15) | `macos-15` arm64 | Xcode 26.3, build 17C529; macOS SDK 26.2 | Debug and Release |
|
||||
| Build (primary) | `xcode-27` arm64 | Xcode 27.1, build 27A9275; macOS SDK 27.0 | Debug, Release and configuration tests |
|
||||
| Build (macOS 15) | `macos-15` arm64 | Xcode 26.3, build 17C529; macOS SDK 26.2 | Debug, Release and configuration tests |
|
||||
|
||||
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.
|
||||
|
||||
This stage performs lint and compilation only: there is no test target yet. Configuration tests start with [issue #3](https://github.com/jayanezj/traccar-client-macos/issues/3); subsequent features extend this workflow with meaningful tests. Builds do not validate location permissions, sleep behaviour or Gatekeeper.
|
||||
Both build jobs execute the hostless Swift Testing target. It compiles the same configuration sources as the application and tests validation, stable identity generation, save/reload, storage errors, transient status expiry/cancellation and the guard for future pending-position checks. Window-focus tests cover deferred activation, closed/minimized windows, activation failure and superseded requests. Tests use isolated UserDefaults suites, synthetic Keychain operations and fake windows/activation; they do not open the app, use the login Keychain or contact a server. A narrowly scoped legacy Keychain API suppresses interaction during the initial file-based read and immediately restores its previous setting. All application Keychain operations run synchronously on the main actor. The modern LAContext interaction flags do not suppress the legacy file-based dialogs. Real Keychain access, UI relaunch, location permissions, sleep behaviour and Gatekeeper require separate checks. Subsequent features extend this suite in the same PR as their implementation.
|
||||
|
||||
### Local checks before pushing
|
||||
|
||||
@@ -63,7 +90,15 @@ DEVELOPER_DIR=/path/to/Xcode.app/Contents/Developer \
|
||||
scripts/check-local.sh /path/to/external/local-ci
|
||||
```
|
||||
|
||||
The command checks the toolchain, shell syntax and whitespace in staged/unstaged tracked changes, runs strict SwiftLint, builds Debug and Release, then verifies both binaries' architecture, deployment target and signatures. It stops at the first failure and returns a nonzero exit code. Tools are cached under `tools/` and incremental build products under `build/` in the supplied directory. The toolchain, lint, build and binary verification scripts are shared with CI.
|
||||
The default signing mode is `development` and requires the local Apple Development setup above. To run the credential-free CI signing mode explicitly, use a separate validation directory:
|
||||
|
||||
```sh
|
||||
TRACCAR_SIGNING_MODE=adhoc scripts/check-local.sh /path/to/external/local-ci-adhoc
|
||||
```
|
||||
|
||||
`TRACCAR_SIGNING_MODE` also applies to the individual build, test and verification scripts. Accepted values are `development` and `adhoc`; there is no automatic fallback. The ad hoc mode is for CI validation, not for producing the selected distribution build.
|
||||
|
||||
The command checks the toolchain, shell syntax and whitespace in staged/unstaged tracked changes, runs strict SwiftLint, builds Debug and Release, runs configuration tests, then verifies both binaries' architecture, deployment target and the selected signing mode. It stops at the first failure and returns a nonzero exit code. Tools are cached under `tools/` and incremental build products under `build/` in the supplied directory. The toolchain, lint, build, test and binary verification scripts are shared with CI. Test result bundles are saved under `build/TestResults/`, with a unique directory per run.
|
||||
|
||||
On a separate macOS 15 arm64 environment with Xcode 26.3 build 17C529 and SDK 26.2, select the minimum-system profile explicitly:
|
||||
|
||||
@@ -81,7 +116,15 @@ Run the same lint locally, using an absolute tools directory outside the reposit
|
||||
scripts/lint.sh /path/to/external/tools-directory
|
||||
```
|
||||
|
||||
The script downloads the official portable SwiftLint release, verifies its pinned SHA-256 and checks the executable version. `.swiftlint.yml` applies the default rules to application sources, with mandatory trailing commas in multiline collections. No baseline or automatic fixes are applied. SwiftLint is a development tool, not an application package dependency.
|
||||
The script downloads the official portable SwiftLint release, verifies its pinned SHA-256 and checks the executable version. `.swiftlint.yml` applies the default rules to application and test sources, with mandatory trailing commas in multiline collections. No baseline or automatic fixes are applied. SwiftLint is a development tool, not an application package dependency.
|
||||
|
||||
Run configuration 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:
|
||||
|
||||
|
||||
@@ -4,6 +4,33 @@
|
||||
classes = {};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
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 = (B00000000000000000000020, B00000000000000000000022, B00000000000000000000023); path = TraccarClientTests; sourceTree = "<group>"; };
|
||||
D00000000000000000000010 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (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; };
|
||||
B00000000000000000000001 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application/TraccarClientApp.swift; sourceTree = "<group>"; };
|
||||
A00000000000000000000002 = {isa = PBXBuildFile; fileRef = B00000000000000000000002; };
|
||||
@@ -14,20 +41,20 @@
|
||||
A00000000000000000000004 = {isa = PBXBuildFile; fileRef = B00000000000000000000006; };
|
||||
B00000000000000000000006 = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Resources/Assets.xcassets; sourceTree = "<group>"; };
|
||||
B00000000000000000000005 = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Traccar Client macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
C00000000000000000000001 = {isa = PBXGroup; children = (C00000000000000000000002, C00000000000000000000003); sourceTree = "<group>"; };
|
||||
C00000000000000000000002 = {isa = PBXGroup; children = (B00000000000000000000001, B00000000000000000000002, B00000000000000000000003, B00000000000000000000004, B00000000000000000000006); path = TraccarClient; sourceTree = "<group>"; };
|
||||
C00000000000000000000003 = {isa = PBXGroup; children = (B00000000000000000000005); name = Products; sourceTree = "<group>"; };
|
||||
D00000000000000000000001 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A00000000000000000000001, A00000000000000000000002, A00000000000000000000003); runOnlyForDeploymentPostprocessing = 0; };
|
||||
C00000000000000000000001 = {isa = PBXGroup; children = (B00000000000000000000025, C00000000000000000000010, C00000000000000000000002, C00000000000000000000003); sourceTree = "<group>"; };
|
||||
C00000000000000000000002 = {isa = PBXGroup; children = (B00000000000000000000024, B00000000000000000000010, B00000000000000000000011, B00000000000000000000012, B00000000000000000000001, B00000000000000000000002, B00000000000000000000003, B00000000000000000000004, B00000000000000000000006); path = TraccarClient; sourceTree = "<group>"; };
|
||||
C00000000000000000000003 = {isa = PBXGroup; children = (B00000000000000000000005, B00000000000000000000021); name = Products; sourceTree = "<group>"; };
|
||||
D00000000000000000000001 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A00000000000000000000010, A00000000000000000000011, A00000000000000000000012, A00000000000000000000001, A00000000000000000000002, A00000000000000000000003, A00000000000000000000033); runOnlyForDeploymentPostprocessing = 0; };
|
||||
D00000000000000000000002 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
|
||||
D00000000000000000000003 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (A00000000000000000000004); runOnlyForDeploymentPostprocessing = 0; };
|
||||
E00000000000000000000001 = {isa = PBXNativeTarget; buildConfigurationList = F00000000000000000000002; buildPhases = (D00000000000000000000001, D00000000000000000000002, D00000000000000000000003); buildRules = (); dependencies = (); name = TraccarClient; productName = "Traccar Client macOS"; productReference = B00000000000000000000005; productType = "com.apple.product-type.application"; };
|
||||
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; };
|
||||
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; };
|
||||
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; };
|
||||
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; };
|
||||
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; 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;
|
||||
}
|
||||
@@ -5,8 +5,18 @@
|
||||
<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"/>
|
||||
</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>
|
||||
</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">
|
||||
<BuildableProductRunnable runnableDebuggingMode="0">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="E00000000000000000000001" BuildableName="Traccar Client macOS.app" BlueprintName="TraccarClient" ReferencedContainer="container:TraccarClient.xcodeproj"/>
|
||||
|
||||
@@ -5,6 +5,8 @@ import SwiftUI
|
||||
struct TraccarClientApp: App {
|
||||
@NSApplicationDelegateAdaptor(ApplicationDelegate.self) private var delegate
|
||||
|
||||
@State private var configuration = ConfigurationModel.live()
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra {
|
||||
TrackingMenu()
|
||||
@@ -16,13 +18,54 @@ struct TraccarClientApp: App {
|
||||
.menuBarExtraStyle(.menu)
|
||||
|
||||
Settings {
|
||||
SettingsView()
|
||||
SettingsView(model: configuration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
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 {
|
||||
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() -> ConfigurationModel {
|
||||
ConfigurationModel(storage: PreferencesConfigurationStorage(
|
||||
preferences: .standard, connectionStorage: KeychainConnectionStorage(
|
||||
service: "\(Bundle.main.bundleIdentifier ?? "com.codigojose.traccar-client-macos").configuration"
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
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. Tracking remains paused.")
|
||||
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,164 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
@MainActor
|
||||
protocol ConnectionStorage {
|
||||
func read(allowInteraction: Bool) throws -> ReceiverConnection?
|
||||
func write(_ connection: ReceiverConnection) throws
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol ConfigurationStorage {
|
||||
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
|
||||
}
|
||||
|
||||
func load(allowInteraction: Bool) throws -> AppConfiguration {
|
||||
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
|
||||
}
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,173 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@Bindable var model: ConfigurationModel
|
||||
@State private var focus = WindowFocusController()
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
Form {
|
||||
LabeledContent("Status", value: "Paused")
|
||||
Text("Server and device configuration will be available in a later build.")
|
||||
VStack(spacing: 12) {
|
||||
if model.hasLoaded {
|
||||
TabView {
|
||||
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") }
|
||||
|
||||
Form {
|
||||
Text("Continuous tracking, manual location and SOS are not implemented yet.")
|
||||
Text("This build does not request your location or send positions.")
|
||||
VStack(alignment: .leading) {
|
||||
if let error = model.errorMessage {
|
||||
Label(error, systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.red).textSelection(.enabled)
|
||||
} else if let status = model.statusMessage {
|
||||
Label(status, systemImage: "info.circle").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.tabItem { Label("Tracking", systemImage: "location") }
|
||||
|
||||
Form {
|
||||
Text("Login startup and tracking restoration are not enabled.")
|
||||
Text("Closing Settings keeps the app in the menu bar. Use Quit to exit.")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(height: 64, alignment: .topLeading)
|
||||
HStack {
|
||||
if !model.hasLoaded {
|
||||
Button(model.errorMessage == nil ? "Access Keychain" : "Retry loading") {
|
||||
focus.perform { model.reload() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
Spacer()
|
||||
if model.hasLoaded {
|
||||
Button("Save") { focus.perform { model.save() } }
|
||||
.keyboardShortcut("s", modifiers: .command)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
.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("Preferences are saved for later tracking implementation. Tracking remains paused in this build.")
|
||||
}
|
||||
.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 the app in the menu bar. Use Quit to exit.")
|
||||
}
|
||||
}
|
||||
|
||||
private var diagnostics: some View {
|
||||
Form {
|
||||
LabeledContent("Status", value: "Paused")
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,13 @@ struct TrackingMenu: View {
|
||||
Divider()
|
||||
|
||||
Button("Settings…") {
|
||||
NSApplication.shared.activate(ignoringOtherApps: true)
|
||||
NSApplication.shared.activate()
|
||||
openSettings()
|
||||
}
|
||||
.keyboardShortcut(",")
|
||||
|
||||
Button("About Traccar Client for macOS") {
|
||||
NSApplication.shared.activate(ignoringOtherApps: true)
|
||||
NSApplication.shared.activate()
|
||||
NSApplication.shared.orderFrontStandardAboutPanel(options: [
|
||||
.applicationName: "Traccar Client for macOS",
|
||||
.credits: NSAttributedString(
|
||||
|
||||
@@ -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,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,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 ;;
|
||||
esac
|
||||
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
source "$repo_root/scripts/signing-options.sh"
|
||||
mkdir -p "$build_root"
|
||||
|
||||
exec xcodebuild \
|
||||
@@ -22,4 +23,5 @@ exec xcodebuild \
|
||||
-derivedDataPath "$build_root/DerivedData" \
|
||||
"CLANG_MODULE_CACHE_PATH=$build_root/ModuleCache" \
|
||||
"SWIFT_MODULE_CACHE_PATH=$build_root/ModuleCache" \
|
||||
"${signing_options[@]}" \
|
||||
build
|
||||
@@ -28,9 +28,11 @@ 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..."
|
||||
@@ -48,8 +50,11 @@ for configuration in Debug Release; do
|
||||
scripts/build.sh "$validation_root/build" "$configuration"
|
||||
done
|
||||
|
||||
echo "Running configuration 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): lint, Debug, Release, arm64, macOS 15.0 minimum and signatures."
|
||||
echo "Local checks passed ($profile, $signing_mode): lint, Debug, Release, configuration tests, arm64, macOS 15.0 minimum and signatures."
|
||||
echo "Hosted workflow execution and manual application checks remain separate."
|
||||
@@ -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/Configuration.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/Configuration.xcresult" \
|
||||
"CLANG_MODULE_CACHE_PATH=$build_root/ModuleCache" \
|
||||
"SWIFT_MODULE_CACHE_PATH=$build_root/ModuleCache" \
|
||||
"${signing_options[@]}" \
|
||||
test
|
||||
@@ -6,6 +6,8 @@ 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"
|
||||
@@ -14,4 +16,25 @@ for configuration in Debug Release; do
|
||||
test "$(lipo -archs "$executable")" = arm64
|
||||
test "$(xcrun vtool -show-build "$executable" | awk '/minos/ {print $2}')" = 15.0
|
||||
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
|
||||
Reference in new issue
Block a user