Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@

All notable changes to GeoShift are documented here.

## 1.5.1 — 2026-07-21

### Fixed

- A timed-out `launchctl` status check could block the app's only monitoring task,
freeze the UI on “Simulation active,” and stop its heartbeat.
- The five-minute crash watchdog could consequently restore real GPS while the
GeoShift window was still open and Start was still selected.
- Command timeouts now return promptly, terminate the child process, and cannot
strand later commands behind an uncancellable synchronous wait.
- Worker crash detection now requires both a stale heartbeat and a released
process-liveness lease, so an alive but delayed or suspended app cannot trigger
an unwanted GPS restore.
- The liveness lease now passes a decoded Application Support path to the POSIX
lock API instead of treating the encoded `%20` path as a real directory.
- Start retries a liveness-lease acquisition after a concurrent crash cleanup
finishes, without requiring the app to be relaunched.
- Monitoring is owned by the app controller and refreshes immediately when the
app becomes active instead of depending on a single view task.

## 1.5.0 — 2026-07-20

### Added
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

<p align="center">
<a href="https://github.com/Lemelson/GeoShift/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/Lemelson/GeoShift/actions/workflows/ci.yml/badge.svg"></a>
<img alt="Version 1.5.0" src="https://img.shields.io/badge/version-1.5.0-2563eb">
<img alt="Version 1.5.1" src="https://img.shields.io/badge/version-1.5.1-2563eb">
<img alt="macOS 14+" src="https://img.shields.io/badge/macOS-14%2B-black">
<img alt="Swift 6.2" src="https://img.shields.io/badge/Swift-6.2-f05138">
<a href="LICENSE"><img alt="MIT License" src="https://img.shields.io/badge/license-MIT-green"></a>
Expand Down Expand Up @@ -122,10 +122,12 @@ The pairing permission does not need weekly or monthly renewal. See
- **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.
GeoShift writes an application heartbeat every 30 seconds and holds a kernel-owned
liveness lease for the lifetime of the GUI process. A delayed UI task or App Nap
cannot be mistaken for a crash: after a stale heartbeat, the worker restores real
GPS only when it can also prove that the GeoShift process is gone. 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
Expand Down
4 changes: 4 additions & 0 deletions Sources/GeoShift/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import AppKit
final class AppDelegate: NSObject, NSApplicationDelegate {
var controller: KeeperController?

func applicationDidBecomeActive(_ notification: Notification) {
controller?.refreshAfterActivation()
}

func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true
}
Expand Down
32 changes: 32 additions & 0 deletions Sources/GeoShift/AppLivenessLease.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Darwin
import Foundation

final class AppLivenessLease {
private let descriptor: Int32

init?(url: URL) {
try? FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)

let descriptor = open(
url.path(percentEncoded: false),
O_CREAT | O_RDWR | O_CLOEXEC,
S_IRUSR | S_IWUSR
)
guard descriptor >= 0 else {
return nil
}
guard flock(descriptor, LOCK_EX | LOCK_NB) == 0 else {
close(descriptor)
return nil
}
self.descriptor = descriptor
}

deinit {
flock(descriptor, LOCK_UN)
close(descriptor)
}
}
2 changes: 2 additions & 0 deletions Sources/GeoShift/AppPaths.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ enum AppPaths {

static let pairingStatusURL = applicationSupportURL.appending(path: "pairing-status.json")

static let appLivenessLockURL = applicationSupportURL.appending(path: "gui-liveness.lock")

static let pairingRecordsURL = FileManager.default.homeDirectoryForCurrentUser
.appending(path: ".pymobiledevice3")

Expand Down
192 changes: 141 additions & 51 deletions Sources/GeoShift/CommandRunner.swift
Original file line number Diff line number Diff line change
@@ -1,29 +1,147 @@
@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)
private let stateLock = NSLock()
private let process = Process()
private let outputURL: URL
private let outputHandle: FileHandle
private var continuation: CheckedContinuation<CommandResult, Never>?
private var timeoutTask: Task<Void, Never>?
private var didFinish = false
private var didCleanUp = false

