From 22705c08cc6b2451647412d3818b0c12b4cc0191 Mon Sep 17 00:00:00 2001 From: Lemelson <97357844+Lemelson@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:04:50 +0300 Subject: [PATCH] Release GeoShift 1.5 with fail-safe recovery and localization --- .github/ISSUE_TEMPLATE/bug_report.yml | 62 ++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 29 + .github/pull_request_template.md | 17 + .github/workflows/ci.yml | 44 ++ .gitignore | 13 + Assets/GeoShiftScreenshot.png | Bin 0 -> 137403 bytes CHANGELOG.md | 33 + CODE_OF_CONDUCT.md | 9 + CONTRIBUTING.md | 39 ++ Package.swift | 1 + README.md | 248 ++++--- SECURITY.md | 17 +- Scripts/package_app.sh | 9 + Sources/GeoShift/AppDelegate.swift | 22 + Sources/GeoShift/AppLanguage.swift | 21 + Sources/GeoShift/AppPaths.swift | 7 + Sources/GeoShift/City.swift | 52 +- Sources/GeoShift/CityPickerView.swift | 9 +- Sources/GeoShift/CityRegion.swift | 36 +- Sources/GeoShift/CityRowView.swift | 7 +- Sources/GeoShift/CommandRunner.swift | 93 ++- Sources/GeoShift/ConfigStore.swift | 17 +- Sources/GeoShift/ConnectionHelpView.swift | 16 +- Sources/GeoShift/ContentView.swift | 7 +- Sources/GeoShift/ControlBar.swift | 9 +- Sources/GeoShift/DestinationCard.swift | 78 ++- Sources/GeoShift/GeoShiftApp.swift | 15 +- Sources/GeoShift/HeaderView.swift | 5 +- Sources/GeoShift/KeeperConfiguration.swift | 45 ++ Sources/GeoShift/KeeperController.swift | 615 +++++++++++++----- Sources/GeoShift/KeeperError.swift | 21 +- Sources/GeoShift/KeeperState.swift | 24 +- Sources/GeoShift/LocalizationStore.swift | 40 ++ Sources/GeoShift/LogPanel.swift | 38 +- Sources/GeoShift/LogReader.swift | 22 +- Sources/GeoShift/PairingGuideView.swift | 107 +++ Sources/GeoShift/PairingStatus.swift | 16 + Sources/GeoShift/PairingStatusStore.swift | 20 + .../Resources/en.lproj/Localizable.strings | 101 +++ Sources/GeoShift/Resources/keeper.py | 613 ++++++++++++++--- Sources/GeoShift/Resources/pairing.py | 68 ++ .../Resources/ru.lproj/Localizable.strings | 101 +++ Sources/GeoShift/SettingsView.swift | 82 ++- Sources/GeoShift/StatusCard.swift | 53 +- Sources/GeoShift/StatusParser.swift | 50 +- Sources/GeoShift/WorkerStatus.swift | 22 + Sources/GeoShift/WorkerStatusStore.swift | 10 + Tests/GeoShiftTests/CityCatalogTests.swift | 25 + .../KeeperConfigurationTests.swift | 13 +- Tests/GeoShiftTests/LocalizationTests.swift | 55 ++ Tests/GeoShiftTests/LogReaderTests.swift | 23 + Tests/GeoShiftTests/StatusParserTests.swift | 142 +++- Tests/Python/test_keeper.py | 353 ++++++++++ docs/ARCHITECTURE.md | 62 ++ docs/COMPATIBILITY.md | 28 + docs/INSTALLATION.md | 63 ++ docs/PAIRING.md | 53 ++ docs/PRIVACY.md | 35 + docs/TROUBLESHOOTING.md | 75 +++ docs/UNINSTALL.md | 27 + version.env | 4 +- 62 files changed, 3390 insertions(+), 536 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 Assets/GeoShiftScreenshot.png create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Sources/GeoShift/AppDelegate.swift create mode 100644 Sources/GeoShift/AppLanguage.swift create mode 100644 Sources/GeoShift/LocalizationStore.swift create mode 100644 Sources/GeoShift/PairingGuideView.swift create mode 100644 Sources/GeoShift/PairingStatus.swift create mode 100644 Sources/GeoShift/PairingStatusStore.swift create mode 100644 Sources/GeoShift/Resources/en.lproj/Localizable.strings create mode 100644 Sources/GeoShift/Resources/pairing.py create mode 100644 Sources/GeoShift/Resources/ru.lproj/Localizable.strings create mode 100644 Sources/GeoShift/WorkerStatus.swift create mode 100644 Sources/GeoShift/WorkerStatusStore.swift create mode 100644 Tests/GeoShiftTests/LocalizationTests.swift create mode 100644 Tests/GeoShiftTests/LogReaderTests.swift create mode 100644 Tests/Python/test_keeper.py create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/COMPATIBILITY.md create mode 100644 docs/INSTALLATION.md create mode 100644 docs/PAIRING.md create mode 100644 docs/PRIVACY.md create mode 100644 docs/TROUBLESHOOTING.md create mode 100644 docs/UNINSTALL.md 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 0000000000000000000000000000000000000000..7d012118b4cff2e3636c134685ef8310b0b56668 GIT binary patch literal 137403 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

E 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 - GeoShift app icon + GeoShift iPhone GPS Simulator app icon

-

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. +

+ CI + Version 1.5.0 + macOS 14+ + Swift 6.2 + MIT License +

+ +

+ GeoShift showing a cleared iPhone location simulation, city map, controls, and collapsed diagnostics +

+ +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