diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..bc34b71 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,62 @@ +name: Bug report +description: Report reproducible GeoShift behavior. +title: "[Bug]: " +labels: [bug] +body: + - type: markdown + attributes: + value: | + Read the troubleshooting guide first. Never attach pairing records or unredacted logs. + - type: input + id: version + attributes: + label: GeoShift version + placeholder: 1.5.0 + validations: + required: true + - type: input + id: environment + attributes: + label: macOS, iOS, and iPhone model + placeholder: macOS 15.x, iOS 18.x, iPhone model + validations: + required: true + - type: dropdown + id: connection + attributes: + label: Connection type + options: + - iOS 27+ Wi-Fi RemotePairing + - Trusted USB + - Legacy trusted Wi-Fi + - Unknown + validations: + required: true + - type: textarea + id: steps + attributes: + label: Reproduction steps + description: Include the visible GeoShift state and what happened before it. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: diagnostics + attributes: + label: Redacted diagnostics + description: Remove UDIDs, IPs, usernames, local paths, pairing data, and trust material. + render: text + - type: checkboxes + id: safety + attributes: + label: Privacy confirmation + options: + - label: I removed device identifiers, IP addresses, personal paths, pairing records, and credentials. + required: true + - label: This is not a security vulnerability or private report. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..f372aa1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security or privacy report + url: https://github.com/Lemelson/GeoShift/security/advisories/new + about: Report vulnerabilities and privacy issues privately. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..58a92f6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,29 @@ +name: Feature request +description: Suggest an improvement for authorized iOS location testing. +title: "[Feature]: " +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Testing problem + description: What development or QA workflow is difficult today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed behavior + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: checkboxes + id: scope + attributes: + label: Scope confirmation + options: + - label: This request is for devices I own or am authorized to test. + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..7def0e6 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,17 @@ +## Summary + +Describe the user-visible behavior and why the change is needed. + +## Verification + +- [ ] `swift test` +- [ ] Python worker tests +- [ ] `git diff --check` +- [ ] English and Russian UI resources remain synchronized +- [ ] No generated app/build files, logs, device IDs, local paths, or pairing data + +## Safety impact + +Explain any effect on Start, Restore GPS, target-device selection, reconnect, +heartbeat, worker termination, or pending-clear behavior. Write “None” when the +change cannot affect those paths. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1c8123a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + swift: + name: Swift build and tests + runs-on: macos-26 + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Show Swift toolchain + run: swift --version + - name: Run Swift tests + run: swift test + - name: Validate packaging script + run: bash -n Scripts/package_app.sh Scripts/install_app.sh + + python: + name: Python worker tests + runs-on: macos-26 + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + - name: Install worker dependency + run: python -m pip install 'pymobiledevice3==9.31.0' + - name: Run worker tests + env: + PYTHONDONTWRITEBYTECODE: "1" + run: python -m unittest discover -s Tests/Python -v diff --git a/.gitignore b/.gitignore index 520d454..c065e9f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,16 @@ __pycache__/ *.xcuserstate DerivedData/ *.log +.env +.env.* +!.env.example +.pymobiledevice3/ +remote_*.plist +config.json +status.json +pairing-status.json +*.p12 +*.pfx +*.pem +*.key +*.mobileprovision diff --git a/Assets/GeoShiftScreenshot.png b/Assets/GeoShiftScreenshot.png new file mode 100644 index 0000000..7d01211 Binary files /dev/null and b/Assets/GeoShiftScreenshot.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3188577 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +All notable changes to GeoShift are documented here. + +## 1.5.0 — 2026-07-20 + +### Added + +- English interface by default with an in-app English/Russian language switch. +- Guided iOS 27+ device-initiated Wi-Fi pairing with a six-digit code. +- Durable worker status, exact target-device identity, and pending restore queue. +- Five-minute application heartbeat watchdog and startup recovery. +- Rotating diagnostics with a collapsed last-50-events view. +- Python regression tests for device selection, clear safety, stale heartbeat, + RemotePairing discovery, and corrupt-configuration recovery. + +### Changed + +- Restore GPS is now mandatory on normal app termination. +- Command-Q is refused if a safe restore handoff cannot be confirmed. +- RemotePairing discovery waits long enough for delayed Bonjour advertisements. +- City search supports both English and Russian names, countries, and regions. + +### Fixed + +- A simulated location could remain on the phone after the app or worker stopped. +- A stale worker could overwrite a confirmed clear with `clearPending`. +- A forced worker termination could incorrectly report success before clear. +- Missing or invalid configuration could abandon a device-side simulation. + +## 1.0.0 + +- Initial public release. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..67084bf --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,9 @@ +# Code of Conduct + +GeoShift is an open-source engineering project. Be respectful, constructive, +and specific. Discuss the work rather than the person, welcome corrections, +and avoid harassment, discrimination, threats, or publication of private data. + +Maintainers may edit or remove abusive, unsafe, off-topic, or privacy-violating +contributions and may restrict participation when necessary. Report conduct or +privacy concerns privately through the repository's Security Advisory channel. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..17b6872 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Contributing to GeoShift + +Thank you for helping improve GeoShift. Bug reports, documentation fixes, +compatibility results, translations, and focused code contributions are welcome. + +## Before opening an issue + +1. Read the [troubleshooting guide](docs/TROUBLESHOOTING.md). +2. Search existing issues. +3. Reproduce with the latest `main` branch when practical. +4. Redact device UDIDs, IP addresses, usernames, local paths, and pairing data. + +Use GitHub Security Advisories, not a public issue, for security or privacy bugs. + +## Development setup + +```bash +brew install uv +uv tool install --python 3.13 'pymobiledevice3==9.31.0' +swift test +PYTHONDONTWRITEBYTECODE=1 uv run --with 'pymobiledevice3==9.31.0' \ + python -m unittest discover -s Tests/Python -v +``` + +See [Installation](docs/INSTALLATION.md) for the complete setup. + +## Pull requests + +- Keep changes focused and explain the user-visible behavior. +- Add or update tests for safety-critical worker and state transitions. +- Keep English and Russian UI resources synchronized. +- Do not commit generated `.app` bundles, `.build`, logs, runtime JSON, pairing + records, credentials, or device identifiers. +- Run `swift test`, the Python test suite, and `git diff --check` before opening + a pull request. + +GeoShift controls a persistent device-side location simulation. Changes to +start, stop, reconnect, process termination, or target-device selection must +fail safe toward a durable Restore GPS request. diff --git a/Package.swift b/Package.swift index a64f68a..1555e7a 100644 --- a/Package.swift +++ b/Package.swift @@ -3,6 +3,7 @@ import PackageDescription let package = Package( name: "GeoShift", + defaultLocalization: "en", platforms: [ .macOS(.v14), ], diff --git a/README.md b/README.md index 328f7da..de5cd9b 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,93 @@

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