init?() {
outputURL = FileManager.default.temporaryDirectory
.appending(path: "GeoShift-command-\(UUID().uuidString).log")
guard FileManager.default.createFile(
atPath: outputURL.path(),
contents: nil,
attributes: [.posixPermissions: 0o600]
), let handle = try? FileHandle(forWritingTo: outputURL) else {
return nil
}
outputHandle = handle
process.standardOutput = handle
process.standardError = handle
}

func run(
executable: String,
arguments: [String],
timeoutSeconds: Double
) async -> CommandResult {
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = arguments

return await withCheckedContinuation { continuation in
stateLock.lock()
self.continuation = continuation
stateLock.unlock()

process.terminationHandler = { [weak self] process in
self?.processDidTerminate(status: process.terminationStatus)
}

do {
try process.run()
} catch {
finish(
CommandResult(status: -1, output: error.localizedDescription),
processHasTerminated: true
)
cleanUpOutput()
return
}

let task = Task { [weak self] in
do {
try await Task.sleep(for: .seconds(timeoutSeconds))
} catch {
return
}
self?.timeOut(after: timeoutSeconds)
}

stateLock.lock()
if didFinish {
task.cancel()
} else {
timeoutTask = task
}
stateLock.unlock()
}
}

private func processDidTerminate(status: Int32) {
let output = readOutputAndCleanUp()
finish(
CommandResult(status: status, output: output),
processHasTerminated: true
)
}

func terminate() {
guard process.isRunning else {
private func timeOut(after seconds: Double) {
let result = CommandResult(
status: -2,
output: "The command timed out after \(Int(seconds)) seconds."
)
guard finish(result, processHasTerminated: false) else {
return
}
process.terminate()
}

func killIfNeeded() {
process.terminate()
if process.isRunning {
kill(process.processIdentifier, SIGKILL)
}
cleanUpOutput()
}

@discardableResult
private func finish(
_ result: CommandResult,
processHasTerminated: Bool
) -> Bool {
let continuation: CheckedContinuation<CommandResult, Never>?
let timeoutTask: Task<Void, Never>?

stateLock.lock()
if didFinish {
stateLock.unlock()
if processHasTerminated {
cleanUpOutput()
}
return false
}
didFinish = true
continuation = self.continuation
self.continuation = nil
timeoutTask = self.timeoutTask
self.timeoutTask = nil
stateLock.unlock()

timeoutTask?.cancel()
continuation?.resume(returning: result)
return true
}

private func readOutputAndCleanUp() -> String {
stateLock.lock()
if didCleanUp {
stateLock.unlock()
return ""
}
didCleanUp = true
stateLock.unlock()

try? outputHandle.synchronize()
try? outputHandle.close()
let data = (try? Data(contentsOf: outputURL)) ?? Data()
try? FileManager.default.removeItem(at: outputURL)
return String(decoding: data, as: UTF8.self)
}

private func cleanUpOutput() {
_ = readOutputAndCleanUp()
}
}

Expand All @@ -33,44 +151,16 @@ actor CommandRunner {
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.")
}

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."
guard let execution = CommandExecution() else {
return CommandResult(
status: -1,
output: "Could not create a temporary command output file."
)
group.cancelAll()
return result
}
return await execution.run(
executable: executable,
arguments: arguments,
timeoutSeconds: timeoutSeconds
)
}
}
3 changes: 0 additions & 3 deletions Sources/GeoShift/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,6 @@ struct ContentView: View {
}
.padding(24)
}
.task {
await controller.poll()
}
.sheet(isPresented: $isCityPickerPresented) {
CityPickerView(controller: controller)
}
Expand Down
1 change: 1 addition & 0 deletions Sources/GeoShift/GeoShiftApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ struct GeoShiftApp: App {
.frame(minWidth: 680, minHeight: 680)
.onAppear {
appDelegate.controller = controller
controller.startMonitoring()
}
}
.defaultSize(width: 760, height: 820)
Expand Down
Loading
Loading