diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..bc34b71
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,62 @@
+name: Bug report
+description: Report reproducible GeoShift behavior.
+title: "[Bug]: "
+labels: [bug]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Read the troubleshooting guide first. Never attach pairing records or unredacted logs.
+ - type: input
+ id: version
+ attributes:
+ label: GeoShift version
+ placeholder: 1.5.0
+ validations:
+ required: true
+ - type: input
+ id: environment
+ attributes:
+ label: macOS, iOS, and iPhone model
+ placeholder: macOS 15.x, iOS 18.x, iPhone model
+ validations:
+ required: true
+ - type: dropdown
+ id: connection
+ attributes:
+ label: Connection type
+ options:
+ - iOS 27+ Wi-Fi RemotePairing
+ - Trusted USB
+ - Legacy trusted Wi-Fi
+ - Unknown
+ validations:
+ required: true
+ - type: textarea
+ id: steps
+ attributes:
+ label: Reproduction steps
+ description: Include the visible GeoShift state and what happened before it.
+ validations:
+ required: true
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected behavior
+ validations:
+ required: true
+ - type: textarea
+ id: diagnostics
+ attributes:
+ label: Redacted diagnostics
+ description: Remove UDIDs, IPs, usernames, local paths, pairing data, and trust material.
+ render: text
+ - type: checkboxes
+ id: safety
+ attributes:
+ label: Privacy confirmation
+ options:
+ - label: I removed device identifiers, IP addresses, personal paths, pairing records, and credentials.
+ required: true
+ - label: This is not a security vulnerability or private report.
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..f372aa1
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Security or privacy report
+ url: https://github.com/Lemelson/GeoShift/security/advisories/new
+ about: Report vulnerabilities and privacy issues privately.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..58a92f6
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,29 @@
+name: Feature request
+description: Suggest an improvement for authorized iOS location testing.
+title: "[Feature]: "
+labels: [enhancement]
+body:
+ - type: textarea
+ id: problem
+ attributes:
+ label: Testing problem
+ description: What development or QA workflow is difficult today?
+ validations:
+ required: true
+ - type: textarea
+ id: proposal
+ attributes:
+ label: Proposed behavior
+ validations:
+ required: true
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives considered
+ - type: checkboxes
+ id: scope
+ attributes:
+ label: Scope confirmation
+ options:
+ - label: This request is for devices I own or am authorized to test.
+ required: true
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..7def0e6
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,17 @@
+## Summary
+
+Describe the user-visible behavior and why the change is needed.
+
+## Verification
+
+- [ ] `swift test`
+- [ ] Python worker tests
+- [ ] `git diff --check`
+- [ ] English and Russian UI resources remain synchronized
+- [ ] No generated app/build files, logs, device IDs, local paths, or pairing data
+
+## Safety impact
+
+Explain any effect on Start, Restore GPS, target-device selection, reconnect,
+heartbeat, worker termination, or pending-clear behavior. Write “None” when the
+change cannot affect those paths.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..1c8123a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,44 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ swift:
+ name: Swift build and tests
+ runs-on: macos-26
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ persist-credentials: false
+ - name: Show Swift toolchain
+ run: swift --version
+ - name: Run Swift tests
+ run: swift test
+ - name: Validate packaging script
+ run: bash -n Scripts/package_app.sh Scripts/install_app.sh
+
+ python:
+ name: Python worker tests
+ runs-on: macos-26
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ persist-credentials: false
+ - name: Set up Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
+ with:
+ python-version: "3.13"
+ - name: Install worker dependency
+ run: python -m pip install 'pymobiledevice3==9.31.0'
+ - name: Run worker tests
+ env:
+ PYTHONDONTWRITEBYTECODE: "1"
+ run: python -m unittest discover -s Tests/Python -v
diff --git a/.gitignore b/.gitignore
index 520d454..c065e9f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,16 @@ __pycache__/
*.xcuserstate
DerivedData/
*.log
+.env
+.env.*
+!.env.example
+.pymobiledevice3/
+remote_*.plist
+config.json
+status.json
+pairing-status.json
+*.p12
+*.pfx
+*.pem
+*.key
+*.mobileprovision
diff --git a/Assets/GeoShiftScreenshot.png b/Assets/GeoShiftScreenshot.png
new file mode 100644
index 0000000..7d01211
Binary files /dev/null and b/Assets/GeoShiftScreenshot.png differ
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..3188577
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,33 @@
+# Changelog
+
+All notable changes to GeoShift are documented here.
+
+## 1.5.0 — 2026-07-20
+
+### Added
+
+- English interface by default with an in-app English/Russian language switch.
+- Guided iOS 27+ device-initiated Wi-Fi pairing with a six-digit code.
+- Durable worker status, exact target-device identity, and pending restore queue.
+- Five-minute application heartbeat watchdog and startup recovery.
+- Rotating diagnostics with a collapsed last-50-events view.
+- Python regression tests for device selection, clear safety, stale heartbeat,
+ RemotePairing discovery, and corrupt-configuration recovery.
+
+### Changed
+
+- Restore GPS is now mandatory on normal app termination.
+- Command-Q is refused if a safe restore handoff cannot be confirmed.
+- RemotePairing discovery waits long enough for delayed Bonjour advertisements.
+- City search supports both English and Russian names, countries, and regions.
+
+### Fixed
+
+- A simulated location could remain on the phone after the app or worker stopped.
+- A stale worker could overwrite a confirmed clear with `clearPending`.
+- A forced worker termination could incorrectly report success before clear.
+- Missing or invalid configuration could abandon a device-side simulation.
+
+## 1.0.0
+
+- Initial public release.
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..67084bf
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,9 @@
+# Code of Conduct
+
+GeoShift is an open-source engineering project. Be respectful, constructive,
+and specific. Discuss the work rather than the person, welcome corrections,
+and avoid harassment, discrimination, threats, or publication of private data.
+
+Maintainers may edit or remove abusive, unsafe, off-topic, or privacy-violating
+contributions and may restrict participation when necessary. Report conduct or
+privacy concerns privately through the repository's Security Advisory channel.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..17b6872
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,39 @@
+# Contributing to GeoShift
+
+Thank you for helping improve GeoShift. Bug reports, documentation fixes,
+compatibility results, translations, and focused code contributions are welcome.
+
+## Before opening an issue
+
+1. Read the [troubleshooting guide](docs/TROUBLESHOOTING.md).
+2. Search existing issues.
+3. Reproduce with the latest `main` branch when practical.
+4. Redact device UDIDs, IP addresses, usernames, local paths, and pairing data.
+
+Use GitHub Security Advisories, not a public issue, for security or privacy bugs.
+
+## Development setup
+
+```bash
+brew install uv
+uv tool install --python 3.13 'pymobiledevice3==9.31.0'
+swift test
+PYTHONDONTWRITEBYTECODE=1 uv run --with 'pymobiledevice3==9.31.0' \
+ python -m unittest discover -s Tests/Python -v
+```
+
+See [Installation](docs/INSTALLATION.md) for the complete setup.
+
+## Pull requests
+
+- Keep changes focused and explain the user-visible behavior.
+- Add or update tests for safety-critical worker and state transitions.
+- Keep English and Russian UI resources synchronized.
+- Do not commit generated `.app` bundles, `.build`, logs, runtime JSON, pairing
+ records, credentials, or device identifiers.
+- Run `swift test`, the Python test suite, and `git diff --check` before opening
+ a pull request.
+
+GeoShift controls a persistent device-side location simulation. Changes to
+start, stop, reconnect, process termination, or target-device selection must
+fail safe toward a durable Restore GPS request.
diff --git a/Package.swift b/Package.swift
index a64f68a..1555e7a 100644
--- a/Package.swift
+++ b/Package.swift
@@ -3,6 +3,7 @@ import PackageDescription
let package = Package(
name: "GeoShift",
+ defaultLocalization: "en",
platforms: [
.macOS(.v14),
],
diff --git a/README.md b/README.md
index 328f7da..de5cd9b 100644
--- a/README.md
+++ b/README.md
@@ -1,58 +1,93 @@
-
+
-GeoShift
+GeoShift — iPhone GPS Simulator for macOS
- A native macOS controller for simulating an iPhone's Core Location coordinates.
+ Open-source macOS app for persistent Core Location simulation on a physical iPhone over USB or Wi-Fi.
-GeoShift keeps a selected location active on a connected iPhone, offers a
-searchable catalog of 100+ cities, and reconnects automatically when the
-developer tunnel is interrupted. It is intended for development, QA, demos,
-and location-aware app testing.
+
+
+
+
+
+
+
+
+
+
+
+
+GeoShift is a native SwiftUI controller for simulating iPhone GPS coordinates
+using Apple's developer location-simulation path through
+[`pymobiledevice3`](https://github.com/doronz88/pymobiledevice3). It is designed
+for iOS development, QA, demos, localization testing, geofencing tests, and
+other authorized location-aware workflows on a physical iPhone.
-## What it changes
+It provides a searchable catalog of 100+ destinations, automatic reconnect,
+iOS 27 cable-free pairing (verified on the current beta), English and Russian
+interfaces, and a fail-safe
+Restore GPS queue that survives app crashes and temporary phone disconnection.
-GeoShift changes the coordinates reported by iOS Core Location while its
-background worker is active. The selected coordinates are visible to apps such
-as Maps.
+> Common searches call this an “iPhone GPS spoofer” or “fake location” tool.
+> GeoShift does not hide what it does: it uses developer location simulation,
+> requires Developer Mode, and is intended for devices you own or are authorized
+> to test. It does not jailbreak the phone or bypass simulation detection.
-It is **not a VPN**. It does not change:
+## Why GeoShift?
-- the phone's public IP address or network route;
-- latency, bandwidth, DNS, or cellular provider;
-- App Store country, system timezone, or device language.
+- **Physical iPhone testing:** simulate Core Location outside the iOS Simulator.
+- **USB or Wi-Fi:** trusted USB, legacy Wi-Fi lockdown, and iOS 27 RemotePairing.
+- **No cable on iOS 27:** the beta-tested assistant shows a six-digit pairing code.
+- **Persistent and recoverable:** the worker reconnects after sleep or network loss.
+- **Fail-safe Restore GPS:** exact-device identity, durable status, crash heartbeat,
+ pending clear queue, and startup recovery prevent stale simulated locations.
+- **English and Russian:** English is the first-run default; switch languages in
+ Settings without restarting the app.
+- **Private by design:** no analytics, account, cloud service, or remote server.
+- **Transparent:** open-source SwiftUI controller and Python worker with regression tests.
-An app can compare GPS coordinates with IP-derived location or detect simulated
-locations. GeoShift does not attempt to hide simulation or bypass third-party
-enforcement.
+## What GeoShift changes
-## Requirements
+GeoShift changes coordinates reported by iOS Core Location while developer
+simulation is active. Maps and other location-aware apps can observe the selected
+coordinates.
-- macOS 14 or newer;
-- an iPhone running iOS 17 or newer;
-- Developer Mode enabled on the iPhone;
-- the iPhone paired with and trusted by the Mac;
-- Swift 6.2 or newer;
-- Python 3.11–3.13 with
- [pymobiledevice3](https://github.com/doronz88/pymobiledevice3).
+GeoShift is **not a VPN** and does not change:
-A USB cable is recommended for first-time pairing. After pymobiledevice3 enables
-wireless connections, reconnection may also work when both devices are on the
-same local network.
+- public IP address or internet country;
+- network route, DNS, ping, bandwidth, or cellular provider;
+- App Store country, timezone, locale, or device language.
-## Install from source
+Apps may detect simulated coordinates or compare GPS with IP-derived location.
+GeoShift does not attempt to conceal simulation or bypass third-party rules.
-Install the backend with `uv`:
+## Compatibility
+
+| Requirement | Support |
+| --- | --- |
+| macOS | 14 or newer |
+| Swift | 6.2 or newer |
+| Python | 3.11–3.13; 3.13 recommended |
+| pymobiledevice3 | 9.31.0 |
+| Physical iPhone | iOS 17+ with Developer Mode |
+| Cable-free pairing | iOS 27 RemotePairing; currently beta-tested |
+| iOS Simulator | Not targeted; use Xcode's built-in controls |
+
+See the full [compatibility matrix and known limitations](docs/COMPATIBILITY.md).
+
+## Quick install
+
+Install the backend in an isolated `uv` environment:
```bash
brew install uv
-uv tool install --python 3.13 pymobiledevice3
+uv tool install --python 3.13 'pymobiledevice3==9.31.0'
```
-Clone, build, and install the app:
+Clone, build, and install GeoShift:
```bash
git clone https://github.com/Lemelson/GeoShift.git
@@ -60,98 +95,113 @@ cd GeoShift
./Scripts/install_app.sh
```
-The script builds a release binary, creates an ad-hoc signed app bundle, copies
-it to `/Applications/GeoShift.app`, and opens it.
-
-If macOS blocks the first launch, Control-click the app in Finder, choose
-**Open**, and confirm once. Public binary releases are not currently notarized.
+The script creates an ad-hoc signed `/Applications/GeoShift.app` and opens it.
+Public builds are not currently notarized; see the
+[installation and Gatekeeper guide](docs/INSTALLATION.md).
## First run
-1. Connect and unlock the iPhone.
-2. Tap **Trust** if iOS asks whether to trust the Mac.
-3. Enable **Settings → Privacy & Security → Developer Mode** on the iPhone.
-4. Open GeoShift and choose a destination.
-5. Click **Start**.
-
-GeoShift automatically selects the first available iPhone. The background
-LaunchAgent keeps running when the controller window is closed. Click **Stop**
-before disconnecting if you want to restore real GPS immediately; restarting
-the iPhone also clears developer location simulation.
-
-## Controls
-
-- **Start** installs and starts the per-user background worker.
-- **Stop** stops the worker and clears the simulated location.
-- **Restart** rebuilds the developer tunnel.
-- **Refresh** wakes a sleeping reconnect attempt within about 250 ms.
-- **Choose city** searches destinations by city, country, or region.
-- **Settings** controls reconnect and coordinate refresh intervals.
-
-The configurable reconnect delay prevents a disconnected device from causing a
-tight retry loop. Manual Refresh bypasses that delay without changing the saved
-setting.
+1. Enable **Settings → Privacy & Security → Developer Mode** on the iPhone.
+2. Open GeoShift and choose **Settings → Connection**.
+3. On iOS 27, start the Wi-Fi assistant, then open iPhone
+ **Settings → Developer → Paired Macs → Other Devices → GeoShift**.
+4. Enter the six-digit code shown in GeoShift. This is normally a one-time step.
+5. Alternatively, connect the unlocked iPhone by cable and confirm **Trust**.
+6. Choose a destination and select **Start**.
+
+The pairing permission does not need weekly or monthly renewal. See
+[Pairing an iPhone](docs/PAIRING.md) for the exact flow and renewal conditions.
+
+## Controls and safety behavior
+
+- **Start** installs or wakes the background worker and applies the destination.
+- **Restore GPS** sends a clear through the trusted developer tunnel. If the phone
+ is offline, the request remains pending and completes when that exact phone returns.
+- **Restart** rebuilds the developer tunnel when a connection is stuck.
+- **Reconnect** immediately wakes a sleeping retry.
+- **Choose city** searches English and Russian city, country, and region names.
+- **Settings** controls language, reconnect timing, GPS refresh, and Wi-Fi pairing.
+
+GeoShift writes an application heartbeat every 30 seconds. If the app crashes or
+is killed, a missing heartbeat converts the active request into Restore GPS within
+five minutes. Command-Q is refused when the app cannot confirm either a completed
+clear or a durable handoff to the restore worker.
+
+The UI reports **Simulation cleared** only after the no-reply `clear` command
+returns successfully through the connected iPhone's developer channel. Apple's
+developer API does not read back the physical GPS sensor, so open Maps for the
+final sensor-side check. A missing worker, stale log, or disconnected phone is
+never treated as proof.
+
+Restarting the iPhone clears the current developer location simulation. If
+**Start** is still requested and the worker remains active, GeoShift can reconnect
+and apply it again; choose **Restore GPS** before restarting whenever possible.
## Build and test
```bash
swift test
-./Scripts/build_icon.sh
+PYTHONDONTWRITEBYTECODE=1 uv run --with 'pymobiledevice3==9.31.0' \
+ python -m unittest discover -s Tests/Python -v
./Scripts/package_app.sh release
```
-The app bundle is written to `GeoShift.app` in the repository root.
-
-## Troubleshooting
-
-**The app keeps waiting for an iPhone**
+The package script builds the localized resource bundle, creates `GeoShift.app`,
+and applies an ad-hoc signature.
-- unlock the phone and reconnect it by cable;
-- confirm that Finder can see the iPhone;
-- verify Developer Mode is enabled;
-- keep the phone unlocked during the first developer-tunnel connection;
-- click Restart after changing any of those conditions.
+## Documentation
-**pymobiledevice3 is missing**
+- [Installation](docs/INSTALLATION.md)
+- [USB and Wi-Fi pairing](docs/PAIRING.md)
+- [Troubleshooting and diagnostics](docs/TROUBLESHOOTING.md)
+- [Compatibility and limitations](docs/COMPATIBILITY.md)
+- [Architecture and fail-safe state machine](docs/ARCHITECTURE.md)
+- [Privacy and local data](docs/PRIVACY.md)
+- [Uninstall and cleanup](docs/UNINSTALL.md)
+- [Security policy](SECURITY.md)
+- [Contributing](CONTRIBUTING.md)
+- [Changelog](CHANGELOG.md)
-GeoShift searches standard `uv`, `pipx`, Homebrew, and local-bin locations. The
-recommended installation is:
+## Architecture overview
-```bash
-uv tool install --python 3.13 pymobiledevice3
-```
-
-**The location returns to normal**
+The SwiftUI app writes atomic configuration and heartbeat state and manages a
+per-user LaunchAgent. The LaunchAgent runs the bundled `keeper.py` using the
+separately installed pymobiledevice3 environment. The worker opens a trusted
+developer tunnel, durably marks that simulation may be active before sending a
+location, and writes a separate atomic successful-clear status after the clear
+command returns without error.
-iOS clears simulation after a phone restart, and a developer tunnel can be
-interrupted by sleep, network changes, or disconnection. Leave the background
-worker running; it will reconnect and reapply the selected coordinates.
+Human-readable logs rotate automatically. The UI reads only the latest 50 events
+and keeps diagnostics collapsed by default. Runtime configuration, status, logs,
+and pairing credentials remain outside the repository under the current user's
+Library and pymobiledevice3 directories.
-**Inspect the background service**
+Read the detailed [architecture guide](docs/ARCHITECTURE.md) and
+[privacy documentation](docs/PRIVACY.md).
-```bash
-launchctl print "gui/$(id -u)/com.lemelson.geoshift.keeper"
-tail -f "$HOME/Library/Logs/GeoShift.log"
-```
+## Troubleshooting
-## Architecture
+If GeoShift cannot see the iPhone:
-The SwiftUI app writes a small JSON configuration and manages a per-user
-LaunchAgent. The LaunchAgent runs the bundled Python worker using the
-pymobiledevice3 environment installed on the Mac. The worker opens an iOS
-developer tunnel and periodically applies the selected latitude and longitude.
+- unlock the phone and keep both devices on the same local network;
+- check the saved pair under **Settings → Connection**;
+- allow Bonjour/local traffic through VPN or firewall software;
+- retry the in-app pairing assistant on iOS 27+;
+- use a trusted USB connection as a fallback;
+- choose **Reconnect** after changing those conditions.
-No analytics, account system, cloud service, or remote server is included.
-Configuration and logs remain under the current user's Library directory.
+For stuck locations, pairing failures, Command-Q refusal, and safe log collection,
+see [Troubleshooting](docs/TROUBLESHOOTING.md).
## Responsible use
Use GeoShift only on devices you own or are authorized to test. Location-aware
-services may prohibit simulated coordinates in their terms, and accounts may be
-restricted when those rules are violated. This project is a testing utility,
-not an anti-detection or ban-evasion tool.
+services may prohibit simulated coordinates, and accounts may be restricted when
+their rules are violated. GeoShift is a testing utility, not an anti-detection,
+ban-evasion, fraud, or access-control bypass tool.
## License
GeoShift is available under the [MIT License](LICENSE). pymobiledevice3 is a
-separate dependency distributed under GPL-3.0.
+separate runtime dependency distributed under GPL-3.0 and is not bundled into
+the GeoShift app or repository.
diff --git a/SECURITY.md b/SECURITY.md
index 260cced..4c60f2e 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,8 +1,23 @@
-# Security policy
+# Security Policy
Please report suspected security or privacy issues privately through GitHub
Security Advisories instead of opening a public issue.
+## Supported versions
+
+Security fixes are applied to the latest release and the current `main` branch.
+Older builds may not receive backports.
+
+## Reporting a vulnerability
+
+Open the repository's **Security** tab and choose **Report a vulnerability**.
+Include the affected version, macOS and iOS versions, reproduction steps, and
+the expected impact. Please allow a reasonable period for investigation before
+publishing details.
+
GeoShift does not collect or transmit telemetry. Device identifiers may appear
in local pymobiledevice3 diagnostics, so logs should be reviewed before they
are attached to a public issue.
+
+Never include pairing records, trust records, private keys, full device UDIDs,
+local IP addresses, or unredacted diagnostics in a public report.
diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh
index 6155c9c..f138859 100755
--- a/Scripts/package_app.sh
+++ b/Scripts/package_app.sh
@@ -32,6 +32,12 @@ cat > "$APP/Contents/Info.plist" <CFBundlePackageTypeAPPL
CFBundleShortVersionString${MARKETING_VERSION}
CFBundleVersion${BUILD_NUMBER}
+ CFBundleDevelopmentRegionen
+ CFBundleLocalizations
+
+ en
+ ru
+
LSMinimumSystemVersion${MACOS_MIN_VERSION}
NSHighResolutionCapable
@@ -46,6 +52,9 @@ fi
cp "$BINARY" "$APP/Contents/MacOS/$EXECUTABLE_NAME"
chmod +x "$APP/Contents/MacOS/$EXECUTABLE_NAME"
cp "$ROOT/Sources/GeoShift/Resources/keeper.py" "$APP/Contents/Resources/keeper.py"
+cp "$ROOT/Sources/GeoShift/Resources/pairing.py" "$APP/Contents/Resources/pairing.py"
+cp -R "$ROOT/Sources/GeoShift/Resources/en.lproj" "$APP/Contents/Resources/en.lproj"
+cp -R "$ROOT/Sources/GeoShift/Resources/ru.lproj" "$APP/Contents/Resources/ru.lproj"
if [[ -f "$ROOT/Assets/AppIcon.icns" ]]; then
cp "$ROOT/Assets/AppIcon.icns" "$APP/Contents/Resources/AppIcon.icns"
fi
diff --git a/Sources/GeoShift/AppDelegate.swift b/Sources/GeoShift/AppDelegate.swift
new file mode 100644
index 0000000..36e7957
--- /dev/null
+++ b/Sources/GeoShift/AppDelegate.swift
@@ -0,0 +1,22 @@
+import AppKit
+
+@MainActor
+final class AppDelegate: NSObject, NSApplicationDelegate {
+ var controller: KeeperController?
+
+ func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
+ true
+ }
+
+ func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
+ guard let controller, controller.requiresClearBeforeTermination else {
+ return .terminateNow
+ }
+
+ Task { @MainActor in
+ let safetyHandoffSucceeded = await controller.stopBeforeQuit()
+ sender.reply(toApplicationShouldTerminate: safetyHandoffSucceeded)
+ }
+ return .terminateLater
+ }
+}
diff --git a/Sources/GeoShift/AppLanguage.swift b/Sources/GeoShift/AppLanguage.swift
new file mode 100644
index 0000000..16ac661
--- /dev/null
+++ b/Sources/GeoShift/AppLanguage.swift
@@ -0,0 +1,21 @@
+import Foundation
+
+enum AppLanguage: String, CaseIterable, Identifiable, Sendable {
+ case english = "en"
+ case russian = "ru"
+
+ var id: String { rawValue }
+
+ var displayName: String {
+ switch self {
+ case .english:
+ "English"
+ case .russian:
+ "Русский"
+ }
+ }
+
+ var locale: Locale {
+ Locale(identifier: rawValue)
+ }
+}
diff --git a/Sources/GeoShift/AppPaths.swift b/Sources/GeoShift/AppPaths.swift
index a77431a..5cbaf1d 100644
--- a/Sources/GeoShift/AppPaths.swift
+++ b/Sources/GeoShift/AppPaths.swift
@@ -6,6 +6,13 @@ enum AppPaths {
static let configurationURL = applicationSupportURL.appending(path: "config.json")
+ static let statusURL = applicationSupportURL.appending(path: "status.json")
+
+ static let pairingStatusURL = applicationSupportURL.appending(path: "pairing-status.json")
+
+ static let pairingRecordsURL = FileManager.default.homeDirectoryForCurrentUser
+ .appending(path: ".pymobiledevice3")
+
static let logURL = FileManager.default.homeDirectoryForCurrentUser
.appending(path: "Library/Logs/GeoShift.log")
diff --git a/Sources/GeoShift/City.swift b/Sources/GeoShift/City.swift
index d7aca27..bc41807 100644
--- a/Sources/GeoShift/City.swift
+++ b/Sources/GeoShift/City.swift
@@ -1,3 +1,5 @@
+import Foundation
+
struct City: Identifiable, Hashable, Codable, Sendable {
let id: String
let name: String
@@ -7,18 +9,58 @@ struct City: Identifiable, Hashable, Codable, Sendable {
let latitude: Double
let longitude: Double
- var displayName: String {
- "\(flag) \(name)"
+ func localizedName(for language: AppLanguage) -> String {
+ switch language {
+ case .english:
+ Self.englishNameOverrides[id] ?? id
+ .replacing("-", with: " ")
+ .capitalized(with: Locale(identifier: "en_US"))
+ case .russian:
+ name
+ }
}
- var subtitle: String {
- "\(country) · \(region.title)"
+ func localizedCountry(for language: AppLanguage) -> String {
+ switch language {
+ case .english:
+ guard let countryCode else {
+ return country
+ }
+ return Locale(identifier: "en_US").localizedString(forRegionCode: countryCode) ?? country
+ case .russian:
+ return country
+ }
}
func matches(_ query: String) -> Bool {
query.isEmpty ||
name.localizedStandardContains(query) ||
country.localizedStandardContains(query) ||
- region.title.localizedStandardContains(query)
+ localizedName(for: .english).localizedStandardContains(query) ||
+ localizedCountry(for: .english).localizedStandardContains(query) ||
+ region.searchTerms.localizedStandardContains(query)
+ }
+
+ private var countryCode: String? {
+ let letters = flag.unicodeScalars.compactMap { scalar -> Character? in
+ guard (127_462...127_487).contains(scalar.value),
+ let letter = UnicodeScalar(scalar.value - 127_397) else {
+ return nil
+ }
+ return Character(String(letter))
+ }
+ return letters.count == 2 ? String(letters) : nil
}
+
+ private static let englishNameOverrides = [
+ "bali": "Bali · Denpasar",
+ "bogota": "Bogotá",
+ "cancun": "Cancún",
+ "ho-chi-minh": "Ho Chi Minh City",
+ "krakow": "Kraków",
+ "medellin": "Medellín",
+ "rio": "Rio de Janeiro",
+ "san-jose": "San José",
+ "sao-paulo": "São Paulo",
+ ]
}
diff --git a/Sources/GeoShift/CityPickerView.swift b/Sources/GeoShift/CityPickerView.swift
index 7c5ea71..a981e36 100644
--- a/Sources/GeoShift/CityPickerView.swift
+++ b/Sources/GeoShift/CityPickerView.swift
@@ -3,6 +3,7 @@ import SwiftUI
struct CityPickerView: View {
@Bindable var controller: KeeperController
@Environment(\.dismiss) private var dismiss
+ @Environment(LocalizationStore.self) private var localization
@State private var searchText = ""
var body: some View {
@@ -14,7 +15,7 @@ struct CityPickerView: View {
ContentUnavailableView.search
} else {
ForEach(sections) { section in
- Section(section.region.title) {
+ Section(section.region.title(using: localization)) {
ForEach(section.cities) { city in
Button {
choose(city)
@@ -30,11 +31,11 @@ struct CityPickerView: View {
}
}
}
- .searchable(text: $searchText, prompt: "Город, страна или регион")
- .navigationTitle("Выбор города")
+ .searchable(text: $searchText, prompt: localization.text("city.searchPrompt"))
+ .navigationTitle(localization.text("city.pickerTitle"))
.toolbar {
ToolbarItem(placement: .confirmationAction) {
- Button("Готово", action: dismiss.callAsFunction)
+ Button(localization.text("common.done"), action: dismiss.callAsFunction)
}
}
}
diff --git a/Sources/GeoShift/CityRegion.swift b/Sources/GeoShift/CityRegion.swift
index 3529f9d..93a22b7 100644
--- a/Sources/GeoShift/CityRegion.swift
+++ b/Sources/GeoShift/CityRegion.swift
@@ -11,22 +11,42 @@ enum CityRegion: String, CaseIterable, Identifiable, Codable, Sendable {
rawValue
}
- var title: String {
+ @MainActor
+ func title(using localization: LocalizationStore) -> String {
switch self {
case .balkans:
- "Балканы"
+ localization.text("region.balkans")
case .europe:
- "Европа"
+ localization.text("region.europe")
case .middleEastAfrica:
- "Ближний Восток и Африка"
+ localization.text("region.middleEastAfrica")
case .asia:
- "Азия"
+ localization.text("region.asia")
case .northAmerica:
- "Северная Америка"
+ localization.text("region.northAmerica")
case .latinAmerica:
- "Латинская Америка"
+ localization.text("region.latinAmerica")
case .oceania:
- "Австралия и Океания"
+ localization.text("region.oceania")
+ }
+ }
+
+ var searchTerms: String {
+ switch self {
+ case .balkans:
+ "Balkans Балканы"
+ case .europe:
+ "Europe Европа"
+ case .middleEastAfrica:
+ "Middle East Africa Ближний Восток Африка"
+ case .asia:
+ "Asia Азия"
+ case .northAmerica:
+ "North America Северная Америка"
+ case .latinAmerica:
+ "Latin America Латинская Америка"
+ case .oceania:
+ "Australia Oceania Австралия Океания"
}
}
}
diff --git a/Sources/GeoShift/CityRowView.swift b/Sources/GeoShift/CityRowView.swift
index a075818..db2414a 100644
--- a/Sources/GeoShift/CityRowView.swift
+++ b/Sources/GeoShift/CityRowView.swift
@@ -3,6 +3,7 @@ import SwiftUI
struct CityRowView: View {
let city: City
let isSelected: Bool
+ @Environment(LocalizationStore.self) private var localization
var body: some View {
HStack(spacing: 12) {
@@ -11,8 +12,8 @@ struct CityRowView: View {
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
- Text(city.name)
- Text(city.country)
+ Text(city.localizedName(for: localization.language))
+ Text(city.localizedCountry(for: localization.language))
.font(.footnote)
.foregroundStyle(.secondary)
}
@@ -22,7 +23,7 @@ struct CityRowView: View {
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
- .accessibilityLabel("Выбрано")
+ .accessibilityLabel(localization.text("city.selected"))
}
}
.contentShape(.rect)
diff --git a/Sources/GeoShift/CommandRunner.swift b/Sources/GeoShift/CommandRunner.swift
index 088d148..2571aa8 100644
--- a/Sources/GeoShift/CommandRunner.swift
+++ b/Sources/GeoShift/CommandRunner.swift
@@ -1,37 +1,76 @@
@preconcurrency import Foundation
+private final class CommandExecution: @unchecked Sendable {
+ let process = Process()
+ let outputPipe = Pipe()
+
+ func waitForResult() -> CommandResult {
+ process.waitUntilExit()
+ let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
+ return CommandResult(
+ status: process.terminationStatus,
+ output: String(decoding: data, as: UTF8.self)
+ )
+ }
+
+ func terminate() {
+ guard process.isRunning else {
+ return
+ }
+ process.terminate()
+ }
+
+ func killIfNeeded() {
+ if process.isRunning {
+ kill(process.processIdentifier, SIGKILL)
+ }
+ }
+}
+
actor CommandRunner {
- func run(_ executable: String, arguments: [String]) async -> CommandResult {
- await withCheckedContinuation { continuation in
- let process = Process()
- let outputPipe = Pipe()
-
- process.executableURL = URL(fileURLWithPath: executable)
- process.arguments = arguments
- process.standardOutput = outputPipe
- process.standardError = outputPipe
-
- process.terminationHandler = { finishedProcess in
- let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
- let output = String(decoding: data, as: UTF8.self)
- continuation.resume(
- returning: CommandResult(
- status: finishedProcess.terminationStatus,
- output: output
- )
- )
+ func run(
+ _ executable: String,
+ arguments: [String],
+ timeoutSeconds: Double = 5
+ ) async -> CommandResult {
+ let execution = CommandExecution()
+ execution.process.executableURL = URL(fileURLWithPath: executable)
+ execution.process.arguments = arguments
+ execution.process.standardOutput = execution.outputPipe
+ execution.process.standardError = execution.outputPipe
+
+ do {
+ try execution.process.run()
+ } catch {
+ return CommandResult(status: -1, output: error.localizedDescription)
+ }
+
+ return await withTaskGroup(of: CommandResult.self) { group in
+ group.addTask {
+ execution.waitForResult()
}
+ group.addTask {
+ do {
+ try await Task.sleep(for: .seconds(timeoutSeconds))
+ } catch {
+ return CommandResult(status: -2, output: "The command was cancelled.")
+ }
- do {
- try process.run()
- } catch {
- continuation.resume(
- returning: CommandResult(
- status: -1,
- output: error.localizedDescription
- )
+ execution.terminate()
+ try? await Task.sleep(for: .seconds(2))
+ execution.killIfNeeded()
+ return CommandResult(
+ status: -2,
+ output: "The command timed out after \(Int(timeoutSeconds)) seconds."
)
}
+
+ let result = await group.next() ?? CommandResult(
+ status: -2,
+ output: "The command returned no result."
+ )
+ group.cancelAll()
+ return result
}
}
}
diff --git a/Sources/GeoShift/ConfigStore.swift b/Sources/GeoShift/ConfigStore.swift
index 24dd61c..7ab5f8c 100644
--- a/Sources/GeoShift/ConfigStore.swift
+++ b/Sources/GeoShift/ConfigStore.swift
@@ -13,20 +13,26 @@ enum ConfigStore {
return nil
}
+ @discardableResult
static func save(
city: City,
retrySeconds: Int,
- refreshSeconds: Int
- ) throws {
+ refreshSeconds: Int,
+ simulationEnabled: Bool,
+ requestID: String = UUID().uuidString,
+ appHeartbeatAt: Double? = nil
+ ) throws -> KeeperConfiguration {
let configuration = KeeperConfiguration(
cityID: city.id,
- cityName: city.name,
- country: city.country,
+ cityName: city.localizedName(for: .english),
+ country: city.localizedCountry(for: .english),
latitude: city.latitude,
longitude: city.longitude,
retrySeconds: retrySeconds,
refreshSeconds: refreshSeconds,
- requestID: UUID().uuidString
+ requestID: requestID,
+ simulationEnabled: simulationEnabled,
+ appHeartbeatAt: appHeartbeatAt
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
@@ -37,5 +43,6 @@ enum ConfigStore {
withIntermediateDirectories: true
)
try data.write(to: AppPaths.configurationURL, options: .atomic)
+ return configuration
}
}
diff --git a/Sources/GeoShift/ConnectionHelpView.swift b/Sources/GeoShift/ConnectionHelpView.swift
index cf59e6f..2cf3aab 100644
--- a/Sources/GeoShift/ConnectionHelpView.swift
+++ b/Sources/GeoShift/ConnectionHelpView.swift
@@ -1,17 +1,11 @@
import SwiftUI
struct ConnectionHelpView: View {
- var body: some View {
- VStack(alignment: .leading, spacing: 8) {
- Label("Как подключить iPhone", systemImage: "cable.connector")
- .font(.headline)
+ @Bindable var controller: KeeperController
- Text("1. Надёжно: подключите кабель и нажмите «Доверять». Приложение само включит дальнейшее подключение по Wi‑Fi.")
- Text("2. Без кабеля: Mac и iPhone должны быть в одной Wi‑Fi‑сети.")
- Text("3. Если на iPhone включён VPN, разрешите ему локальную сеть или временно отключите VPN для подключения.")
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(14)
- .background(.orange.opacity(0.08), in: .rect(cornerRadius: 10))
+ var body: some View {
+ PairingGuideView(controller: controller)
+ .padding(14)
+ .background(.orange.opacity(0.08), in: .rect(cornerRadius: 10))
}
}
diff --git a/Sources/GeoShift/ContentView.swift b/Sources/GeoShift/ContentView.swift
index 969b0ac..618a4ec 100644
--- a/Sources/GeoShift/ContentView.swift
+++ b/Sources/GeoShift/ContentView.swift
@@ -2,6 +2,7 @@ import SwiftUI
struct ContentView: View {
@Bindable var controller: KeeperController
+ @Environment(LocalizationStore.self) private var localization
@State private var isCityPickerPresented = false
@State private var isSettingsPresented = false
@@ -19,8 +20,8 @@ struct ContentView: View {
)
ControlBar(controller: controller)
- if controller.state == .waiting {
- ConnectionHelpView()
+ if controller.state == .waiting || controller.state == .restoring {
+ ConnectionHelpView(controller: controller)
}
if let errorMessage = controller.errorMessage {
@@ -29,7 +30,7 @@ struct ContentView: View {
LogPanel(logs: controller.logs)
- Text("Command‑Q закрывает приложение, но keeper продолжает работать. Для возврата реального GPS нажмите Stop.")
+ Text(localization.text("content.safetyFootnote"))
.font(.footnote)
.foregroundStyle(.secondary)
}
diff --git a/Sources/GeoShift/ControlBar.swift b/Sources/GeoShift/ControlBar.swift
index d987f6e..75ea639 100644
--- a/Sources/GeoShift/ControlBar.swift
+++ b/Sources/GeoShift/ControlBar.swift
@@ -2,11 +2,12 @@ import SwiftUI
struct ControlBar: View {
let controller: KeeperController
+ @Environment(LocalizationStore.self) private var localization
var body: some View {
HStack(spacing: 10) {
Button(
- "Start",
+ localization.text("control.start"),
systemImage: "play.fill",
action: controller.startAction
)
@@ -19,14 +20,14 @@ struct ControlBar: View {
)
Button(
- "Stop",
+ localization.text("control.restore"),
systemImage: "stop.fill",
action: controller.stopAction
)
.disabled(controller.isBusy || controller.state == .stopped)
Button(
- "Restart",
+ localization.text("control.restart"),
systemImage: "arrow.clockwise",
action: controller.restartAction
)
@@ -35,7 +36,7 @@ struct ControlBar: View {
Spacer()
Button(
- "Refresh",
+ localization.text("control.refresh"),
systemImage: "arrow.trianglehead.2.clockwise",
action: controller.refreshAction
)
diff --git a/Sources/GeoShift/DestinationCard.swift b/Sources/GeoShift/DestinationCard.swift
index 8d63b17..0462f14 100644
--- a/Sources/GeoShift/DestinationCard.swift
+++ b/Sources/GeoShift/DestinationCard.swift
@@ -1,32 +1,72 @@
+import MapKit
import SwiftUI
struct DestinationCard: View {
let city: City
let chooseAction: () -> Void
+ @Environment(LocalizationStore.self) private var localization
+
+ private var coordinate: CLLocationCoordinate2D {
+ CLLocationCoordinate2D(latitude: city.latitude, longitude: city.longitude)
+ }
+
+ private var cameraPosition: MapCameraPosition {
+ .region(
+ MKCoordinateRegion(
+ center: coordinate,
+ span: MKCoordinateSpan(latitudeDelta: 0.4, longitudeDelta: 0.4)
+ )
+ )
+ }
var body: some View {
- HStack(spacing: 16) {
- Text(city.flag)
- .font(.largeTitle)
- .accessibilityHidden(true)
-
- VStack(alignment: .leading, spacing: 3) {
- Text(city.name)
- .font(.title2)
- .bold()
- Text(city.subtitle)
- .foregroundStyle(.secondary)
- }
+ VStack(spacing: 0) {
+ HStack(spacing: 16) {
+ Text(city.flag)
+ .font(.largeTitle)
+ .accessibilityHidden(true)
- Spacer()
+ VStack(alignment: .leading, spacing: 3) {
+ Text(city.localizedName(for: localization.language))
+ .font(.title2)
+ .bold()
+ Text("\(city.localizedCountry(for: localization.language)) · \(city.region.title(using: localization))")
+ .foregroundStyle(.secondary)
+ }
- Button(
- "Выбрать город",
- systemImage: "globe.europe.africa",
- action: chooseAction
- )
+ Spacer()
+
+ VStack(alignment: .trailing, spacing: 6) {
+ Button(
+ localization.text("city.choose"),
+ systemImage: "globe.europe.africa",
+ action: chooseAction
+ )
+
+ Text(coordinateText)
+ .font(.caption.monospaced())
+ .foregroundStyle(.tertiary)
+ .textSelection(.enabled)
+ }
+ }
+ .padding(16)
+
+ Map(position: .constant(cameraPosition), interactionModes: []) {
+ Marker(city.localizedName(for: localization.language), coordinate: coordinate)
+ .tint(.red)
+ }
+ .frame(height: 150)
+ .allowsHitTesting(false)
+ .id(city.id)
+ .accessibilityHidden(true)
}
- .padding(16)
.background(.quaternary, in: .rect(cornerRadius: 14))
+ .clipShape(.rect(cornerRadius: 14))
+ }
+
+ private var coordinateText: String {
+ let precision = FloatingPointFormatStyle.number
+ .precision(.fractionLength(4))
+ return "\(city.latitude.formatted(precision)), \(city.longitude.formatted(precision))"
}
}
diff --git a/Sources/GeoShift/GeoShiftApp.swift b/Sources/GeoShift/GeoShiftApp.swift
index b8b8e41..78820c2 100644
--- a/Sources/GeoShift/GeoShiftApp.swift
+++ b/Sources/GeoShift/GeoShiftApp.swift
@@ -2,12 +2,25 @@ import SwiftUI
@main
struct GeoShiftApp: App {
- @State private var controller = KeeperController()
+ @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
+ @State private var controller: KeeperController
+ @State private var localization: LocalizationStore
+
+ init() {
+ let localization = LocalizationStore()
+ _localization = State(initialValue: localization)
+ _controller = State(initialValue: KeeperController(localization: localization))
+ }
var body: some Scene {
WindowGroup("GeoShift") {
ContentView(controller: controller)
+ .environment(localization)
+ .environment(\.locale, localization.language.locale)
.frame(minWidth: 680, minHeight: 680)
+ .onAppear {
+ appDelegate.controller = controller
+ }
}
.defaultSize(width: 760, height: 820)
.windowResizability(.contentMinSize)
diff --git a/Sources/GeoShift/HeaderView.swift b/Sources/GeoShift/HeaderView.swift
index 12f9b1c..c739baf 100644
--- a/Sources/GeoShift/HeaderView.swift
+++ b/Sources/GeoShift/HeaderView.swift
@@ -2,6 +2,7 @@ import SwiftUI
struct HeaderView: View {
let settingsAction: () -> Void
+ @Environment(LocalizationStore.self) private var localization
var body: some View {
HStack(alignment: .top) {
@@ -9,14 +10,14 @@ struct HeaderView: View {
Text("GeoShift")
.font(.largeTitle)
.bold()
- Text("Управление симуляцией GPS для подключённого iPhone")
+ Text(localization.text("header.subtitle"))
.foregroundStyle(.secondary)
}
Spacer()
Button(
- "Настройки",
+ localization.text("header.settings"),
systemImage: "gearshape",
action: settingsAction
)
diff --git a/Sources/GeoShift/KeeperConfiguration.swift b/Sources/GeoShift/KeeperConfiguration.swift
index 32058f0..f6aadd4 100644
--- a/Sources/GeoShift/KeeperConfiguration.swift
+++ b/Sources/GeoShift/KeeperConfiguration.swift
@@ -7,4 +7,49 @@ struct KeeperConfiguration: Codable, Equatable, Sendable {
let retrySeconds: Int
let refreshSeconds: Int
let requestID: String
+ let simulationEnabled: Bool
+ let appHeartbeatAt: Double?
+
+ init(
+ cityID: String,
+ cityName: String,
+ country: String,
+ latitude: Double,
+ longitude: Double,
+ retrySeconds: Int,
+ refreshSeconds: Int,
+ requestID: String,
+ simulationEnabled: Bool = true,
+ appHeartbeatAt: Double? = nil
+ ) {
+ self.cityID = cityID
+ self.cityName = cityName
+ self.country = country
+ self.latitude = latitude
+ self.longitude = longitude
+ self.retrySeconds = retrySeconds
+ self.refreshSeconds = refreshSeconds
+ self.requestID = requestID
+ self.simulationEnabled = simulationEnabled
+ self.appHeartbeatAt = appHeartbeatAt
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case cityID, cityName, country, latitude, longitude
+ case retrySeconds, refreshSeconds, requestID, simulationEnabled, appHeartbeatAt
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ cityID = try container.decode(String.self, forKey: .cityID)
+ cityName = try container.decode(String.self, forKey: .cityName)
+ country = try container.decode(String.self, forKey: .country)
+ latitude = try container.decode(Double.self, forKey: .latitude)
+ longitude = try container.decode(Double.self, forKey: .longitude)
+ retrySeconds = try container.decode(Int.self, forKey: .retrySeconds)
+ refreshSeconds = try container.decode(Int.self, forKey: .refreshSeconds)
+ requestID = try container.decode(String.self, forKey: .requestID)
+ simulationEnabled = try container.decodeIfPresent(Bool.self, forKey: .simulationEnabled) ?? true
+ appHeartbeatAt = try container.decodeIfPresent(Double.self, forKey: .appHeartbeatAt)
+ }
}
diff --git a/Sources/GeoShift/KeeperController.swift b/Sources/GeoShift/KeeperController.swift
index 9d9ba0b..a11c7be 100644
--- a/Sources/GeoShift/KeeperController.swift
+++ b/Sources/GeoShift/KeeperController.swift
@@ -4,15 +4,43 @@ import Observation
@MainActor
@Observable
final class KeeperController {
+ private static let heartbeatInterval: Double = 30
+ private static let watchdogInterval: Double = 5 * 60
+
+ private let localization: LocalizationStore
private let runner = CommandRunner()
+ private let pairingRunner = CommandRunner()
private let label = "com.lemelson.geoshift.keeper"
+ private var didRecoverOnLaunch = false
+ private var lastHeartbeatWrite = 0.0
+ private var lastWatchdogCheck = 0.0
+ private var lastLogRefresh = 0.0
- private var userID: uid_t {
- getuid()
+ var state: KeeperState = .working
+ var detail: String
+ var logs = ""
+ var isBusy = false
+ var errorMessage: String?
+ var selectedCity: City
+ var retrySeconds: Int
+ var locationRefreshSeconds: Int
+ private(set) var simulationEnabled: Bool
+ private(set) var pairingStatus: PairingStatus?
+ private(set) var isPairing = false
+ private(set) var hasWirelessPairing = PairingStatusStore.hasWirelessPairing
+
+ var requiresClearBeforeTermination: Bool {
+ if ConfigStore.load()?.simulationEnabled == true {
+ return true
+ }
+ if WorkerStatusStore.load()?.simulationMayBeActive == true {
+ return true
+ }
+ return state != .stopped && ConfigStore.load() != nil
}
private var domain: String {
- "gui/\(userID)"
+ "gui/\(getuid())"
}
private var serviceTarget: String {
@@ -24,238 +52,301 @@ final class KeeperController {
.appending(path: "Library/LaunchAgents/\(label).plist")
}
- private var logURL: URL {
- AppPaths.logURL
- }
-
- var state: KeeperState = .working
- var detail = "Проверяю фоновый сервис…"
- var logs = ""
- var isBusy = false
- var errorMessage: String?
- var selectedCity: City
- var retrySeconds: Int
- var locationRefreshSeconds: Int
-
- init() {
+ init(localization: LocalizationStore) {
+ self.localization = localization
let saved = ConfigStore.load()
selectedCity = saved.flatMap { CityCatalog.city(withID: $0.cityID) } ?? CityCatalog.defaultCity
retrySeconds = saved?.retrySeconds ?? 5
locationRefreshSeconds = saved?.refreshSeconds ?? 10
+ simulationEnabled = saved?.simulationEnabled ?? false
+ let savedPairingStatus = PairingStatusStore.load()
+ if savedPairingStatus?.phase == .advertising || savedPairingStatus?.phase == .codeReady {
+ pairingStatus = nil
+ } else {
+ pairingStatus = savedPairingStatus
+ }
+ detail = localization.text("controller.checkingService")
}
func startAction() {
- Task {
- await installAndStart()
- }
+ Task { await installAndStart() }
}
func stopAction() {
- Task {
- await stop()
- }
+ Task { await stop() }
}
func restartAction() {
- Task {
- await hardRestart()
- }
+ Task { await hardRestart() }
}
func refreshAction() {
- Task {
- await reconnectNow()
- }
+ Task { await reconnectNow() }
+ }
+
+ func startPairingAction() {
+ Task { await startPairing() }
}
func selectCity(_ city: City) {
selectedCity = city
- requestImmediateUpdate(message: "Передаю координаты: \(city.name)…")
+ requestImmediateUpdate(
+ message: "\(localization.text("controller.sendingCoordinates")) \(city.localizedName(for: localization.language))…"
+ )
}
func applySettings() {
- requestImmediateUpdate(message: "Применяю новые интервалы…")
+ requestImmediateUpdate(message: localization.text("controller.applyingSettings"))
+ }
+
+ func languageDidChange() {
+ if errorMessage != nil {
+ errorMessage = localization.text("error.previousOperation")
+ }
+ detail = statusDetail(
+ state: state,
+ configuration: ConfigStore.load()
+ )
}
func poll() async {
+ await recoverOnLaunch()
+
while !Task.isCancelled {
+ let now = Date.now.timeIntervalSince1970
+ if simulationEnabled, now - lastHeartbeatWrite >= Self.heartbeatInterval {
+ refreshHeartbeat(at: now)
+ }
+ if now - lastWatchdogCheck >= Self.watchdogInterval {
+ lastWatchdogCheck = now
+ await repairWorkerIfNeeded()
+ }
+ if now - lastLogRefresh >= 10 {
+ lastLogRefresh = now
+ refreshLogs()
+ }
+
+ pairingStatus = PairingStatusStore.load()
+ hasWirelessPairing = PairingStatusStore.hasWirelessPairing
await refreshStatus()
try? await Task.sleep(for: .seconds(1))
}
}
func installAndStart() async {
- guard !isBusy else {
+ await transition(
+ simulationEnabled: true,
+ message: localization.text("controller.startingTunnel")
+ )
+ }
+
+ func stop() async {
+ if successfulClearIsRecorded() {
+ await refreshStatus()
return
}
-
- isBusy = true
- errorMessage = nil
- state = .working
- detail = "Устанавливаю фоновый сервис…"
- defer {
- isBusy = false
+ if ConfigStore.load()?.simulationEnabled == false {
+ await continuePendingClear()
+ return
}
+ await transition(
+ simulationEnabled: false,
+ message: localization.text("controller.restoring")
+ )
+ }
- do {
- try saveConfiguration()
- try installLaunchAgent()
- _ = await runner.run("/bin/launchctl", arguments: ["bootout", serviceTarget])
-
- let bootstrap = await runner.run(
- "/bin/launchctl",
- arguments: ["bootstrap", domain, launchAgentURL.path()]
- )
- guard bootstrap.succeeded else {
- throw KeeperError.commandFailed(bootstrap.output)
- }
-
- _ = await runner.run("/bin/launchctl", arguments: ["enable", serviceTarget])
- let kickstart = await runner.run(
- "/bin/launchctl",
- arguments: ["kickstart", "-k", serviceTarget]
- )
- guard kickstart.succeeded else {
- throw KeeperError.commandFailed(kickstart.output)
- }
-
- try? await Task.sleep(for: .milliseconds(500))
- await refreshStatus()
- } catch {
- state = .failed
- detail = "Не удалось запустить keeper"
- errorMessage = error.localizedDescription
+ func stopBeforeQuit() async -> Bool {
+ for _ in 0..<100 where isBusy {
+ try? await Task.sleep(for: .milliseconds(100))
+ }
+ guard !isBusy else {
+ errorMessage = localization.text("controller.quitBusy")
+ return false
+ }
+ await stop()
+ let handoffSucceeded = await terminationSafetyHandoffSucceeded()
+ if !handoffSucceeded {
+ errorMessage = localization.text("controller.quitHandoff")
}
+ return handoffSucceeded
}
- func stop() async {
+ func hardRestart() async {
guard !isBusy else {
return
}
-
isBusy = true
errorMessage = nil
- state = .working
- detail = "Останавливаю и возвращаю реальный GPS…"
- defer {
- isBusy = false
- }
+ state = simulationEnabled ? .working : .restoring
+ detail = localization.text("controller.hardRestart")
- _ = await runner.run("/bin/launchctl", arguments: ["bootout", serviceTarget])
- if
- let pythonPath = DependencyLocator.pymobiledevicePython(),
- let keeperURL = Bundle.main.url(forResource: "keeper", withExtension: "py")
- {
- _ = await runner.run(pythonPath, arguments: [keeperURL.path(), "--clear"])
+ do {
+ let requestID = ConfigStore.load()?.requestID
+ let configuration = try saveConfiguration(requestID: requestID)
+ try installLaunchAgent()
+ try await ensureLaunchAgentRunning(forceRestart: true)
+ await waitForWorkerAcknowledgement(
+ requestID: configuration.requestID,
+ simulationEnabled: configuration.simulationEnabled
+ )
+ } catch {
+ state = .failed
+ errorMessage = localizedDescription(for: error)
}
- state = .stopped
- detail = "Используется реальная геопозиция"
- refreshLogs()
+ isBusy = false
+ await refreshStatus()
}
- func hardRestart() async {
+ func reconnectNow() async {
guard !isBusy else {
return
}
-
- let status = await runner.run("/bin/launchctl", arguments: ["print", serviceTarget])
- guard status.succeeded else {
- await installAndStart()
- return
- }
-
isBusy = true
errorMessage = nil
- state = .working
- detail = "Полностью перезапускаю developer‑туннель…"
+ detail = localization.text("controller.reconnecting")
do {
- try saveConfiguration()
- let result = await runner.run(
- "/bin/launchctl",
- arguments: ["kickstart", "-k", serviceTarget]
+ let requestID = ConfigStore.load()?.requestID
+ let configuration = try saveConfiguration(requestID: requestID)
+ try installLaunchAgent()
+ try await ensureLaunchAgentRunning(forceRestart: false)
+ await waitForWorkerAcknowledgement(
+ requestID: configuration.requestID,
+ simulationEnabled: configuration.simulationEnabled
)
- guard result.succeeded else {
- throw KeeperError.commandFailed(result.output)
- }
} catch {
- errorMessage = error.localizedDescription
state = .failed
+ errorMessage = localizedDescription(for: error)
}
isBusy = false
- try? await Task.sleep(for: .milliseconds(500))
await refreshStatus()
}
- func reconnectNow() async {
+ func refreshStatus() async {
guard !isBusy else {
return
}
- let status = await runner.run("/bin/launchctl", arguments: ["print", serviceTarget])
- guard status.succeeded else {
- await installAndStart()
+ let launchctl = await runner.run(
+ "/bin/launchctl",
+ arguments: ["print", serviceTarget]
+ )
+ let configuration = ConfigStore.load()
+ let workerStatus = WorkerStatusStore.load()
+
+ simulationEnabled = configuration?.simulationEnabled ?? false
+ state = StatusParser.state(
+ launchctlOutput: launchctl.output,
+ configuration: configuration,
+ workerStatus: workerStatus
+ )
+ detail = statusDetail(
+ state: state,
+ configuration: configuration
+ )
+ }
+
+ private func recoverOnLaunch() async {
+ guard !didRecoverOnLaunch else {
+ return
+ }
+ didRecoverOnLaunch = true
+
+ // Keep the on-disk LaunchAgent definition current even when the last
+ // A successful clear send is already recorded and no worker needs to
+ // run right now.
+ if ConfigStore.load() != nil {
+ do {
+ try installLaunchAgent()
+ } catch {
+ state = .failed
+ errorMessage = "\(localization.text("controller.watchdogUpdateFailed")): \(localizedDescription(for: error))"
+ }
+ }
+
+ if ConfigStore.load()?.simulationEnabled == true {
+ await transition(
+ simulationEnabled: false,
+ message: localization.text("controller.recoverySession")
+ )
+ } else {
+ await repairWorkerIfNeeded()
+ }
+ }
+
+ private func transition(
+ simulationEnabled requestedValue: Bool,
+ message: String
+ ) async {
+ guard !isBusy else {
return
}
isBusy = true
errorMessage = nil
- state = .working
- detail = "Немедленная попытка подключения…"
+ state = requestedValue ? .working : .restoring
+ detail = message
do {
- try saveConfiguration()
- try? await Task.sleep(for: .milliseconds(350))
+ simulationEnabled = requestedValue
+ let configuration = try saveConfiguration()
+ try installLaunchAgent()
+ // The running worker watches config.json. Do not tear down a healthy
+ // tunnel during a normal Start/Stop transition.
+ try await ensureLaunchAgentRunning(forceRestart: false)
+ await waitForWorkerAcknowledgement(
+ requestID: configuration.requestID,
+ simulationEnabled: requestedValue
+ )
} catch {
- errorMessage = error.localizedDescription
state = .failed
+ detail = requestedValue
+ ? localization.text("controller.startFailed")
+ : localization.text("controller.restoreFailed")
+ errorMessage = localizedDescription(for: error)
}
isBusy = false
await refreshStatus()
}
- func refreshStatus() async {
- guard !isBusy else {
- return
- }
-
- let status = await runner.run("/bin/launchctl", arguments: ["print", serviceTarget])
- refreshLogs()
-
- guard status.succeeded else {
- state = .stopped
- detail = "Используется реальная геопозиция"
+ private func continuePendingClear() async {
+ guard !isBusy, let configuration = ConfigStore.load() else {
return
}
-
- state = StatusParser.state(launchctlOutput: status.output, logs: logs)
- detail = switch state {
- case .stopped:
- "Используется реальная геопозиция"
- case .waiting:
- "macOS не видит iPhone по USB или локальной Wi‑Fi‑сети"
- case .active:
- "\(selectedCity.name), \(selectedCity.country) · GPS каждые \(locationRefreshSeconds) сек"
- case .working:
- "Фоновый сервис запускается"
- case .failed:
- "Нажмите Restart; подробности находятся ниже"
+ isBusy = true
+ state = .restoring
+ detail = localization.text("controller.checkingQueue")
+ do {
+ try installLaunchAgent()
+ try await ensureLaunchAgentRunning(forceRestart: false)
+ await waitForWorkerAcknowledgement(
+ requestID: configuration.requestID,
+ simulationEnabled: false
+ )
+ } catch {
+ state = .failed
+ errorMessage = localizedDescription(for: error)
}
+ isBusy = false
+ await refreshStatus()
}
private func requestImmediateUpdate(message: String) {
errorMessage = nil
- state = .working
detail = message
do {
- try saveConfiguration()
+ let existingRequestID = simulationEnabled ? nil : ConfigStore.load()?.requestID
+ _ = try saveConfiguration(requestID: existingRequestID)
+ if simulationEnabled {
+ state = .working
+ }
} catch {
state = .failed
- errorMessage = error.localizedDescription
+ errorMessage = localizedDescription(for: error)
return
}
@@ -265,15 +356,70 @@ final class KeeperController {
}
}
- private func refreshLogs() {
- logs = LogReader.tail(of: logURL)
+ private func refreshHeartbeat(at timestamp: Double) {
+ guard simulationEnabled, let requestID = ConfigStore.load()?.requestID else {
+ return
+ }
+ do {
+ _ = try saveConfiguration(requestID: requestID, heartbeatAt: timestamp)
+ lastHeartbeatWrite = timestamp
+ } catch {
+ state = .failed
+ errorMessage = "\(localization.text("controller.watchdogUpdateFailed")): \(localizedDescription(for: error))"
+ }
+ }
+
+ private func repairWorkerIfNeeded() async {
+ guard !isBusy, let configuration = ConfigStore.load() else {
+ return
+ }
+ let workerStatus = WorkerStatusStore.load()
+ if !configuration.simulationEnabled,
+ workerStatus?.requestID == configuration.requestID,
+ workerStatus?.phase == .cleared,
+ workerStatus?.simulationMayBeActive == false {
+ return
+ }
+
+ let launchctl = await runner.run(
+ "/bin/launchctl",
+ arguments: ["print", serviceTarget]
+ )
+ let statusAge = Date.now.timeIntervalSince1970 - (workerStatus?.updatedAt ?? 0)
+ let heartbeatLimit = max(Double(configuration.refreshSeconds * 2), 30)
+ let workerLooksHealthy = launchctl.output.contains("state = running")
+ && workerStatus?.requestID == configuration.requestID
+ && statusAge <= heartbeatLimit
+ guard !workerLooksHealthy else {
+ return
+ }
+
+ do {
+ try installLaunchAgent()
+ try await ensureLaunchAgentRunning(forceRestart: launchctl.succeeded)
+ } catch {
+ state = .failed
+ errorMessage = "\(localization.text("controller.watchdogRepairFailed")): \(localizedDescription(for: error))"
+ }
}
- private func saveConfiguration() throws {
- try ConfigStore.save(
+ private func saveConfiguration(
+ requestID: String? = nil,
+ heartbeatAt: Double? = nil
+ ) throws -> KeeperConfiguration {
+ let heartbeat = simulationEnabled
+ ? heartbeatAt ?? Date.now.timeIntervalSince1970
+ : nil
+ if simulationEnabled {
+ lastHeartbeatWrite = heartbeat ?? 0
+ }
+ return try ConfigStore.save(
city: selectedCity,
retrySeconds: retrySeconds,
- refreshSeconds: locationRefreshSeconds
+ refreshSeconds: locationRefreshSeconds,
+ simulationEnabled: simulationEnabled,
+ requestID: requestID ?? UUID().uuidString,
+ appHeartbeatAt: heartbeat
)
}
@@ -281,12 +427,10 @@ final class KeeperController {
guard let pythonPath = DependencyLocator.pymobiledevicePython() else {
throw KeeperError.missingDependency("pymobiledevice3")
}
-
guard let keeperURL = Bundle.main.url(forResource: "keeper", withExtension: "py") else {
throw KeeperError.missingKeeper
}
- let logPath = logURL.path()
let propertyList: [String: Any] = [
"Label": label,
"ProgramArguments": LaunchAgentConfiguration.programArguments(
@@ -294,12 +438,12 @@ final class KeeperController {
keeperURL: keeperURL
),
"RunAtLoad": true,
- "KeepAlive": true,
+ "KeepAlive": ["SuccessfulExit": false],
"ThrottleInterval": 5,
- "StandardOutPath": logPath,
- "StandardErrorPath": logPath,
+ "ExitTimeOut": 20,
+ "StandardOutPath": "/dev/null",
+ "StandardErrorPath": "/dev/null",
]
-
let data = try PropertyListSerialization.data(
fromPropertyList: propertyList,
format: .xml,
@@ -312,4 +456,177 @@ final class KeeperController {
)
try data.write(to: launchAgentURL, options: .atomic)
}
+
+ private func ensureLaunchAgentRunning(forceRestart: Bool) async throws {
+ let existing = await runner.run(
+ "/bin/launchctl",
+ arguments: ["print", serviceTarget]
+ )
+ if existing.output.contains("state = running"), !forceRestart {
+ return
+ }
+ if existing.succeeded {
+ let bootout = await runner.run(
+ "/bin/launchctl",
+ arguments: ["bootout", serviceTarget]
+ )
+ guard bootout.succeeded else {
+ throw KeeperError.commandFailed(bootout.output)
+ }
+ try await waitUntilLaunchAgentIsUnloaded()
+ }
+
+ let enable = await runner.run(
+ "/bin/launchctl",
+ arguments: ["enable", serviceTarget]
+ )
+ guard enable.succeeded else {
+ throw KeeperError.commandFailed(enable.output)
+ }
+
+ let bootstrap = await runner.run(
+ "/bin/launchctl",
+ arguments: ["bootstrap", domain, launchAgentURL.path()]
+ )
+ guard bootstrap.succeeded else {
+ throw KeeperError.commandFailed(bootstrap.output)
+ }
+ }
+
+ private func waitUntilLaunchAgentIsUnloaded() async throws {
+ for _ in 0..<60 {
+ let check = await runner.run(
+ "/bin/launchctl",
+ arguments: ["print", serviceTarget]
+ )
+ if !check.succeeded {
+ return
+ }
+ try await Task.sleep(for: .milliseconds(250))
+ }
+ throw KeeperError.commandFailed(localization.text("controller.previousWorkerTimeout"))
+ }
+
+ private func waitForWorkerAcknowledgement(
+ requestID: String,
+ simulationEnabled: Bool
+ ) async {
+ for _ in 0..<24 {
+ if let status = WorkerStatusStore.load(), status.requestID == requestID {
+ if simulationEnabled {
+ if [.active, .waitingForDevice, .failed].contains(status.phase) {
+ return
+ }
+ } else if [.cleared, .clearPending, .failed].contains(status.phase) {
+ return
+ }
+ }
+ try? await Task.sleep(for: .milliseconds(250))
+ }
+ }
+
+ private func successfulClearIsRecorded() -> Bool {
+ guard let configuration = ConfigStore.load(),
+ let status = WorkerStatusStore.load() else {
+ return false
+ }
+ return !configuration.simulationEnabled
+ && status.requestID == configuration.requestID
+ && status.phase == .cleared
+ && !status.simulationMayBeActive
+ }
+
+ private func terminationSafetyHandoffSucceeded() async -> Bool {
+ guard let configuration = ConfigStore.load(),
+ !configuration.simulationEnabled,
+ let status = WorkerStatusStore.load(),
+ status.requestID == configuration.requestID else {
+ return false
+ }
+ if status.phase == .cleared, !status.simulationMayBeActive {
+ return true
+ }
+ guard status.phase == .clearPending || status.phase == .clearing else {
+ return false
+ }
+ let launchctl = await runner.run(
+ "/bin/launchctl",
+ arguments: ["print", serviceTarget]
+ )
+ return launchctl.output.contains("state = running")
+ }
+
+ private func startPairing() async {
+ guard !isPairing else {
+ return
+ }
+ guard let pythonPath = DependencyLocator.pymobiledevicePython(),
+ let helperURL = Bundle.main.url(forResource: "pairing", withExtension: "py") else {
+ errorMessage = localization.text("controller.pairingModuleMissing")
+ return
+ }
+
+ try? FileManager.default.removeItem(at: AppPaths.pairingStatusURL)
+ errorMessage = nil
+ pairingStatus = PairingStatus(
+ phase: .advertising,
+ code: nil,
+ deviceUDID: nil,
+ message: localization.text("controller.pairingStarting"),
+ updatedAt: Date.now.timeIntervalSince1970
+ )
+ isPairing = true
+
+ let result = await pairingRunner.run(
+ pythonPath,
+ arguments: [helperURL.path()],
+ timeoutSeconds: 190
+ )
+ pairingStatus = PairingStatusStore.load()
+ hasWirelessPairing = PairingStatusStore.hasWirelessPairing
+ isPairing = false
+
+ if pairingStatus?.phase == .paired {
+ errorMessage = nil
+ await reconnectNow()
+ } else if !result.succeeded {
+ errorMessage = localization.text("pairing.failed")
+ }
+ }
+
+ private func statusDetail(
+ state: KeeperState,
+ configuration: KeeperConfiguration?
+ ) -> String {
+ return switch state {
+ case .stopped:
+ localization.text("status.cleared")
+ case .restoring:
+ localization.text("status.restorePending")
+ case .waiting:
+ localization.text("status.phoneUnavailable")
+ case .active:
+ if let configuration,
+ let city = CityCatalog.city(withID: configuration.cityID) {
+ "\(city.localizedName(for: localization.language)), \(city.localizedCountry(for: localization.language)) · \(localization.text("status.gpsEvery")) \(configuration.refreshSeconds) \(localization.text("unit.secondsShort"))"
+ } else {
+ localization.text("status.active")
+ }
+ case .working:
+ localization.text("status.connecting")
+ case .failed:
+ localization.text("status.notConfirmed")
+ }
+ }
+
+ private func refreshLogs() {
+ logs = LogReader.tail(of: AppPaths.logURL)
+ }
+
+ private func localizedDescription(for error: Error) -> String {
+ if let keeperError = error as? KeeperError {
+ return keeperError.description(using: localization)
+ }
+ return error.localizedDescription
+ }
}
diff --git a/Sources/GeoShift/KeeperError.swift b/Sources/GeoShift/KeeperError.swift
index c07ab18..5fbae5c 100644
--- a/Sources/GeoShift/KeeperError.swift
+++ b/Sources/GeoShift/KeeperError.swift
@@ -8,11 +8,26 @@ enum KeeperError: LocalizedError {
var errorDescription: String? {
switch self {
case .commandFailed(let output):
- output.isEmpty ? "Команда завершилась с ошибкой." : output
+ output.isEmpty ? "The command failed." : output
case .missingDependency(let path):
- "Не найден pymobiledevice3: \(path)"
+ "pymobiledevice3 was not found: \(path)"
case .missingKeeper:
- "В приложении отсутствует keeper.py."
+ "keeper.py is missing from the app bundle."
+ }
+ }
+
+ @MainActor
+ func description(using localization: LocalizationStore) -> String {
+ switch self {
+ case .commandFailed:
+ // Process and launchctl output is diagnostic data, not UI copy.
+ // Keep the error banner localized instead of exposing arbitrary
+ // English system messages when the app is set to Russian.
+ localization.text("error.commandFailed")
+ case .missingDependency(let path):
+ "\(localization.text("error.missingDependency")): \(path)"
+ case .missingKeeper:
+ localization.text("error.missingKeeper")
}
}
}
diff --git a/Sources/GeoShift/KeeperState.swift b/Sources/GeoShift/KeeperState.swift
index 623a5c9..a453817 100644
--- a/Sources/GeoShift/KeeperState.swift
+++ b/Sources/GeoShift/KeeperState.swift
@@ -2,30 +2,36 @@ import SwiftUI
enum KeeperState: Sendable {
case stopped
+ case restoring
case waiting
case active
case working
case failed
- var title: String {
+ @MainActor
+ func title(using localization: LocalizationStore) -> String {
switch self {
case .stopped:
- "Остановлено"
+ localization.text("state.stopped")
+ case .restoring:
+ localization.text("state.restoring")
case .waiting:
- "Ожидание iPhone"
+ localization.text("state.waiting")
case .active:
- "Белград активен"
+ localization.text("state.active")
case .working:
- "Подключение…"
+ localization.text("state.working")
case .failed:
- "Требуется внимание"
+ localization.text("state.failed")
}
}
var systemImage: String {
switch self {
case .stopped:
- "stop.circle"
+ "location.slash.circle"
+ case .restoring:
+ "iphone.gen3.radiowaves.left.and.right"
case .waiting:
"iphone.gen3.badge.exclamationmark"
case .active:
@@ -40,7 +46,9 @@ enum KeeperState: Sendable {
var color: Color {
switch self {
case .stopped:
- .secondary
+ .green
+ case .restoring:
+ .orange
case .waiting:
.orange
case .active:
diff --git a/Sources/GeoShift/LocalizationStore.swift b/Sources/GeoShift/LocalizationStore.swift
new file mode 100644
index 0000000..8d335b0
--- /dev/null
+++ b/Sources/GeoShift/LocalizationStore.swift
@@ -0,0 +1,40 @@
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+final class LocalizationStore {
+ private static let defaultsKey = "appLanguage"
+ private let defaults: UserDefaults
+
+ var language: AppLanguage {
+ didSet {
+ defaults.set(language.rawValue, forKey: Self.defaultsKey)
+ }
+ }
+
+ init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ if let saved = defaults.string(forKey: Self.defaultsKey),
+ let language = AppLanguage(rawValue: saved) {
+ self.language = language
+ } else {
+ language = .english
+ }
+ }
+
+ func text(_ key: String) -> String {
+ #if DEBUG
+ let resources = Bundle.main.path(forResource: "en", ofType: "lproj") == nil
+ ? Bundle.module
+ : Bundle.main
+ #else
+ let resources = Bundle.main
+ #endif
+ guard let path = resources.path(forResource: language.rawValue, ofType: "lproj"),
+ let bundle = Bundle(path: path) else {
+ return key
+ }
+ return bundle.localizedString(forKey: key, value: key, table: nil)
+ }
+}
diff --git a/Sources/GeoShift/LogPanel.swift b/Sources/GeoShift/LogPanel.swift
index 9100077..2a357fa 100644
--- a/Sources/GeoShift/LogPanel.swift
+++ b/Sources/GeoShift/LogPanel.swift
@@ -2,25 +2,31 @@ import SwiftUI
struct LogPanel: View {
let logs: String
+ @State private var isExpanded = false
+ @Environment(LocalizationStore.self) private var localization
var body: some View {
- GroupBox("Logs") {
- if logs.isEmpty {
- ContentUnavailableView(
- "Журнал пока пуст",
- systemImage: "doc.text"
- )
- .frame(maxWidth: .infinity, minHeight: 180)
- } else {
- ScrollView {
- Text(logs)
- .font(.system(.footnote, design: .monospaced))
- .textSelection(.enabled)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(10)
+ GroupBox {
+ DisclosureGroup(isExpanded: $isExpanded) {
+ if logs.isEmpty {
+ ContentUnavailableView(
+ localization.text("logs.empty"),
+ systemImage: "doc.text"
+ )
+ .frame(maxWidth: .infinity, minHeight: 120)
+ } else {
+ ScrollView {
+ Text(logs)
+ .font(.system(.footnote, design: .monospaced))
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.top, 10)
+ }
+ .defaultScrollAnchor(.bottom)
+ .frame(maxHeight: 220)
}
- .defaultScrollAnchor(.bottom)
- .frame(minHeight: 180)
+ } label: {
+ Label(localization.text("logs.title"), systemImage: "stethoscope")
}
}
}
diff --git a/Sources/GeoShift/LogReader.swift b/Sources/GeoShift/LogReader.swift
index 50d211e..91366a1 100644
--- a/Sources/GeoShift/LogReader.swift
+++ b/Sources/GeoShift/LogReader.swift
@@ -1,7 +1,11 @@
import Foundation
enum LogReader {
- static func tail(of url: URL, maximumBytes: UInt64 = 64 * 1024) -> String {
+ static func tail(
+ of url: URL,
+ maximumBytes: UInt64 = 64 * 1024,
+ maximumLines: Int = 50
+ ) -> String {
guard let handle = try? FileHandle(forReadingFrom: url) else {
return ""
}
@@ -18,12 +22,20 @@ enum LogReader {
}
let text = String(decoding: data, as: UTF8.self)
- if offset == 0 {
- return text
+ let completeText = if offset == 0 {
+ text
+ } else {
+ text.split(separator: "\n", omittingEmptySubsequences: false)
+ .dropFirst()
+ .joined(separator: "\n")
}
+ let withoutTrailingNewline = completeText.last == "\n"
+ ? String(completeText.dropLast())
+ : completeText
- return text.split(separator: "\n", omittingEmptySubsequences: false)
- .dropFirst()
+ return withoutTrailingNewline
+ .split(separator: "\n", omittingEmptySubsequences: false)
+ .suffix(maximumLines)
.joined(separator: "\n")
}
}
diff --git a/Sources/GeoShift/PairingGuideView.swift b/Sources/GeoShift/PairingGuideView.swift
new file mode 100644
index 0000000..587bcbe
--- /dev/null
+++ b/Sources/GeoShift/PairingGuideView.swift
@@ -0,0 +1,107 @@
+import SwiftUI
+
+struct PairingGuideView: View {
+ @Bindable var controller: KeeperController
+ @Environment(LocalizationStore.self) private var localization
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Label(localization.text("pairing.title"), systemImage: "wifi")
+ .font(.headline)
+
+ if controller.isPairing {
+ livePairingContent
+ } else if controller.pairingStatus?.phase == .failed {
+ failedContent
+ } else if controller.hasWirelessPairing {
+ pairedContent
+ } else {
+ unpairedContent
+ }
+
+ Text(localization.text("pairing.fallback"))
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ @ViewBuilder
+ private var livePairingContent: some View {
+ if let code = controller.pairingStatus?.code {
+ Text(code)
+ .font(.system(.title, design: .monospaced, weight: .bold))
+ .textSelection(.enabled)
+ .accessibilityLabel("\(localization.text("pairing.codeAccessibility")): \(code.map(String.init).joined(separator: ", "))")
+ } else {
+ ProgressView()
+ .controlSize(.small)
+ }
+ Text(pairingProgressMessage)
+ .foregroundStyle(.secondary)
+ pairingInstructions
+ }
+
+ private var pairedContent: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Label(localization.text("pairing.saved"), systemImage: "checkmark.circle.fill")
+ .foregroundStyle(.green)
+ Text(localization.text("pairing.frequency"))
+ .font(.callout)
+ HStack {
+ Button(localization.text("pairing.check"), systemImage: "arrow.clockwise", action: controller.refreshAction)
+ .buttonStyle(.borderedProminent)
+ Button(localization.text("pairing.recreate"), action: controller.startPairingAction)
+ .buttonStyle(.bordered)
+ }
+ }
+ }
+
+ private var failedContent: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Label(
+ localization.text("pairing.failed"),
+ systemImage: "exclamationmark.triangle.fill"
+ )
+ .foregroundStyle(.red)
+ pairingInstructions
+ Button(localization.text("pairing.retry"), systemImage: "arrow.clockwise", action: controller.startPairingAction)
+ .buttonStyle(.borderedProminent)
+ }
+ }
+
+ private var unpairedContent: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Label(localization.text("pairing.missing"), systemImage: "exclamationmark.circle")
+ .foregroundStyle(.orange)
+ pairingInstructions
+ Button(
+ localization.text("pairing.setup"),
+ systemImage: "iphone.and.arrow.forward",
+ action: controller.startPairingAction
+ )
+ .buttonStyle(.borderedProminent)
+ }
+ }
+
+ private var pairingInstructions: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(localization.text("pairing.step1"))
+ Text(localization.text("pairing.step2"))
+ Text(localization.text("pairing.step3"))
+ Text(localization.text("pairing.step4"))
+ }
+ .font(.callout)
+ }
+
+ private var pairingProgressMessage: String {
+ switch controller.pairingStatus?.phase {
+ case .advertising:
+ localization.text("controller.pairingStarting")
+ case .codeReady:
+ localization.text("pairing.step3")
+ default:
+ localization.text("pairing.waiting")
+ }
+ }
+}
diff --git a/Sources/GeoShift/PairingStatus.swift b/Sources/GeoShift/PairingStatus.swift
new file mode 100644
index 0000000..5652ed4
--- /dev/null
+++ b/Sources/GeoShift/PairingStatus.swift
@@ -0,0 +1,16 @@
+import Foundation
+
+struct PairingStatus: Codable, Equatable, Sendable {
+ enum Phase: String, Codable, Sendable {
+ case advertising
+ case codeReady
+ case paired
+ case failed
+ }
+
+ let phase: Phase
+ let code: String?
+ let deviceUDID: String?
+ let message: String
+ let updatedAt: Double
+}
diff --git a/Sources/GeoShift/PairingStatusStore.swift b/Sources/GeoShift/PairingStatusStore.swift
new file mode 100644
index 0000000..09a8be3
--- /dev/null
+++ b/Sources/GeoShift/PairingStatusStore.swift
@@ -0,0 +1,20 @@
+import Foundation
+
+enum PairingStatusStore {
+ static func load() -> PairingStatus? {
+ guard let data = try? Data(contentsOf: AppPaths.pairingStatusURL) else {
+ return nil
+ }
+ return try? JSONDecoder().decode(PairingStatus.self, from: data)
+ }
+
+ static var hasWirelessPairing: Bool {
+ guard let files = try? FileManager.default.contentsOfDirectory(
+ at: AppPaths.pairingRecordsURL,
+ includingPropertiesForKeys: nil
+ ) else {
+ return false
+ }
+ return files.contains { $0.lastPathComponent.hasPrefix("remote_") && $0.pathExtension == "plist" }
+ }
+}
diff --git a/Sources/GeoShift/Resources/en.lproj/Localizable.strings b/Sources/GeoShift/Resources/en.lproj/Localizable.strings
new file mode 100644
index 0000000..2320d13
--- /dev/null
+++ b/Sources/GeoShift/Resources/en.lproj/Localizable.strings
@@ -0,0 +1,101 @@
+"header.subtitle" = "Control iPhone GPS simulation from your Mac";
+"header.settings" = "Settings";
+"content.safetyFootnote" = "When GeoShift closes, it always queues a return to real GPS. After an unexpected termination, the watchdog does this within five minutes.";
+
+"control.start" = "Start";
+"control.restore" = "Restore GPS";
+"control.restart" = "Restart";
+"control.refresh" = "Reconnect";
+
+"city.choose" = "Choose city";
+"city.searchPrompt" = "City, country, or region";
+"city.pickerTitle" = "Choose a city";
+"city.selected" = "Selected";
+"common.done" = "Done";
+"common.close" = "Close";
+"unit.secondsShort" = "sec";
+
+"region.balkans" = "Balkans";
+"region.europe" = "Europe";
+"region.middleEastAfrica" = "Middle East & Africa";
+"region.asia" = "Asia";
+"region.northAmerica" = "North America";
+"region.latinAmerica" = "Latin America";
+"region.oceania" = "Australia & Oceania";
+
+"state.stopped" = "Simulation cleared";
+"state.restoring" = "Restoring real GPS";
+"state.waiting" = "Waiting for iPhone";
+"state.active" = "Simulation active";
+"state.working" = "Connecting…";
+"state.failed" = "Action required";
+
+"logs.empty" = "No diagnostics yet";
+"logs.title" = "Diagnostics · last 50 events";
+
+"settings.title" = "Settings";
+"settings.languageSection" = "Language";
+"settings.language" = "App language";
+"settings.timings" = "Timing";
+"settings.retry" = "Reconnect delay";
+"settings.refresh" = "GPS refresh";
+"settings.safetySection" = "Protection from a stuck location";
+"settings.safetyQuit" = "Command-Q and closing the window automatically queue Restore GPS.";
+"settings.safetyHeartbeat" = "If GeoShift disappears unexpectedly, the worker detects the missing heartbeat and restores GPS within five minutes.";
+"settings.safetyQueue" = "If the iPhone is unavailable, the restore request remains queued until the next trusted connection.";
+"settings.connection" = "Connection";
+"settings.changesSection" = "What GeoShift changes";
+"settings.changesCoordinates" = "Only iPhone Core Location coordinates are simulated.";
+"settings.changesPersistence" = "A simulated location remains on the iPhone without the Mac until a clear command succeeds or the device restarts.";
+"settings.changesIP" = "Your IP address, latency, and internet route do not change.";
+"settings.changesVPN" = "A VPN is a separate tool that changes the network route and public IP.";
+"settings.changesDetection" = "Some apps can detect software-based location simulation.";
+
+"pairing.title" = "Connect iPhone without a cable";
+"pairing.fallback" = "Fallback: connect the unlocked iPhone by cable once and confirm Trust. You do not need to remove the existing MacBook Pro system pairing.";
+"pairing.waiting" = "Waiting for iPhone…";
+"pairing.saved" = "GeoShift Wi-Fi pairing credentials are saved";
+"pairing.frequency" = "This permission is normally one-time; it does not need weekly or monthly renewal. Pair again only after deleting the pair, resetting trust or network settings, reinstalling macOS, or when an iOS/macOS update invalidates the record.";
+"pairing.check" = "Check connection";
+"pairing.recreate" = "Pair again";
+"pairing.failed" = "Could not create the Wi-Fi pair";
+"pairing.retry" = "Retry pairing";
+"pairing.missing" = "A dedicated GeoShift Wi-Fi pair has not been created yet";
+"pairing.setup" = "Set up Wi-Fi connection";
+"pairing.step1" = "1. Keep the Mac and iPhone on the same Wi-Fi network.";
+"pairing.step2" = "2. On iPhone, open Settings → Developer → Paired Macs.";
+"pairing.step3" = "3. Under Other Devices, tap GeoShift and enter the six-digit code shown here.";
+"pairing.step4" = "4. GeoShift saves the pair and reconnects automatically.";
+"pairing.codeAccessibility" = "Pairing code";
+
+"controller.checkingService" = "Checking the background service…";
+"controller.applyingSettings" = "Applying timing settings…";
+"controller.sendingCoordinates" = "Sending coordinates:";
+"controller.startingTunnel" = "Starting the developer tunnel…";
+"controller.restoring" = "Sending the command to restore real GPS…";
+"controller.quitBusy" = "GeoShift could not close safely because the current operation did not finish.";
+"controller.quitHandoff" = "GeoShift stayed open because Restore GPS was not handed to a reliable queue. Try Restore GPS again.";
+"controller.hardRestart" = "Restarting the developer tunnel…";
+"controller.reconnecting" = "Trying to connect now…";
+"controller.recoverySession" = "An unfinished session was found. Restoring real GPS immediately…";
+"controller.startFailed" = "Could not start the background service";
+"controller.restoreFailed" = "Could not start GPS restoration";
+"controller.checkingQueue" = "Checking the Restore GPS queue…";
+"controller.watchdogUpdateFailed" = "Could not update the safety watchdog";
+"controller.watchdogRepairFailed" = "The watchdog could not repair the worker";
+"controller.previousWorkerTimeout" = "The previous worker did not stop within 15 seconds.";
+"controller.pairingModuleMissing" = "The wireless pairing module is missing.";
+"controller.pairingStarting" = "Starting the Wi-Fi pairing assistant…";
+
+"status.cleared" = "The clear command was sent through the connected iPhone's developer channel. Open Maps to verify the physical location.";
+"status.restorePending" = "Restore GPS is waiting for a trusted iPhone connection";
+"status.phoneUnavailable" = "GeoShift cannot see the iPhone over USB or the trusted Wi-Fi pair";
+"status.active" = "Simulation is active";
+"status.gpsEvery" = "GPS refresh every";
+"status.connecting" = "The background service is connecting";
+"status.notConfirmed" = "Real GPS is not confirmed; choose Restore GPS";
+
+"error.commandFailed" = "The command failed.";
+"error.previousOperation" = "The previous operation failed. Try again or open Diagnostics for connection details.";
+"error.missingDependency" = "pymobiledevice3 was not found";
+"error.missingKeeper" = "keeper.py is missing from the app bundle.";
diff --git a/Sources/GeoShift/Resources/keeper.py b/Sources/GeoShift/Resources/keeper.py
index d9de557..5d23e24 100644
--- a/Sources/GeoShift/Resources/keeper.py
+++ b/Sources/GeoShift/Resources/keeper.py
@@ -1,62 +1,202 @@
#!/usr/bin/env python3
-"""Continuously keep an attached iPhone at a configured simulated location."""
+"""Keep or clear a simulated Core Location position on one trusted iPhone."""
import argparse
import asyncio
import json
import logging
+import math
import os
import signal
+import time
from contextlib import suppress
+from logging.handlers import RotatingFileHandler
from pathlib import Path
from pymobiledevice3.exceptions import DeviceNotFoundError, NoDeviceConnectedError
from pymobiledevice3.lockdown import create_using_usbmux
+from pymobiledevice3.remote import tunnel_service, userspace_tunnel
+from pymobiledevice3.remote.tunnel_service import iter_remote_paired_identifiers
from pymobiledevice3.remote.userspace_tunnel import UserspaceRsdTunnel
from pymobiledevice3.services.dvt.instruments.dvt_provider import DvtProvider
from pymobiledevice3.services.dvt.instruments.location_simulation import LocationSimulation
+from pymobiledevice3.usbmux import list_devices
-CONFIG_PATH = Path.home() / "Library/Application Support/GeoShift/config.json"
+APP_SUPPORT_PATH = Path.home() / "Library/Application Support/GeoShift"
+CONFIG_PATH = APP_SUPPORT_PATH / "config.json"
+STATUS_PATH = APP_SUPPORT_PATH / "status.json"
+LOG_PATH = Path(
+ os.environ.get(
+ "GEOSHIFT_LOG_PATH",
+ Path.home() / "Library/Logs/GeoShift.log",
+ )
+)
SET_TIMEOUT_SECONDS = 5
+CONNECT_TIMEOUT_SECONDS = 30
+REMOTE_BONJOUR_TIMEOUT_SECONDS = 12
WAKE_CHECK_SECONDS = 0.25
-DEFAULT_CONFIG = {
- "cityID": "belgrade",
- "cityName": "Белград",
- "country": "Сербия",
- "latitude": 44.8125,
- "longitude": 20.4612,
- "retrySeconds": 5,
- "refreshSeconds": 10,
- "requestID": "default",
-}
+STOP_GRACE_SECONDS = 12
+APP_HEARTBEAT_TIMEOUT_SECONDS = 5 * 60
+APP_HEARTBEAT_FUTURE_TOLERANCE_SECONDS = 60
+
+
+class ConfigurationError(ValueError):
+ pass
+
+
+class AmbiguousDeviceError(RuntimeError):
+ pass
+
+LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
+ handlers=[
+ RotatingFileHandler(
+ LOG_PATH,
+ maxBytes=2 * 1024 * 1024,
+ backupCount=2,
+ encoding="utf-8",
+ )
+ ],
)
-logger = logging.getLogger("location-keeper")
+logger = logging.getLogger("geoshift-keeper")
-def load_config() -> dict:
+def load_config(path: Path | None = None) -> dict:
+ path = path or CONFIG_PATH
try:
- data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
- except (OSError, ValueError, TypeError):
- return DEFAULT_CONFIG.copy()
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, ValueError, TypeError) as error:
+ raise ConfigurationError(f"Cannot read a valid GeoShift configuration: {error}") from error
+
+ required = (
+ "cityID",
+ "cityName",
+ "country",
+ "latitude",
+ "longitude",
+ "retrySeconds",
+ "refreshSeconds",
+ "requestID",
+ )
+ missing = [key for key in required if key not in data]
+ if missing:
+ raise ConfigurationError(f"Missing configuration fields: {', '.join(missing)}")
- config = DEFAULT_CONFIG | data
- config["retrySeconds"] = max(1, min(60, int(config["retrySeconds"])))
- config["refreshSeconds"] = max(2, min(300, int(config["refreshSeconds"])))
- config["latitude"] = float(config["latitude"])
- config["longitude"] = float(config["longitude"])
- return config
+ try:
+ latitude = float(data["latitude"])
+ longitude = float(data["longitude"])
+ retry_seconds = max(1, min(60, int(data["retrySeconds"])))
+ refresh_seconds = max(2, min(300, int(data["refreshSeconds"])))
+ except (TypeError, ValueError) as error:
+ raise ConfigurationError(f"Invalid coordinate or interval: {error}") from error
+
+ if not math.isfinite(latitude) or not -90 <= latitude <= 90:
+ raise ConfigurationError("Latitude must be a finite number between -90 and 90")
+ if not math.isfinite(longitude) or not -180 <= longitude <= 180:
+ raise ConfigurationError("Longitude must be a finite number between -180 and 180")
+
+ simulation_enabled = data.get("simulationEnabled", True)
+ if not isinstance(simulation_enabled, bool):
+ raise ConfigurationError("simulationEnabled must be a boolean")
+
+ app_heartbeat_at = data.get("appHeartbeatAt")
+ if app_heartbeat_at is not None:
+ try:
+ app_heartbeat_at = float(app_heartbeat_at)
+ except (TypeError, ValueError) as error:
+ raise ConfigurationError("appHeartbeatAt must be a timestamp") from error
+ if not math.isfinite(app_heartbeat_at):
+ raise ConfigurationError("appHeartbeatAt must be a finite timestamp")
+
+ return {
+ **data,
+ "latitude": latitude,
+ "longitude": longitude,
+ "retrySeconds": retry_seconds,
+ "refreshSeconds": refresh_seconds,
+ "simulationEnabled": simulation_enabled,
+ "appHeartbeatAt": app_heartbeat_at,
+ "requestID": str(data["requestID"]),
+ }
+
+
+def simulation_is_requested(config: dict, now: float | None = None) -> bool:
+ if not config.get("simulationEnabled", False):
+ return False
+ heartbeat = config.get("appHeartbeatAt")
+ if heartbeat is None:
+ return False
+ now = time.time() if now is None else now
+ age = now - float(heartbeat)
+ return -APP_HEARTBEAT_FUTURE_TOLERANCE_SECONDS <= age <= APP_HEARTBEAT_TIMEOUT_SECONDS
+
+
+def config_version(path: Path | None = None) -> int:
+ path = path or CONFIG_PATH
+ try:
+ return path.stat().st_mtime_ns
+ except OSError:
+ return 0
-def config_version() -> int:
+def read_status(path: Path | None = None) -> dict:
+ path = path or STATUS_PATH
try:
- return CONFIG_PATH.stat().st_mtime_ns
- except OSError:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ return data if isinstance(data, dict) else {}
+ except (OSError, ValueError, TypeError):
+ return {}
+
+
+def forced_shutdown_exit_code(status: dict | None = None) -> int:
+ status = read_status() if status is None else status
+ if status.get("phase") == "cleared" and not status.get("simulationMayBeActive", True):
return 0
+ return 75
+
+
+def write_status(
+ phase: str,
+ request_id: str,
+ simulation_may_be_active: bool,
+ device_udid: str | None,
+ message: str | None = None,
+ path: Path | None = None,
+ required: bool = False,
+) -> None:
+ path = path or STATUS_PATH
+ payload = {
+ "phase": phase,
+ "requestID": request_id,
+ "deviceUDID": device_udid,
+ "simulationMayBeActive": simulation_may_be_active,
+ "message": message,
+ "updatedAt": time.time(),
+ }
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary_path = path.with_name(f".{path.name}.{os.getpid()}.tmp")
+ try:
+ with temporary_path.open("w", encoding="utf-8") as output:
+ json.dump(payload, output, ensure_ascii=False, sort_keys=True)
+ output.flush()
+ os.fsync(output.fileno())
+ os.replace(temporary_path, path)
+ if required:
+ directory_fd = os.open(path.parent, os.O_RDONLY)
+ try:
+ os.fsync(directory_fd)
+ finally:
+ os.close(directory_fd)
+ except OSError:
+ logger.exception("Could not write worker status")
+ with suppress(OSError):
+ temporary_path.unlink()
+ if required:
+ raise
async def wait_or_stop(stop_event: asyncio.Event, timeout: float) -> None:
@@ -70,23 +210,78 @@ async def wait_until_timeout_or_config_change(
starting_version: int,
) -> bool:
deadline = asyncio.get_running_loop().time() + timeout
-
while not stop_event.is_set():
if config_version() != starting_version:
return True
-
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
return False
-
await wait_or_stop(stop_event, min(WAKE_CHECK_SECONDS, remaining))
-
return False
-async def enable_wireless_connections_if_usb_is_available() -> None:
+async def resolve_target_device(saved_udid: str | None) -> tuple[str, bool]:
+ devices = await list_devices()
+ available_udids = sorted({device.serial for device in devices})
+
+ if saved_udid:
+ if saved_udid in available_udids:
+ return saved_udid, False
+ if saved_udid in set(iter_remote_paired_identifiers()):
+ return saved_udid, True
+ raise NoDeviceConnectedError()
+ if not available_udids:
+ paired_udids = sorted(set(iter_remote_paired_identifiers()))
+ if not paired_udids:
+ raise NoDeviceConnectedError()
+ if len(paired_udids) > 1:
+ raise AmbiguousDeviceError(
+ "Several remotely paired iPhones are available. Remove unused GeoShift pairing records."
+ )
+ return paired_udids[0], True
+ if len(available_udids) > 1:
+ raise AmbiguousDeviceError(
+ "Several iPhones are visible. Disconnect all but the intended target and retry."
+ )
+ return available_udids[0], False
+
+
+async def open_target_tunnel(target_udid: str, uses_remote_pairing: bool):
+ tunnel = UserspaceRsdTunnel(serial=target_udid, autopair=not uses_remote_pairing)
+ if not uses_remote_pairing:
+ return tunnel, await asyncio.wait_for(tunnel.aopen(), timeout=CONNECT_TIMEOUT_SECONDS)
+
+ original_provider_factory = userspace_tunnel._create_no_root_tunnel_provider
+
+ async def remote_pairing_provider(serial: str | None, autopair: bool):
+ services = await tunnel_service.get_remote_pairing_tunnel_services(
+ bonjour_timeout=REMOTE_BONJOUR_TIMEOUT_SECONDS,
+ udid=serial,
+ )
+ if not services:
+ raise NoDeviceConnectedError()
+ return services[0], None
+
+ # pymobiledevice3's public userspace tunnel currently probes usbmux before
+ # its Wi-Fi fallback. A device-initiated iOS 27 pair has no usbmux entry, so
+ # inject the already-paired provider at its narrow factory seam.
+ userspace_tunnel._create_no_root_tunnel_provider = remote_pairing_provider
+ try:
+ rsd = await asyncio.wait_for(tunnel.aopen(), timeout=CONNECT_TIMEOUT_SECONDS)
+ return tunnel, rsd
+ finally:
+ userspace_tunnel._create_no_root_tunnel_provider = original_provider_factory
+
+
+async def close_tunnel(tunnel: UserspaceRsdTunnel) -> None:
+ with suppress(Exception, asyncio.CancelledError):
+ await asyncio.wait_for(tunnel.aclose(), timeout=SET_TIMEOUT_SECONDS)
+
+
+async def enable_wireless_connections(target_udid: str) -> None:
try:
lockdown = await create_using_usbmux(
+ serial=target_udid,
autopair=True,
connection_type="USB",
)
@@ -96,101 +291,353 @@ async def enable_wireless_connections_if_usb_is_available() -> None:
try:
if not await lockdown.get_enable_wifi_connections():
await lockdown.set_enable_wifi_connections(True)
- logger.info("Enabled wireless iPhone connections for future cable-free use")
+ logger.info("Enabled wireless connections for %s", target_udid)
+ except Exception:
+ logger.warning("Could not enable wireless connections", exc_info=True)
finally:
- await lockdown.close()
+ with suppress(Exception):
+ await lockdown.close()
-async def keep_location(stop_event: asyncio.Event) -> None:
+async def set_location_with_retry(
+ location: LocationSimulation,
+ config: dict,
+ stop_event: asyncio.Event,
+) -> None:
+ try:
+ await asyncio.wait_for(
+ location.set(config["latitude"], config["longitude"]),
+ timeout=SET_TIMEOUT_SECONDS,
+ )
+ except Exception:
+ if stop_event.is_set():
+ raise
+ logger.warning("Location refresh failed once; retrying")
+ await wait_or_stop(stop_event, 1.0)
+ await asyncio.wait_for(
+ location.set(config["latitude"], config["longitude"]),
+ timeout=SET_TIMEOUT_SECONDS,
+ )
+
+
+async def apply_location(
+ location: LocationSimulation,
+ config: dict,
+ stop_event: asyncio.Event,
+ target_udid: str,
+) -> None:
+ # The pessimistic latch is durable before the no-reply DTX command is sent.
+ write_status(
+ "applying",
+ config["requestID"],
+ True,
+ target_udid,
+ f"Applying {config['cityName']}…",
+ required=True,
+ )
+ await set_location_with_retry(location, config, stop_event)
+ write_status(
+ "active",
+ config["requestID"],
+ True,
+ target_udid,
+ f"{config['cityName']}, {config['country']}",
+ )
+
+
+async def clear_over_open_tunnel(
+ location: LocationSimulation,
+ config: dict,
+ target_udid: str,
+) -> None:
+ write_status(
+ "clearing",
+ config["requestID"],
+ True,
+ target_udid,
+ "Sending clear to iPhone…",
+ )
+ await asyncio.wait_for(location.clear(), timeout=SET_TIMEOUT_SECONDS)
+ logger.info("Clear command sent successfully to %s", target_udid)
+ write_status(
+ "cleared",
+ config["requestID"],
+ False,
+ target_udid,
+ "The clear command was sent successfully",
+ required=True,
+ )
+
+
+async def clear_location_for_config(config: dict, saved_udid: str | None) -> str:
+ target_udid, uses_remote_pairing = await resolve_target_device(saved_udid)
+ if not uses_remote_pairing:
+ await enable_wireless_connections(target_udid)
+ tunnel = None
+ try:
+ tunnel, rsd = await open_target_tunnel(target_udid, uses_remote_pairing)
+ if rsd.udid != target_udid:
+ raise RuntimeError(
+ f"Connected to unexpected iPhone {rsd.udid}; expected {target_udid}"
+ )
+ async with DvtProvider(rsd) as dvt, LocationSimulation(dvt) as location:
+ await clear_over_open_tunnel(location, config, target_udid)
+ return target_udid
+ finally:
+ if tunnel is not None:
+ await close_tunnel(tunnel)
+
+
+async def run_worker(stop_event: asyncio.Event) -> None:
+ previous_status = read_status()
+ simulation_may_be_active = bool(previous_status.get("simulationMayBeActive", False))
+ target_udid = previous_status.get("deviceUDID") if simulation_may_be_active else None
+ last_logged_request_id = ""
+ waiting_log_time = 0.0
+ expired_heartbeat_request = ""
+
while not stop_event.is_set():
- config = load_config()
version = config_version()
+ try:
+ config = load_config()
+ except ConfigurationError as error:
+ logger.error("Invalid configuration: %s", error)
+ # Coordinates are unnecessary for clear. If config.json is damaged
+ # or deleted, use the last durable request/device identity and keep
+ # retrying a conservative restore instead of abandoning the phone.
+ recovery_config = {
+ "requestID": previous_status.get("requestID") or "configuration-recovery"
+ }
+ simulation_may_be_active = True
+ write_status(
+ "clearPending",
+ recovery_config["requestID"],
+ True,
+ target_udid,
+ f"Configuration is invalid; recovery clear is waiting for iPhone: {error}",
+ required=True,
+ )
+ try:
+ target_udid = await clear_location_for_config(recovery_config, target_udid)
+ return
+ except (DeviceNotFoundError, NoDeviceConnectedError):
+ logger.warning("Recovery clear is waiting for the trusted iPhone")
+ except Exception as clear_error:
+ logger.exception("Recovery clear failed; retrying")
+ write_status(
+ "clearPending",
+ recovery_config["requestID"],
+ True,
+ target_udid,
+ f"Recovery clear could not be sent: {clear_error}",
+ )
+ await wait_until_timeout_or_config_change(stop_event, 5, version)
+ continue
+
+ should_simulate = simulation_is_requested(config)
+ if config["simulationEnabled"] and not should_simulate:
+ if expired_heartbeat_request != config["requestID"]:
+ logger.warning("App heartbeat expired; forcing real GPS restoration")
+ expired_heartbeat_request = config["requestID"]
+
+ if (
+ not should_simulate
+ and previous_status.get("requestID") == config["requestID"]
+ and previous_status.get("phase") == "cleared"
+ and not previous_status.get("simulationMayBeActive", True)
+ ):
+ logger.info("Matching successful clear is already recorded; worker has nothing to do")
+ return
+
+ if not should_simulate and not previous_status:
+ # Upgrading from an older build: there may be a device-side simulation
+ # even though no machine-readable status exists yet.
+ simulation_may_be_active = True
+
+ phase = "connecting" if should_simulate else "clearPending"
+ message = (
+ "Connecting to iPhone…"
+ if should_simulate
+ else "Restore GPS is waiting for a trusted iPhone connection"
+ )
+ write_status(
+ phase,
+ config["requestID"],
+ simulation_may_be_active,
+ target_udid,
+ message,
+ )
try:
- logger.info("Connecting to the first available iPhone")
- await enable_wireless_connections_if_usb_is_available()
+ target_udid, uses_remote_pairing = await resolve_target_device(target_udid)
+ if not uses_remote_pairing:
+ await enable_wireless_connections(target_udid)
+
+ tunnel = None
+ try:
+ tunnel, rsd = await open_target_tunnel(target_udid, uses_remote_pairing)
+ actual_udid = rsd.udid
+ if actual_udid != target_udid:
+ raise RuntimeError(
+ f"Connected to unexpected iPhone {actual_udid}; expected {target_udid}"
+ )
- async with UserspaceRsdTunnel(autopair=True) as rsd:
async with DvtProvider(rsd) as dvt, LocationSimulation(dvt) as location:
- logger.info("Connected to iPhone")
+ logger.info("Connected to iPhone %s", target_udid)
refresh_count = 0
- last_request_id = ""
while not stop_event.is_set():
config = load_config()
version = config_version()
-
+ should_simulate = simulation_is_requested(config)
+
+ if not should_simulate:
+ try:
+ await clear_over_open_tunnel(location, config, target_udid)
+ except Exception as error:
+ simulation_may_be_active = True
+ write_status(
+ "clearPending",
+ config["requestID"],
+ True,
+ target_udid,
+ f"Clear could not be sent: {error}",
+ )
+ raise
+ return
+
+ simulation_may_be_active = True
try:
- await asyncio.wait_for(
- location.set(config["latitude"], config["longitude"]),
- timeout=SET_TIMEOUT_SECONDS,
- )
+ await apply_location(location, config, stop_event, target_udid)
except Exception:
- logger.exception(
- "Location refresh failed or timed out; forcing a clean process restart"
- )
- os._exit(75)
+ logger.exception("Location refresh failed; reconnecting cleanly")
+ with suppress(Exception):
+ await clear_over_open_tunnel(location, config, target_udid)
+ simulation_may_be_active = False
+ raise
refresh_count += 1
- if config["requestID"] != last_request_id or refresh_count % 20 == 0:
+ if config["requestID"] != last_logged_request_id or refresh_count % 20 == 0:
logger.info(
- "Location set: %s, %s (%.4f, %.4f)",
+ "Location set: %s, %s (%.4f, %.4f) on %s",
config["cityName"],
config["country"],
config["latitude"],
config["longitude"],
+ target_udid,
)
- last_request_id = config["requestID"]
+ last_logged_request_id = config["requestID"]
- changed = await wait_until_timeout_or_config_change(
+ await wait_until_timeout_or_config_change(
stop_event,
config["refreshSeconds"],
version,
)
- if changed:
- logger.info("Configuration changed; applying immediately")
- with suppress(Exception):
- await location.clear()
+ if simulation_may_be_active:
+ try:
+ await clear_over_open_tunnel(location, config, target_udid)
+ simulation_may_be_active = False
+ except Exception as error:
+ write_status(
+ "clearPending",
+ config["requestID"],
+ True,
+ target_udid,
+ f"Worker shutdown could not send clear: {error}",
+ required=True,
+ )
+ raise
+ finally:
+ if tunnel is not None:
+ await close_tunnel(tunnel)
+
+ except (DeviceNotFoundError, NoDeviceConnectedError):
+ now = time.monotonic()
+ if now - waiting_log_time >= 60:
+ logger.warning("Target iPhone is not connected; waiting")
+ waiting_log_time = now
+ should_simulate = simulation_is_requested(config)
+ phase = "waitingForDevice" if should_simulate else "clearPending"
+ message = (
+ "Connect the unlocked iPhone over USB"
+ if should_simulate
+ else "Restore GPS is waiting for trusted USB or Wi-Fi"
+ )
+ write_status(
+ phase,
+ config["requestID"],
+ simulation_may_be_active,
+ target_udid,
+ message,
+ )
+ except AmbiguousDeviceError as error:
+ logger.error("%s", error)
+ write_status(
+ "failed",
+ config["requestID"],
+ simulation_may_be_active,
+ target_udid,
+ "Several iPhones are connected; leave only the intended target",
+ )
except asyncio.CancelledError:
raise
- except (DeviceNotFoundError, NoDeviceConnectedError):
- retry_seconds = load_config()["retrySeconds"]
- logger.warning("iPhone is not connected; waiting %d seconds", retry_seconds)
- changed = await wait_until_timeout_or_config_change(stop_event, retry_seconds, version)
- if changed:
- logger.info("Wake request received; retrying immediately")
- except Exception:
- retry_seconds = load_config()["retrySeconds"]
- logger.exception("Connection failed; retrying in %d seconds", retry_seconds)
- changed = await wait_until_timeout_or_config_change(stop_event, retry_seconds, version)
- if changed:
- logger.info("Wake request received; restarting immediately")
- os._exit(75)
+ except Exception as error:
+ logger.exception("Connection failed; retrying")
+ should_simulate = simulation_is_requested(config)
+ phase = "failed" if should_simulate else "clearPending"
+ write_status(
+ phase,
+ config["requestID"],
+ simulation_may_be_active,
+ target_udid,
+ f"Connection error: {error}",
+ )
+
+ if stop_event.is_set():
+ return
+ await wait_until_timeout_or_config_change(
+ stop_event,
+ config["retrySeconds"],
+ version,
+ )
+
+
+async def clear_location_once() -> None:
+ config = load_config()
+ status = read_status()
+ await clear_location_for_config(config, status.get("deviceUDID"))
async def main() -> None:
stop_event = asyncio.Event()
loop = asyncio.get_running_loop()
- loop.add_signal_handler(signal.SIGINT, stop_event.set)
- loop.add_signal_handler(signal.SIGTERM, lambda: os._exit(0))
+ def request_stop() -> None:
+ stop_event.set()
+ loop.call_later(
+ STOP_GRACE_SECONDS,
+ lambda: os._exit(forced_shutdown_exit_code()),
+ )
+
+ loop.add_signal_handler(signal.SIGINT, request_stop)
+ loop.add_signal_handler(signal.SIGTERM, request_stop)
- logger.info("Location Keeper started")
+ logger.info("GeoShift worker started")
try:
- await keep_location(stop_event)
+ await run_worker(stop_event)
+ if stop_event.is_set() and forced_shutdown_exit_code() != 0:
+ raise RuntimeError("Worker stopped before a durable successful clear status")
finally:
- logger.info("Location Keeper stopped")
-
-async def clear_location() -> None:
- async with UserspaceRsdTunnel(autopair=True) as rsd:
- async with DvtProvider(rsd) as dvt, LocationSimulation(dvt) as location:
- await location.clear()
- logger.info("Location simulation cleared")
+ logger.info("GeoShift worker stopped")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="GeoShift location simulation worker")
- parser.add_argument("--clear", action="store_true", help="Clear the simulated location and exit")
+ parser.add_argument("--clear", action="store_true", help="Clear the simulated location once and exit")
arguments = parser.parse_args()
- asyncio.run(clear_location() if arguments.clear else main())
+ try:
+ asyncio.run(clear_location_once() if arguments.clear else main())
+ except Exception:
+ logger.exception("GeoShift worker failed")
+ raise SystemExit(1)
diff --git a/Sources/GeoShift/Resources/pairing.py b/Sources/GeoShift/Resources/pairing.py
new file mode 100644
index 0000000..d7e7acb
--- /dev/null
+++ b/Sources/GeoShift/Resources/pairing.py
@@ -0,0 +1,68 @@
+#!/usr/bin/env python3
+"""Create a device-initiated iOS 27+ RemotePairing record for GeoShift."""
+
+import asyncio
+import json
+import os
+import time
+import uuid
+from pathlib import Path
+
+from pymobiledevice3.remote.tunnel_service import PairableHostInfo, serve_pairable_host
+
+
+STATUS_PATH = Path.home() / "Library/Application Support/GeoShift/pairing-status.json"
+
+
+def write_status(phase: str, message: str, code: str | None = None, device_udid: str | None = None) -> None:
+ payload = {
+ "phase": phase,
+ "code": code,
+ "deviceUDID": device_udid,
+ "message": message,
+ "updatedAt": time.time(),
+ }
+ STATUS_PATH.parent.mkdir(parents=True, exist_ok=True)
+ temporary = STATUS_PATH.with_name(f".{STATUS_PATH.name}.{os.getpid()}.tmp")
+ with temporary.open("w", encoding="utf-8") as output:
+ json.dump(payload, output, ensure_ascii=False, sort_keys=True)
+ output.flush()
+ os.fsync(output.fileno())
+ os.replace(temporary, STATUS_PATH)
+
+
+async def main() -> None:
+ host_info = PairableHostInfo(
+ name="GeoShift",
+ identifier=str(uuid.uuid4()).upper(),
+ )
+ write_status(
+ "advertising",
+ "On iPhone, open Settings → Developer → Paired Macs → GeoShift",
+ )
+
+ def show_pin(pin: str) -> None:
+ write_status("codeReady", "Enter this code on iPhone", code=pin)
+
+ try:
+ result = await serve_pairable_host(
+ host_info,
+ pin_callback=show_pin,
+ timeout=180,
+ )
+ except asyncio.TimeoutError:
+ write_status("failed", "The iPhone did not start pairing within three minutes. Open Paired Macs and retry.")
+ raise SystemExit(1) from None
+ except Exception as error:
+ write_status("failed", f"Could not create the Wi-Fi pair: {error}")
+ raise
+
+ write_status(
+ "paired",
+ "Wi-Fi pairing completed. GeoShift is reconnecting…",
+ device_udid=result.peer_device.udid,
+ )
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/Sources/GeoShift/Resources/ru.lproj/Localizable.strings b/Sources/GeoShift/Resources/ru.lproj/Localizable.strings
new file mode 100644
index 0000000..c2fc7c8
--- /dev/null
+++ b/Sources/GeoShift/Resources/ru.lproj/Localizable.strings
@@ -0,0 +1,101 @@
+"header.subtitle" = "Управление симуляцией GPS для подключённого iPhone";
+"header.settings" = "Настройки";
+"content.safetyFootnote" = "При закрытии GeoShift всегда ставит возврат реального GPS в очередь. После аварийного завершения watchdog сделает это не позднее чем через пять минут.";
+
+"control.start" = "Запустить";
+"control.restore" = "Вернуть GPS";
+"control.restart" = "Перезапустить";
+"control.refresh" = "Переподключить";
+
+"city.choose" = "Выбрать город";
+"city.searchPrompt" = "Город, страна или регион";
+"city.pickerTitle" = "Выбор города";
+"city.selected" = "Выбрано";
+"common.done" = "Готово";
+"common.close" = "Закрыть";
+"unit.secondsShort" = "сек";
+
+"region.balkans" = "Балканы";
+"region.europe" = "Европа";
+"region.middleEastAfrica" = "Ближний Восток и Африка";
+"region.asia" = "Азия";
+"region.northAmerica" = "Северная Америка";
+"region.latinAmerica" = "Латинская Америка";
+"region.oceania" = "Австралия и Океания";
+
+"state.stopped" = "Симуляция сброшена";
+"state.restoring" = "Возвращаю реальный GPS";
+"state.waiting" = "Ожидание iPhone";
+"state.active" = "Симуляция активна";
+"state.working" = "Подключение…";
+"state.failed" = "Требуется внимание";
+
+"logs.empty" = "Журнал пока пуст";
+"logs.title" = "Диагностика · последние 50 событий";
+
+"settings.title" = "Настройки";
+"settings.languageSection" = "Язык";
+"settings.language" = "Язык приложения";
+"settings.timings" = "Тайминги";
+"settings.retry" = "Повтор подключения";
+"settings.refresh" = "Обновление GPS";
+"settings.safetySection" = "Защита от зависшей геопозиции";
+"settings.safetyQuit" = "При Command-Q и закрытии окна возврат GPS ставится в очередь автоматически.";
+"settings.safetyHeartbeat" = "Если GeoShift аварийно исчезнет, worker заметит это по heartbeat и выполнит сброс максимум через пять минут.";
+"settings.safetyQueue" = "Если iPhone недоступен, очередь сохранится до следующего доверенного подключения.";
+"settings.connection" = "Подключение";
+"settings.changesSection" = "Что меняется";
+"settings.changesCoordinates" = "Подменяются только координаты Core Location на iPhone.";
+"settings.changesPersistence" = "Установленная геопозиция сохраняется на iPhone без связи с Mac — пока команда clear не завершится успешно или устройство не перезагрузится.";
+"settings.changesIP" = "IP-адрес, ping и маршрут интернета не меняются.";
+"settings.changesVPN" = "VPN — отдельный инструмент; он меняет интернет-маршрут и IP.";
+"settings.changesDetection" = "Некоторые приложения могут распознавать программную симуляцию.";
+
+"pairing.title" = "Подключение iPhone без кабеля";
+"pairing.fallback" = "Запасной вариант: один раз подключите разблокированный iPhone кабелем и подтвердите «Доверять». Существующую системную пару MacBook Pro удалять не нужно.";
+"pairing.waiting" = "Ожидаю iPhone…";
+"pairing.saved" = "Учётные данные Wi-Fi-пары GeoShift сохранены";
+"pairing.frequency" = "Обычно это одноразовое разрешение — обновлять его каждую неделю или месяц не нужно. Повторите сопряжение только после удаления пары, сброса доверия или сети, переустановки macOS или если обновление iOS/macOS аннулировало запись.";
+"pairing.check" = "Проверить подключение";
+"pairing.recreate" = "Создать пару заново";
+"pairing.failed" = "Не удалось создать Wi-Fi-пару";
+"pairing.retry" = "Повторить сопряжение";
+"pairing.missing" = "Отдельная Wi-Fi-пара GeoShift ещё не создана";
+"pairing.setup" = "Настроить Wi-Fi-подключение";
+"pairing.step1" = "1. Оставьте Mac и iPhone в одной Wi-Fi-сети.";
+"pairing.step2" = "2. На iPhone откройте: Настройки → Для разработчиков → Сопряжённые Mac.";
+"pairing.step3" = "3. В разделе «Другие устройства» нажмите GeoShift и введите показанный здесь шестизначный код.";
+"pairing.step4" = "4. GeoShift сохранит пару и переподключится автоматически.";
+"pairing.codeAccessibility" = "Код сопряжения";
+
+"controller.checkingService" = "Проверяю фоновый сервис…";
+"controller.applyingSettings" = "Применяю новые интервалы…";
+"controller.sendingCoordinates" = "Передаю координаты:";
+"controller.startingTunnel" = "Запускаю developer-туннель…";
+"controller.restoring" = "Отправляю команду возврата реального GPS…";
+"controller.quitBusy" = "Не удалось безопасно закрыть GeoShift: текущая операция не завершилась.";
+"controller.quitHandoff" = "GeoShift остался открыт: возврат GPS не поставлен в надёжную очередь. Повторите «Вернуть GPS».";
+"controller.hardRestart" = "Полностью перезапускаю developer-туннель…";
+"controller.reconnecting" = "Немедленная попытка подключения…";
+"controller.recoverySession" = "Найдена незавершённая сессия. Сразу возвращаю реальный GPS…";
+"controller.startFailed" = "Не удалось запустить фоновый сервис";
+"controller.restoreFailed" = "Не удалось запустить восстановление GPS";
+"controller.checkingQueue" = "Проверяю очередь возврата GPS…";
+"controller.watchdogUpdateFailed" = "Не удалось обновить safety-watchdog";
+"controller.watchdogRepairFailed" = "Watchdog не смог восстановить worker";
+"controller.previousWorkerTimeout" = "Предыдущий worker не завершился за 15 секунд.";
+"controller.pairingModuleMissing" = "Не найден модуль беспроводного сопряжения.";
+"controller.pairingStarting" = "Запускаю мастер Wi-Fi-сопряжения…";
+
+"status.cleared" = "Команда clear отправлена через developer-канал подключённого iPhone. Проверьте реальную геопозицию в «Картах».";
+"status.restorePending" = "Сброс ожидает доверенное подключение к iPhone";
+"status.phoneUnavailable" = "GeoShift не видит iPhone по USB или доверенной Wi-Fi-паре";
+"status.active" = "Симуляция активна";
+"status.gpsEvery" = "GPS каждые";
+"status.connecting" = "Фоновый сервис подключается";
+"status.notConfirmed" = "Реальный GPS не подтверждён; нажмите «Вернуть GPS»";
+
+"error.commandFailed" = "Команда завершилась с ошибкой.";
+"error.previousOperation" = "Предыдущая операция завершилась с ошибкой. Повторите её или откройте диагностику для проверки подключения.";
+"error.missingDependency" = "Не найден pymobiledevice3";
+"error.missingKeeper" = "В приложении отсутствует keeper.py.";
diff --git a/Sources/GeoShift/SettingsView.swift b/Sources/GeoShift/SettingsView.swift
index 1933532..93666da 100644
--- a/Sources/GeoShift/SettingsView.swift
+++ b/Sources/GeoShift/SettingsView.swift
@@ -3,57 +3,87 @@ import SwiftUI
struct SettingsView: View {
@Bindable var controller: KeeperController
@Environment(\.dismiss) private var dismiss
+ @Environment(LocalizationStore.self) private var localization
var body: some View {
+ @Bindable var localization = localization
+
NavigationStack {
Form {
- Section("Тайминги") {
+ Section(localization.text("settings.languageSection")) {
Picker(
- "Повтор подключения",
+ localization.text("settings.language"),
+ selection: $localization.language
+ ) {
+ ForEach(AppLanguage.allCases) { language in
+ Text(language.displayName).tag(language)
+ }
+ }
+ .pickerStyle(.segmented)
+ }
+
+ Section(localization.text("settings.timings")) {
+ Picker(
+ localization.text("settings.retry"),
selection: $controller.retrySeconds
) {
- Text("2 сек").tag(2)
- Text("5 сек").tag(5)
- Text("10 сек").tag(10)
- Text("30 сек").tag(30)
+ durationOption(2)
+ durationOption(5)
+ durationOption(10)
+ durationOption(30)
}
Picker(
- "Обновление GPS",
+ localization.text("settings.refresh"),
selection: $controller.locationRefreshSeconds
) {
- Text("5 сек").tag(5)
- Text("10 сек").tag(10)
- Text("15 сек").tag(15)
- Text("30 сек").tag(30)
- Text("60 сек").tag(60)
+ durationOption(5)
+ durationOption(10)
+ durationOption(15)
+ durationOption(30)
+ durationOption(60)
}
}
- Section("Что меняется") {
- Label("Подменяются только координаты Core Location на iPhone.", systemImage: "location.fill")
- Label("IP-адрес, ping и маршрут интернета не меняются.", systemImage: "network")
- Label("VPN — отдельный инструмент; он меняет интернет-маршрут и IP.", systemImage: "shield.lefthalf.filled")
- Label("Некоторые приложения могут распознавать программную симуляцию.", systemImage: "exclamationmark.triangle")
+ Section(localization.text("settings.safetySection")) {
+ Label(localization.text("settings.safetyQuit"), systemImage: "power")
+ Label(localization.text("settings.safetyHeartbeat"), systemImage: "heart.text.square")
+ Label(localization.text("settings.safetyQueue"), systemImage: "checklist")
+ }
+
+ Section(localization.text("settings.connection")) {
+ PairingGuideView(controller: controller)
+ }
+
+ Section(localization.text("settings.changesSection")) {
+ Label(localization.text("settings.changesCoordinates"), systemImage: "location.fill")
+ Label(localization.text("settings.changesPersistence"), systemImage: "iphone.and.arrow.forward")
+ Label(localization.text("settings.changesIP"), systemImage: "network")
+ Label(localization.text("settings.changesVPN"), systemImage: "shield.lefthalf.filled")
+ Label(localization.text("settings.changesDetection"), systemImage: "exclamationmark.triangle")
}
}
.formStyle(.grouped)
- .navigationTitle("Настройки")
+ .navigationTitle(localization.text("settings.title"))
.toolbar {
ToolbarItem(placement: .cancellationAction) {
- Button("Закрыть", action: dismiss.callAsFunction)
- }
- ToolbarItem(placement: .confirmationAction) {
- Button("Применить", action: apply)
- .buttonStyle(.borderedProminent)
+ Button(localization.text("common.close"), action: dismiss.callAsFunction)
}
}
}
.frame(minWidth: 580, minHeight: 460)
+ .onChange(of: controller.retrySeconds) {
+ controller.applySettings()
+ }
+ .onChange(of: controller.locationRefreshSeconds) {
+ controller.applySettings()
+ }
+ .onChange(of: localization.language) {
+ controller.languageDidChange()
+ }
}
- private func apply() {
- controller.applySettings()
- dismiss()
+ private func durationOption(_ seconds: Int) -> some View {
+ Text("\(seconds) \(localization.text("unit.secondsShort"))").tag(seconds)
}
}
diff --git a/Sources/GeoShift/StatusCard.swift b/Sources/GeoShift/StatusCard.swift
index efe1452..046db08 100644
--- a/Sources/GeoShift/StatusCard.swift
+++ b/Sources/GeoShift/StatusCard.swift
@@ -4,30 +4,63 @@ struct StatusCard: View {
let state: KeeperState
let detail: String
+ @Environment(LocalizationStore.self) private var localization
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @State private var isPulsing = false
+
var body: some View {
HStack(spacing: 16) {
- Image(systemName: state.systemImage)
- .font(.title)
- .foregroundStyle(state.color)
- .frame(width: 40, height: 40)
- .accessibilityHidden(true)
+ ZStack {
+ if state == .working {
+ ProgressView()
+ .controlSize(.regular)
+ } else {
+ Image(systemName: state.systemImage)
+ .font(.title)
+ .foregroundStyle(state.color)
+ .contentTransition(.symbolEffect(.replace))
+ }
+ }
+ .frame(width: 40, height: 40)
+ .accessibilityHidden(true)
VStack(alignment: .leading, spacing: 3) {
- Text(state.title)
+ Text(state.title(using: localization))
.font(.title2)
.bold()
Text(detail)
.foregroundStyle(.secondary)
+ .contentTransition(.opacity)
}
+ .animation(.default, value: state)
Spacer()
- Circle()
- .fill(state.color)
- .frame(width: 12, height: 12)
- .accessibilityLabel(state.title)
+ statusDot
}
.padding(18)
.background(.quaternary, in: .rect(cornerRadius: 14))
}
+
+ private var statusDot: some View {
+ Circle()
+ .fill(state.color)
+ .frame(width: 12, height: 12)
+ .background {
+ if state == .active && !reduceMotion {
+ Circle()
+ .stroke(state.color, lineWidth: 2)
+ .scaleEffect(isPulsing ? 2.4 : 1)
+ .opacity(isPulsing ? 0 : 0.8)
+ .animation(
+ .easeOut(duration: 1.4).repeatForever(autoreverses: false),
+ value: isPulsing
+ )
+ }
+ }
+ .onAppear {
+ isPulsing = true
+ }
+ .accessibilityLabel(state.title(using: localization))
+ }
}
diff --git a/Sources/GeoShift/StatusParser.swift b/Sources/GeoShift/StatusParser.swift
index 6b54611..4a56f48 100644
--- a/Sources/GeoShift/StatusParser.swift
+++ b/Sources/GeoShift/StatusParser.swift
@@ -1,27 +1,47 @@
+import Foundation
+
enum StatusParser {
- static func state(launchctlOutput: String, logs: String) -> KeeperState {
- guard launchctlOutput.contains("state = running") ||
- launchctlOutput.contains("state = spawn scheduled") else {
- return .stopped
+ static func state(
+ launchctlOutput: String,
+ configuration: KeeperConfiguration?,
+ workerStatus: WorkerStatus?,
+ now: Double = Date.now.timeIntervalSince1970
+ ) -> KeeperState {
+ guard let configuration else {
+ return .failed
}
- let currentSession = logs.components(separatedBy: "Location Keeper started").last ?? logs
+ let serviceIsRunning = launchctlOutput.contains("state = running")
+ let statusMatchesRequest = workerStatus?.requestID == configuration.requestID
- if currentSession.contains("Location set:") ||
- currentSession.contains("Belgrade location refreshed") {
- return .active
+ if !configuration.simulationEnabled {
+ if statusMatchesRequest, workerStatus?.phase == .cleared,
+ workerStatus?.simulationMayBeActive == false {
+ return .stopped
+ }
+ return serviceIsRunning ? .restoring : .failed
}
- if currentSession.contains("iPhone is not connected") ||
- currentSession.contains("DeviceNotFoundError") {
- return .waiting
+ guard statusMatchesRequest, let workerStatus else {
+ return serviceIsRunning ? .working : .failed
}
-
- if currentSession.contains("ERROR") ||
- currentSession.contains("forcing a clean process restart") {
+ let maximumStatusAge = max(Double(configuration.refreshSeconds * 2), 30)
+ let statusIsFresh = now - workerStatus.updatedAt <= maximumStatusAge
+ guard serviceIsRunning, statusIsFresh else {
return .failed
}
- return .working
+ return switch workerStatus.phase {
+ case .active:
+ .active
+ case .waitingForDevice:
+ .waiting
+ case .starting, .connecting, .applying:
+ .working
+ case .clearing, .clearPending:
+ .restoring
+ case .failed, .cleared:
+ .failed
+ }
}
}
diff --git a/Sources/GeoShift/WorkerStatus.swift b/Sources/GeoShift/WorkerStatus.swift
new file mode 100644
index 0000000..c3c1255
--- /dev/null
+++ b/Sources/GeoShift/WorkerStatus.swift
@@ -0,0 +1,22 @@
+import Foundation
+
+struct WorkerStatus: Codable, Equatable, Sendable {
+ enum Phase: String, Codable, Sendable {
+ case starting
+ case connecting
+ case waitingForDevice
+ case applying
+ case active
+ case clearing
+ case clearPending
+ case cleared
+ case failed
+ }
+
+ let phase: Phase
+ let requestID: String
+ let deviceUDID: String?
+ let simulationMayBeActive: Bool
+ let message: String?
+ let updatedAt: Double
+}
diff --git a/Sources/GeoShift/WorkerStatusStore.swift b/Sources/GeoShift/WorkerStatusStore.swift
new file mode 100644
index 0000000..b7b7ce1
--- /dev/null
+++ b/Sources/GeoShift/WorkerStatusStore.swift
@@ -0,0 +1,10 @@
+import Foundation
+
+enum WorkerStatusStore {
+ static func load() -> WorkerStatus? {
+ guard let data = try? Data(contentsOf: AppPaths.statusURL) else {
+ return nil
+ }
+ return try? JSONDecoder().decode(WorkerStatus.self, from: data)
+ }
+}
diff --git a/Tests/GeoShiftTests/CityCatalogTests.swift b/Tests/GeoShiftTests/CityCatalogTests.swift
index 02da3d4..85a8f25 100644
--- a/Tests/GeoShiftTests/CityCatalogTests.swift
+++ b/Tests/GeoShiftTests/CityCatalogTests.swift
@@ -1,3 +1,4 @@
+import Foundation
import Testing
@testable import GeoShift
@@ -34,10 +35,34 @@ struct CityCatalogTests {
let balkans = CityCatalog.sections(matching: "Балканы")
.flatMap(\.cities)
.map(\.id)
+ let thailandEnglish = CityCatalog.sections(matching: "Thailand")
+ .flatMap(\.cities)
+ .map(\.id)
+ let balkansEnglish = CityCatalog.sections(matching: "Balkans")
+ .flatMap(\.cities)
+ .map(\.id)
#expect(thailand.contains("bangkok"))
#expect(thailand.contains("phuket"))
#expect(balkans.contains("belgrade"))
#expect(!balkans.contains("paris"))
+ #expect(thailandEnglish.contains("bangkok"))
+ #expect(balkansEnglish.contains("belgrade"))
+ }
+
+ @Test("Every city has English and Russian display values")
+ func bilingualDisplayValues() {
+ for city in CityCatalog.cities {
+ #expect(!city.localizedName(for: .english).isEmpty)
+ #expect(!city.localizedName(for: .russian).isEmpty)
+ #expect(!city.localizedCountry(for: .english).isEmpty)
+ #expect(!city.localizedCountry(for: .russian).isEmpty)
+ #expect(city.localizedName(for: .english).range(of: "[А-Яа-яЁё]", options: .regularExpression) == nil)
+ #expect(city.localizedCountry(for: .english).range(of: "[А-Яа-яЁё]", options: .regularExpression) == nil)
+ }
+
+ #expect(CityCatalog.city(withID: "bogota")?.localizedName(for: .english) == "Bogotá")
+ #expect(CityCatalog.city(withID: "ho-chi-minh")?.localizedName(for: .english) == "Ho Chi Minh City")
+ #expect(CityCatalog.city(withID: "san-jose")?.localizedName(for: .english) == "San José")
}
}
diff --git a/Tests/GeoShiftTests/KeeperConfigurationTests.swift b/Tests/GeoShiftTests/KeeperConfigurationTests.swift
index c8b0447..16c8ac6 100644
--- a/Tests/GeoShiftTests/KeeperConfigurationTests.swift
+++ b/Tests/GeoShiftTests/KeeperConfigurationTests.swift
@@ -14,7 +14,9 @@ struct KeeperConfigurationTests {
longitude: 98.3923,
retrySeconds: 2,
refreshSeconds: 5,
- requestID: "test-request"
+ requestID: "test-request",
+ simulationEnabled: false,
+ appHeartbeatAt: 1_234
)
let data = try JSONEncoder().encode(configuration)
@@ -22,4 +24,13 @@ struct KeeperConfigurationTests {
#expect(decoded == configuration)
}
+
+ @Test("Legacy configuration defaults to an enabled simulation")
+ func legacyDecode() throws {
+ let data = Data(#"{"cityID":"belgrade","cityName":"Белград","country":"Сербия","latitude":44.8125,"longitude":20.4612,"retrySeconds":5,"refreshSeconds":10,"requestID":"legacy"}"#.utf8)
+ let decoded = try JSONDecoder().decode(KeeperConfiguration.self, from: data)
+
+ #expect(decoded.simulationEnabled)
+ #expect(decoded.appHeartbeatAt == nil)
+ }
}
diff --git a/Tests/GeoShiftTests/LocalizationTests.swift b/Tests/GeoShiftTests/LocalizationTests.swift
new file mode 100644
index 0000000..bbb4c89
--- /dev/null
+++ b/Tests/GeoShiftTests/LocalizationTests.swift
@@ -0,0 +1,55 @@
+import Foundation
+import Testing
+@testable import GeoShift
+
+@Suite("App localization")
+@MainActor
+struct LocalizationTests {
+ @Test("English is the default and an invalid saved value fails back to English")
+ func englishDefault() throws {
+ let (defaults, suiteName) = try makeDefaults()
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ #expect(LocalizationStore(defaults: defaults).language == .english)
+
+ defaults.set("unsupported", forKey: "appLanguage")
+ #expect(LocalizationStore(defaults: defaults).language == .english)
+ }
+
+ @Test("Russian selection persists across store recreation")
+ func russianPersistence() throws {
+ let (defaults, suiteName) = try makeDefaults()
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let first = LocalizationStore(defaults: defaults)
+ first.language = .russian
+
+ let restored = LocalizationStore(defaults: defaults)
+ #expect(restored.language == .russian)
+ #expect(restored.text("control.restore") == "Вернуть GPS")
+ }
+
+ @Test("Both languages provide translated user-facing strings")
+ func translatedResources() throws {
+ let (defaults, suiteName) = try makeDefaults()
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let localization = LocalizationStore(defaults: defaults)
+
+ localization.language = .english
+ #expect(localization.text("header.subtitle") == "Control iPhone GPS simulation from your Mac")
+ #expect(localization.text("settings.language") == "App language")
+
+ localization.language = .russian
+ #expect(localization.text("header.subtitle").contains("симуляцией GPS"))
+ #expect(localization.text("settings.language") == "Язык приложения")
+ #expect(KeeperError.commandFailed("The command timed out after 5 seconds.")
+ .description(using: localization) == "Команда завершилась с ошибкой.")
+ }
+
+ private func makeDefaults() throws -> (UserDefaults, String) {
+ let suiteName = "GeoShift.LocalizationTests.\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ return (defaults, suiteName)
+ }
+}
diff --git a/Tests/GeoShiftTests/LogReaderTests.swift b/Tests/GeoShiftTests/LogReaderTests.swift
new file mode 100644
index 0000000..8c7a44e
--- /dev/null
+++ b/Tests/GeoShiftTests/LogReaderTests.swift
@@ -0,0 +1,23 @@
+import Foundation
+import Testing
+@testable import GeoShift
+
+@Suite("Log reader")
+struct LogReaderTests {
+ @Test("Only the requested number of recent log lines is returned")
+ func limitsLines() throws {
+ let url = FileManager.default.temporaryDirectory
+ .appending(path: "geoshift-log-reader-\(UUID().uuidString).log")
+ defer { try? FileManager.default.removeItem(at: url) }
+
+ let source = (1...80).map { "event \($0)" }.joined(separator: "\n")
+ try source.write(to: url, atomically: true, encoding: .utf8)
+
+ let result = LogReader.tail(of: url, maximumLines: 50)
+ let lines = result.split(separator: "\n")
+
+ #expect(lines.count == 50)
+ #expect(lines.first == "event 31")
+ #expect(lines.last == "event 80")
+ }
+}
diff --git a/Tests/GeoShiftTests/StatusParserTests.swift b/Tests/GeoShiftTests/StatusParserTests.swift
index 85a90b8..c93c53e 100644
--- a/Tests/GeoShiftTests/StatusParserTests.swift
+++ b/Tests/GeoShiftTests/StatusParserTests.swift
@@ -1,44 +1,109 @@
-import Testing
import Foundation
+import Testing
@testable import GeoShift
@Suite("Keeper status parsing")
struct StatusParserTests {
- @Test("Stopped when launchd service is absent")
- func stopped() {
- #expect(StatusParser.state(launchctlOutput: "", logs: "") == .stopped)
+ private let enabled = KeeperConfiguration(
+ cityID: "belgrade",
+ cityName: "Белград",
+ country: "Сербия",
+ latitude: 44.8125,
+ longitude: 20.4612,
+ retrySeconds: 5,
+ refreshSeconds: 10,
+ requestID: "request",
+ simulationEnabled: true
+ )
+
+ @Test("A missing configuration never proves real GPS")
+ func missingConfigurationIsUnknown() {
+ #expect(StatusParser.state(
+ launchctlOutput: "",
+ configuration: nil,
+ workerStatus: status(.active, mayBeActive: true)
+ ) == .failed)
}
- @Test("Active after a successful location refresh")
- func active() {
- let state = StatusParser.state(
- launchctlOutput: "state = running",
- logs: "Location Keeper started\nLocation set: Belgrade, Serbia"
+ @Test("An absent service never proves real GPS for an enabled request")
+ func absentServiceIsUnknown() {
+ #expect(StatusParser.state(
+ launchctlOutput: "",
+ configuration: enabled,
+ workerStatus: status(.active, mayBeActive: true)
+ ) == .failed)
+ }
+
+ @Test("Only a matching successful clear status marks simulation inactive")
+ func matchingSuccessfulClearStatus() {
+ let disabled = configuration(enabled: false)
+
+ #expect(StatusParser.state(
+ launchctlOutput: "",
+ configuration: disabled,
+ workerStatus: status(.cleared, mayBeActive: false)
+ ) == .stopped)
+ }
+
+ @Test("A failed or stale clear remains pending")
+ func clearRemainsPending() {
+ let disabled = configuration(enabled: false)
+ let stale = WorkerStatus(
+ phase: .cleared,
+ requestID: "old-request",
+ deviceUDID: "phone",
+ simulationMayBeActive: false,
+ message: nil,
+ updatedAt: 0
)
- #expect(state == .active)
+
+ #expect(StatusParser.state(
+ launchctlOutput: "",
+ configuration: disabled,
+ workerStatus: stale
+ ) == .failed)
+ #expect(StatusParser.state(
+ launchctlOutput: "state = running",
+ configuration: disabled,
+ workerStatus: status(.clearPending, mayBeActive: true)
+ ) == .restoring)
}
- @Test("Waiting when iPhone is disconnected")
- func waiting() {
- let state = StatusParser.state(
+ @Test("Active requires the matching request and a running worker")
+ func activeRequiresRunningWorker() {
+ #expect(StatusParser.state(
launchctlOutput: "state = running",
- logs: "Location Keeper started\niPhone is not connected; waiting 3 seconds"
- )
- #expect(state == .waiting)
+ configuration: enabled,
+ workerStatus: status(.active, mayBeActive: true)
+ ) == .active)
}
- @Test("Only the current keeper session affects status")
- func currentSessionWins() {
- let state = StatusParser.state(
+ @Test("Waiting is machine-readable rather than inferred from logs")
+ func waiting() {
+ #expect(StatusParser.state(
launchctlOutput: "state = running",
- logs: """
- Location Keeper started
- Location set: Belgrade, Serbia
- Location Keeper started
- iPhone is not connected; waiting 3 seconds
- """
+ configuration: enabled,
+ workerStatus: status(.waitingForDevice, mayBeActive: false)
+ ) == .waiting)
+ }
+
+ @Test("A stale worker heartbeat is unknown")
+ func staleHeartbeat() {
+ let stale = WorkerStatus(
+ phase: .active,
+ requestID: enabled.requestID,
+ deviceUDID: "phone",
+ simulationMayBeActive: true,
+ message: nil,
+ updatedAt: 100
)
- #expect(state == .waiting)
+
+ #expect(StatusParser.state(
+ launchctlOutput: "state = running",
+ configuration: enabled,
+ workerStatus: stale,
+ now: 1_000
+ ) == .failed)
}
@Test("LaunchAgent receives a decoded file-system path")
@@ -54,4 +119,29 @@ struct StatusParserTests {
#expect(arguments.last == "/Applications/GeoShift.app/Contents/Resources/keeper.py")
#expect(arguments.last?.contains("%20") == false)
}
+
+ private func configuration(enabled: Bool) -> KeeperConfiguration {
+ KeeperConfiguration(
+ cityID: self.enabled.cityID,
+ cityName: self.enabled.cityName,
+ country: self.enabled.country,
+ latitude: self.enabled.latitude,
+ longitude: self.enabled.longitude,
+ retrySeconds: self.enabled.retrySeconds,
+ refreshSeconds: self.enabled.refreshSeconds,
+ requestID: self.enabled.requestID,
+ simulationEnabled: enabled
+ )
+ }
+
+ private func status(_ phase: WorkerStatus.Phase, mayBeActive: Bool) -> WorkerStatus {
+ WorkerStatus(
+ phase: phase,
+ requestID: enabled.requestID,
+ deviceUDID: "phone",
+ simulationMayBeActive: mayBeActive,
+ message: nil,
+ updatedAt: Date.now.timeIntervalSince1970
+ )
+ }
}
diff --git a/Tests/Python/test_keeper.py b/Tests/Python/test_keeper.py
new file mode 100644
index 0000000..7400fbc
--- /dev/null
+++ b/Tests/Python/test_keeper.py
@@ -0,0 +1,353 @@
+import asyncio
+import importlib.util
+import json
+import os
+import tempfile
+import unittest
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import patch
+
+
+KEEPER_PATH = Path(__file__).parents[2] / "Sources/GeoShift/Resources/keeper.py"
+os.environ["GEOSHIFT_LOG_PATH"] = f"/tmp/geoshift-worker-tests-{os.getpid()}.log"
+SPEC = importlib.util.spec_from_file_location("geoshift_keeper", KEEPER_PATH)
+keeper = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(keeper)
+
+
+class ConfigurationTests(unittest.TestCase):
+ def test_missing_config_fails_closed(self):
+ with tempfile.TemporaryDirectory() as directory:
+ with self.assertRaises(keeper.ConfigurationError):
+ keeper.load_config(Path(directory) / "missing.json")
+
+ def test_invalid_coordinates_fail_closed(self):
+ with tempfile.TemporaryDirectory() as directory:
+ path = Path(directory) / "config.json"
+ path.write_text(json.dumps({
+ "cityID": "bad",
+ "cityName": "Bad",
+ "country": "Bad",
+ "latitude": 200,
+ "longitude": 20,
+ "retrySeconds": 5,
+ "refreshSeconds": 10,
+ "requestID": "request",
+ }))
+
+ with self.assertRaises(keeper.ConfigurationError):
+ keeper.load_config(path)
+
+ def test_stale_app_heartbeat_turns_simulation_into_restore(self):
+ config = {
+ "simulationEnabled": True,
+ "appHeartbeatAt": 1_000.0,
+ }
+
+ self.assertFalse(keeper.simulation_is_requested(config, now=1_301.0))
+
+ def test_fresh_app_heartbeat_keeps_simulation_requested(self):
+ config = {
+ "simulationEnabled": True,
+ "appHeartbeatAt": 1_000.0,
+ }
+
+ self.assertTrue(keeper.simulation_is_requested(config, now=1_299.0))
+
+ def test_missing_app_heartbeat_fails_safe_to_restore(self):
+ self.assertFalse(keeper.simulation_is_requested({"simulationEnabled": True}))
+
+
+class WorkerSafetyTests(unittest.IsolatedAsyncioTestCase):
+ def test_forced_shutdown_is_successful_only_after_durable_clear(self):
+ self.assertEqual(
+ keeper.forced_shutdown_exit_code({
+ "phase": "cleared",
+ "simulationMayBeActive": False,
+ }),
+ 0,
+ )
+ self.assertEqual(
+ keeper.forced_shutdown_exit_code({
+ "phase": "clearPending",
+ "simulationMayBeActive": True,
+ }),
+ 75,
+ )
+
+ async def test_latch_is_written_before_location_set(self):
+ with tempfile.TemporaryDirectory() as directory:
+ status_path = Path(directory) / "status.json"
+ config = {
+ "requestID": "request",
+ "cityName": "Белград",
+ "country": "Сербия",
+ "latitude": 44.8125,
+ "longitude": 20.4612,
+ }
+
+ class FakeLocation:
+ async def set(self, latitude, longitude):
+ status = keeper.read_status(status_path)
+ self.assertEqual(status["phase"], "applying")
+ self.assertTrue(status["simulationMayBeActive"])
+
+ location = FakeLocation()
+ location.assertEqual = self.assertEqual
+ location.assertTrue = self.assertTrue
+
+ with patch.object(keeper, "STATUS_PATH", status_path):
+ await keeper.apply_location(
+ location,
+ config,
+ asyncio.Event(),
+ "target-phone",
+ )
+
+ status = keeper.read_status(status_path)
+ self.assertEqual(status["phase"], "active")
+ self.assertEqual(status["deviceUDID"], "target-phone")
+
+ async def test_failed_latch_write_prevents_location_set(self):
+ location = SimpleNamespace(set_called=False)
+
+ async def fake_set(latitude, longitude):
+ location.set_called = True
+
+ location.set = fake_set
+ config = {
+ "requestID": "request",
+ "cityName": "Белград",
+ "country": "Сербия",
+ "latitude": 44.8125,
+ "longitude": 20.4612,
+ }
+
+ with patch.object(keeper, "write_status", side_effect=OSError("disk full")):
+ with self.assertRaises(OSError):
+ await keeper.apply_location(
+ location,
+ config,
+ asyncio.Event(),
+ "target-phone",
+ )
+
+ self.assertFalse(location.set_called)
+
+ async def test_failed_clear_never_writes_cleared_status(self):
+ with tempfile.TemporaryDirectory() as directory:
+ status_path = Path(directory) / "status.json"
+ config = {"requestID": "request"}
+
+ class FakeLocation:
+ async def clear(self):
+ raise TimeoutError("device stopped responding")
+
+ with patch.object(keeper, "STATUS_PATH", status_path):
+ with self.assertRaises(TimeoutError):
+ await keeper.clear_over_open_tunnel(
+ FakeLocation(),
+ config,
+ "target-phone",
+ )
+
+ status = keeper.read_status(status_path)
+ self.assertEqual(status["phase"], "clearing")
+ self.assertTrue(status["simulationMayBeActive"])
+
+ async def test_successful_clear_writes_false_latch(self):
+ with tempfile.TemporaryDirectory() as directory:
+ status_path = Path(directory) / "status.json"
+ config = {"requestID": "request"}
+
+ class FakeLocation:
+ async def clear(self):
+ return None
+
+ with patch.object(keeper, "STATUS_PATH", status_path):
+ await keeper.clear_over_open_tunnel(
+ FakeLocation(),
+ config,
+ "target-phone",
+ )
+
+ status = keeper.read_status(status_path)
+ self.assertEqual(status["phase"], "cleared")
+ self.assertFalse(status["simulationMayBeActive"])
+
+ async def test_disabled_worker_keeps_clear_pending_without_device(self):
+ with tempfile.TemporaryDirectory() as directory:
+ config_path = Path(directory) / "config.json"
+ status_path = Path(directory) / "status.json"
+ config_path.write_text(json.dumps({
+ "cityID": "moscow",
+ "cityName": "Москва",
+ "country": "Россия",
+ "latitude": 55.7558,
+ "longitude": 37.6173,
+ "retrySeconds": 5,
+ "refreshSeconds": 10,
+ "requestID": "restore-request",
+ "simulationEnabled": False,
+ }))
+ stop_event = asyncio.Event()
+
+ async def no_devices():
+ return []
+
+ async def stop_after_first_attempt(*args, **kwargs):
+ stop_event.set()
+ return False
+
+ with (
+ patch.object(keeper, "CONFIG_PATH", config_path),
+ patch.object(keeper, "STATUS_PATH", status_path),
+ patch.object(keeper, "list_devices", no_devices),
+ patch.object(keeper, "iter_remote_paired_identifiers", return_value=[]),
+ patch.object(
+ keeper,
+ "wait_until_timeout_or_config_change",
+ stop_after_first_attempt,
+ ),
+ ):
+ await keeper.run_worker(stop_event)
+
+ status = keeper.read_status(status_path)
+ self.assertEqual(status["phase"], "clearPending")
+ self.assertTrue(status["simulationMayBeActive"])
+
+ async def test_invalid_config_attempts_conservative_clear(self):
+ with tempfile.TemporaryDirectory() as directory:
+ config_path = Path(directory) / "config.json"
+ status_path = Path(directory) / "status.json"
+ config_path.write_text("not-json")
+ status_path.write_text(json.dumps({
+ "phase": "active",
+ "requestID": "last-known-request",
+ "deviceUDID": "target-phone",
+ "simulationMayBeActive": True,
+ }))
+ stop_event = asyncio.Event()
+
+ async def no_device(*args, **kwargs):
+ raise keeper.NoDeviceConnectedError()
+
+ async def stop_after_first_attempt(*args, **kwargs):
+ stop_event.set()
+ return False
+
+ with (
+ patch.object(keeper, "CONFIG_PATH", config_path),
+ patch.object(keeper, "STATUS_PATH", status_path),
+ patch.object(keeper, "clear_location_for_config", no_device),
+ patch.object(
+ keeper,
+ "wait_until_timeout_or_config_change",
+ stop_after_first_attempt,
+ ),
+ ):
+ await keeper.run_worker(stop_event)
+
+ status = keeper.read_status(status_path)
+ self.assertEqual(status["phase"], "clearPending")
+ self.assertEqual(status["requestID"], "last-known-request")
+ self.assertTrue(status["simulationMayBeActive"])
+
+ async def test_multiple_devices_are_rejected_without_a_saved_target(self):
+ async def fake_list_devices():
+ return [SimpleNamespace(serial="phone-a"), SimpleNamespace(serial="phone-b")]
+
+ with patch.object(keeper, "list_devices", fake_list_devices):
+ with self.assertRaises(keeper.AmbiguousDeviceError):
+ await keeper.resolve_target_device(None)
+
+ async def test_remote_pairing_is_used_when_usbmux_has_no_device(self):
+ async def no_devices():
+ return []
+
+ with (
+ patch.object(keeper, "list_devices", no_devices),
+ patch.object(
+ keeper,
+ "iter_remote_paired_identifiers",
+ return_value=["target-phone"],
+ ),
+ ):
+ target = await keeper.resolve_target_device(None)
+
+ self.assertEqual(target, ("target-phone", True))
+
+ async def test_remote_pairing_discovery_waits_long_enough_for_iphone(self):
+ captured = {}
+ fake_service = SimpleNamespace(udid="target-phone")
+
+ async def find_services(*, bonjour_timeout, udid):
+ captured["bonjour_timeout"] = bonjour_timeout
+ captured["udid"] = udid
+ return [fake_service]
+
+ class FakeTunnel:
+ def __init__(self, serial, autopair):
+ self.serial = serial
+ self.autopair = autopair
+
+ async def aopen(self):
+ provider, _ = await keeper.userspace_tunnel._create_no_root_tunnel_provider(
+ self.serial,
+ self.autopair,
+ )
+ return provider
+
+ with (
+ patch.object(keeper, "UserspaceRsdTunnel", FakeTunnel),
+ patch.object(
+ keeper.tunnel_service,
+ "get_remote_pairing_tunnel_services",
+ find_services,
+ ),
+ ):
+ tunnel, service = await keeper.open_target_tunnel("target-phone", True)
+
+ self.assertIsInstance(tunnel, FakeTunnel)
+ self.assertIs(service, fake_service)
+ self.assertEqual(captured["udid"], "target-phone")
+ self.assertGreaterEqual(captured["bonjour_timeout"], 8)
+
+ async def test_matching_cleared_status_exits_without_reopening_phone(self):
+ with tempfile.TemporaryDirectory() as directory:
+ config_path = Path(directory) / "config.json"
+ status_path = Path(directory) / "status.json"
+ config_path.write_text(json.dumps({
+ "cityID": "moscow",
+ "cityName": "Москва",
+ "country": "Россия",
+ "latitude": 55.7558,
+ "longitude": 37.6173,
+ "retrySeconds": 5,
+ "refreshSeconds": 10,
+ "requestID": "cleared-request",
+ "simulationEnabled": False,
+ }))
+ status_path.write_text(json.dumps({
+ "phase": "cleared",
+ "requestID": "cleared-request",
+ "deviceUDID": "target-phone",
+ "simulationMayBeActive": False,
+ "message": "cleared",
+ "updatedAt": 1,
+ }))
+
+ async def must_not_resolve(*args, **kwargs):
+ self.fail("A recorded successful clear must not reopen a device connection")
+
+ with (
+ patch.object(keeper, "CONFIG_PATH", config_path),
+ patch.object(keeper, "STATUS_PATH", status_path),
+ patch.object(keeper, "resolve_target_device", must_not_resolve),
+ ):
+ await keeper.run_worker(asyncio.Event())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..d20218a
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,62 @@
+# Architecture
+
+GeoShift separates the macOS user interface from the persistent device worker.
+
+```text
+SwiftUI app
+ ├─ writes atomic config.json + app heartbeat
+ ├─ reads atomic status.json
+ ├─ manages a per-user LaunchAgent
+ └─ runs the iOS 27 pairing assistant
+ │
+ ▼
+LaunchAgent → bundled keeper.py → pymobiledevice3 → trusted physical iPhone
+```
+
+## SwiftUI controller
+
+`KeeperController` owns the selected city, timing settings, state presentation,
+pairing flow, and LaunchAgent lifecycle. English is the first-run language;
+`LocalizationStore` persists an explicit English/Russian selection without
+changing the safety request or worker identity.
+
+## Configuration and status
+
+`config.json` contains a unique request ID, selected destination, intervals,
+whether simulation is requested, and the app heartbeat timestamp.
+
+`status.json` is a separate pessimistic latch. Before sending a no-reply location
+set command, the worker durably records that simulation may be active. Only a
+clear command that returns successfully through the developer channel writes
+`simulationMayBeActive = false`. The API does not acknowledge a physical GPS fix.
+
+The UI never treats a missing process, stale log, or truncated diagnostics file
+as proof that real GPS has returned.
+
+## Connection paths
+
+The worker chooses one exact trusted iPhone and supports:
+
+1. USB/usbmux;
+2. trusted legacy Wi-Fi lockdown;
+3. iOS 27+ RemotePairing over Bonjour and a saved pairing record.
+
+Multiple ambiguous devices are rejected. A saved target is never silently
+replaced while simulation may be active.
+
+## Fail-safe lifecycle
+
+- Normal Start/Restore updates configuration without tearing down a healthy tunnel.
+- Closing the window or Command-Q queues Restore GPS.
+- If that handoff cannot be proven safe, termination is refused.
+- The app writes a heartbeat every 30 seconds.
+- A stale heartbeat older than five minutes converts Start into Restore GPS.
+- A five-minute watchdog repairs a missing or stale worker.
+- If the phone is offline, `clearPending` persists until the trusted phone returns.
+- A corrupt or missing configuration triggers a conservative clear attempt.
+- Forced worker termination exits successfully only after a durable clear status.
+
+## Diagnostics
+
+The worker uses rotating 2 MiB logs with two backups. The UI reads only the last
+50 events every ten seconds and keeps diagnostics collapsed by default.
diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md
new file mode 100644
index 0000000..476c705
--- /dev/null
+++ b/docs/COMPATIBILITY.md
@@ -0,0 +1,28 @@
+# Compatibility
+
+| Component | Supported or expected | Notes |
+| --- | --- | --- |
+| macOS | 14+ | SwiftUI desktop app and per-user LaunchAgent |
+| Swift | 6.2+ | Required by `Package.swift` |
+| Python | 3.11–3.13 | Python 3.13 is recommended |
+| pymobiledevice3 | 9.31.0 | Installed separately, not bundled |
+| Physical iPhone | iOS 17+ | Developer Mode and a trusted Mac are required |
+| iOS 27 Wi-Fi pairing | Beta-tested | Device-initiated RemotePairing, no cable required |
+| Older iOS pairing | USB first | Later trusted Wi-Fi reconnect may work |
+| iOS Simulator | Not targeted | Use Xcode's built-in simulator location controls |
+| Apple Silicon | Supported | Primary development platform |
+| Intel Mac | Expected | Requires compatible Swift and Python toolchains |
+
+The iOS 27 RemotePairing path has been verified on the current iOS 27 beta over
+local Wi-Fi. Apple may change this developer service before or between stable
+releases. Compatibility reports are welcome, but redact device identifiers.
+
+## Known limitations
+
+- The app is source-distributed and public builds are not notarized.
+- Wi-Fi pairing requires local peer-to-peer connectivity.
+- GeoShift changes Core Location coordinates, not public IP or network country.
+- Apps can detect developer location simulation or cross-check GPS against IP.
+- Apple can change private developer-service behavior between iOS releases.
+- A phone restart clears the current developer location simulation. If **Start**
+ remains requested and the worker reconnects, GeoShift can apply it again.
diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md
new file mode 100644
index 0000000..70f7008
--- /dev/null
+++ b/docs/INSTALLATION.md
@@ -0,0 +1,63 @@
+# Installation
+
+GeoShift is currently distributed as source code. The install script builds an
+ad-hoc signed macOS app, copies it to `/Applications`, and opens it.
+
+## Requirements
+
+- macOS 14 or newer
+- Apple Silicon or Intel Mac capable of running Swift 6.2
+- Xcode 26 or a Swift 6.2 toolchain
+- Python 3.11–3.13
+- `pymobiledevice3` 9.31.0
+- a physical iPhone with Developer Mode enabled
+
+GeoShift does not target the iOS Simulator. Xcode already includes location
+simulation controls for simulator devices.
+
+## Install the backend
+
+The recommended installation keeps `pymobiledevice3` isolated with `uv`:
+
+```bash
+brew install uv
+uv tool install --python 3.13 'pymobiledevice3==9.31.0'
+```
+
+GeoShift also checks common Homebrew, pipx, and local-bin locations.
+
+## Build and install GeoShift
+
+```bash
+git clone https://github.com/Lemelson/GeoShift.git
+cd GeoShift
+./Scripts/install_app.sh
+```
+
+The resulting app is `/Applications/GeoShift.app`.
+
+## First launch and Gatekeeper
+
+Public builds are ad-hoc signed but not notarized. If macOS blocks the first
+launch, Control-click **GeoShift.app** in Finder, choose **Open**, and confirm.
+You normally do this once for a particular build.
+
+Do not disable Gatekeeper globally.
+
+## Developer Mode on iPhone
+
+On the iPhone, open **Settings → Privacy & Security → Developer Mode** and follow
+the restart/confirmation flow. Apple may ask you to reconfirm Developer Mode
+after some iOS updates.
+
+Continue with the [pairing guide](PAIRING.md).
+
+## Build without installing
+
+```bash
+swift test
+./Scripts/package_app.sh release
+```
+
+The packaged app is written to `GeoShift.app` in the repository root and is
+ignored by Git.
diff --git a/docs/PAIRING.md b/docs/PAIRING.md
new file mode 100644
index 0000000..1e61094
--- /dev/null
+++ b/docs/PAIRING.md
@@ -0,0 +1,53 @@
+# Pairing an iPhone
+
+GeoShift can use trusted USB, legacy Wi-Fi lockdown, or iOS 27+ RemotePairing.
+The app always records the exact target iPhone and will not clear another device.
+
+## iOS 27 or newer: pair without a cable
+
+1. Put the Mac and iPhone on the same local Wi-Fi network.
+2. Unlock the iPhone and keep GeoShift open.
+3. In GeoShift, open **Settings → Connection → Set up Wi-Fi connection**.
+4. On iPhone, open **Settings → Developer → Paired Macs**.
+5. Under **Other Devices**, tap **GeoShift**.
+6. Enter the six-digit code shown by GeoShift.
+7. Wait for GeoShift to report that the pairing credentials were saved.
+
+The dedicated GeoShift record is separate from the ordinary MacBook Pro system
+pair. Do not delete the existing system pair.
+
+## USB fallback and older iOS versions
+
+1. Connect the unlocked iPhone to the Mac with a data-capable cable.
+2. Tap **Trust** on the iPhone and enter the device passcode if requested.
+3. Confirm that Finder can see the iPhone.
+4. Leave the phone unlocked for the first developer-service connection.
+5. Open GeoShift and choose **Reconnect**.
+
+After the first trusted connection, wireless reconnect may work while both
+devices remain on the same network.
+
+## How often must pairing be renewed?
+
+Normally once. It is not a weekly, biweekly, or monthly permission.
+
+Pair again only when:
+
+- GeoShift was manually removed from **Paired Macs**;
+- the local pairing record was deleted;
+- trust or network settings were reset;
+- macOS was reinstalled or the user profile was replaced;
+- an iOS/macOS update invalidated the record;
+- GeoShift explicitly reports invalid pairing credentials.
+
+Restarting either device, locking the iPhone, sleeping the Mac, or reconnecting
+to the same Wi-Fi should not require a new six-digit code.
+
+## Network notes
+
+- Both devices must be able to reach each other on the local network.
+- Guest Wi-Fi and client-isolation networks often block peer-to-peer traffic.
+- VPN and firewall software may block Bonjour or local-network traffic even
+ though both devices appear connected to Wi-Fi.
+- A delayed Bonjour advertisement is normal; GeoShift waits before declaring
+ the phone unavailable and then keeps retrying safely.
diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md
new file mode 100644
index 0000000..f4a74e6
--- /dev/null
+++ b/docs/PRIVACY.md
@@ -0,0 +1,35 @@
+# Privacy
+
+GeoShift has no analytics, advertising SDK, account system, cloud backend, or
+remote application server. It does not upload the selected city, coordinates,
+device identity, diagnostics, or pairing material.
+
+## Local data
+
+GeoShift stores runtime data only for the current macOS user:
+
+| Path | Purpose |
+| --- | --- |
+| `~/Library/Application Support/GeoShift/config.json` | selected city, intervals, request ID, safety heartbeat |
+| `~/Library/Application Support/GeoShift/status.json` | worker phase, exact target UDID, clear-safety latch |
+| `~/Library/Application Support/GeoShift/pairing-status.json` | temporary pairing assistant state and code |
+| `~/Library/Logs/GeoShift.log` | rotating diagnostics |
+| `~/.pymobiledevice3/remote_*.plist` | RemotePairing credentials managed by pymobiledevice3 |
+| `~/Library/LaunchAgents/com.lemelson.geoshift.keeper.plist` | per-user background worker definition |
+
+The exact device UDID is stored locally while needed to prevent GeoShift from
+clearing or controlling the wrong iPhone.
+
+## Network behavior
+
+GeoShift communicates with the trusted iPhone over USB or the local network.
+Installing source dependencies and cloning the repository use their normal
+internet endpoints; GeoShift itself does not operate a cloud service.
+
+## Publishing diagnostics
+
+Logs can contain device identifiers, local IP addresses, usernames, or local
+paths. Review and redact them before opening a public issue. Never publish
+pairing records or trust material.
+
+See [Uninstall](UNINSTALL.md) to remove all local data.
diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md
new file mode 100644
index 0000000..394ed53
--- /dev/null
+++ b/docs/TROUBLESHOOTING.md
@@ -0,0 +1,75 @@
+# Troubleshooting
+
+## GeoShift is waiting for iPhone
+
+1. Unlock the iPhone and keep its screen awake temporarily.
+2. Confirm the Mac and iPhone use the same local network.
+3. Open **Settings → Connection** in GeoShift and choose **Check connection**.
+4. On iOS 27+, verify that GeoShift still appears under iPhone
+ **Settings → Developer → Paired Macs**.
+5. Disable guest-network isolation or allow local traffic through VPN/firewall
+ software.
+6. Use a trusted USB connection as a fallback.
+
+GeoShift deliberately keeps a pending Restore GPS request when the phone is
+offline. Reconnecting the trusted phone lets the worker finish that clear.
+
+## The phone still shows the previous city
+
+- Choose **Restore GPS** and wait for **Simulation cleared**.
+- Keep the iPhone unlocked until GeoShift reports that the clear command was sent.
+- Open Apple Maps and wait for a fresh location fix.
+- Restarting the iPhone clears the current simulation. If **Start** is still
+ requested and the worker remains active, GeoShift may reconnect and reapply it.
+
+The developer API's `clear` operation does not request a reply and cannot read
+back the physical GPS coordinate. A successful send is the strongest programmatic
+signal; Maps is the practical sensor-side verification.
+
+## Pairing code never appears
+
+- Keep **Paired Macs** open while the assistant is running.
+- Confirm the phone runs iOS 27 or newer for device-initiated RemotePairing.
+- Retry the assistant; stale pairing sessions are discarded on app launch.
+- Use USB for older iOS versions.
+
+## `pymobiledevice3` is missing
+
+```bash
+brew install uv
+uv tool install --python 3.13 'pymobiledevice3==9.31.0'
+```
+
+Then reopen GeoShift.
+
+## Command-Q is refused
+
+GeoShift stays open when it cannot confirm either a completed clear or a durable
+handoff to the background restore queue. Reconnect the iPhone if possible,
+choose **Restore GPS**, and quit again after the status updates.
+
+## Collect diagnostics
+
+The in-app diagnostics panel shows the last 50 events. Full rotating logs are at:
+
+```bash
+tail -f "$HOME/Library/Logs/GeoShift.log"
+launchctl print "gui/$(id -u)/com.lemelson.geoshift.keeper"
+```
+
+Before posting logs publicly, redact device identifiers, IP addresses, usernames,
+local paths, and any pairing or trust material. Never attach files from
+`~/.pymobiledevice3`.
+
+## Report a bug
+
+Include:
+
+- GeoShift version;
+- macOS and iOS versions;
+- iPhone model;
+- USB or Wi-Fi connection type;
+- the visible GeoShift state and exact reproduction steps;
+- redacted recent diagnostics.
+
+Use a private Security Advisory for suspected security or privacy problems.
diff --git a/docs/UNINSTALL.md b/docs/UNINSTALL.md
new file mode 100644
index 0000000..ab9bfdc
--- /dev/null
+++ b/docs/UNINSTALL.md
@@ -0,0 +1,27 @@
+# Uninstall GeoShift
+
+First open GeoShift, choose **Restore GPS**, and wait for **Simulation cleared**.
+If the phone cannot connect, restart the iPhone before removing the restore queue.
+
+Then quit GeoShift and remove:
+
+```bash
+launchctl bootout "gui/$(id -u)/com.lemelson.geoshift.keeper" 2>/dev/null || true
+rm -f "$HOME/Library/LaunchAgents/com.lemelson.geoshift.keeper.plist"
+rm -rf "/Applications/GeoShift.app"
+rm -rf "$HOME/Library/Application Support/GeoShift"
+rm -f "$HOME/Library/Logs/GeoShift.log"*
+```
+
+Those commands remove the app, worker definition, configuration, status, and
+logs. They do not remove `pymobiledevice3` or its other device records.
+
+To remove only the dedicated GeoShift RemotePairing record, use pymobiledevice3's
+pair-management command or remove the matching device entry from iPhone
+**Settings → Developer → Paired Macs**. Do not delete unrelated pairing files.
+
+To remove the isolated backend installation:
+
+```bash
+uv tool uninstall pymobiledevice3
+```
diff --git a/version.env b/version.env
index 7f875f0..0b9f4ce 100644
--- a/version.env
+++ b/version.env
@@ -1,2 +1,2 @@
-MARKETING_VERSION=1.2.0
-BUILD_NUMBER=3
+MARKETING_VERSION=1.5.0
+BUILD_NUMBER=7