Merge pull request #106 from TastyHeadphones/codex/location-presets-route-replay

Add location presets and route replay controls
This commit is contained in:
Lakr
2026-03-05 10:24:30 +08:00
committed by GitHub
4 changed files with 286 additions and 1 deletions

View File

@@ -150,6 +150,7 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate {
}
}
control.onDisconnect = { [weak mc, weak provider = locationProvider] in
provider?.stopReplay()
provider?.stopForwarding()
mc?.updateLocationCapability(available: false)
}
@@ -163,6 +164,7 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate {
}
}
control.onDisconnect = { [weak provider = locationProvider] in
provider?.stopReplay()
provider?.stopForwarding()
}
}

View File

@@ -9,12 +9,44 @@ import Foundation
/// after vphoned reconnects) re-sends the last known position.
@MainActor
class VPhoneLocationProvider: NSObject {
struct ReplayPoint {
let latitude: Double
let longitude: Double
let altitude: Double
let horizontalAccuracy: Double
let verticalAccuracy: Double
let speed: Double
let course: Double
init(
latitude: Double,
longitude: Double,
altitude: Double = 0,
horizontalAccuracy: Double = 5,
verticalAccuracy: Double = 8,
speed: Double = 0,
course: Double = -1
) {
self.latitude = latitude
self.longitude = longitude
self.altitude = altitude
self.horizontalAccuracy = horizontalAccuracy
self.verticalAccuracy = verticalAccuracy
self.speed = speed
self.course = course
}
}
private let control: VPhoneControl
private var hostModeStarted = false
private var locationManager: CLLocationManager?
private var delegateProxy: LocationDelegateProxy?
private var lastLocation: CLLocation?
private var replayTask: Task<Void, Never>?
private var replayName: String?
var isReplaying: Bool { replayTask != nil }
init(control: VPhoneControl) {
self.control = control
@@ -35,6 +67,7 @@ class VPhoneLocationProvider: NSObject {
/// Begin sending location to the guest. Safe to call on every (re)connect.
func startForwarding() {
stopReplay()
guard let mgr = locationManager else { return }
mgr.requestAlwaysAuthorization()
mgr.startUpdatingLocation()
@@ -47,7 +80,7 @@ class VPhoneLocationProvider: NSObject {
}
}
/// Stop forwarding and clear the simulated location in the guest.
/// Stop forwarding host location updates.
func stopForwarding() {
if hostModeStarted {
locationManager?.stopUpdatingLocation()
@@ -56,6 +89,93 @@ class VPhoneLocationProvider: NSObject {
}
}
/// Send a fixed simulated location to the guest.
func sendPreset(name: String, latitude: Double, longitude: Double, altitude: Double = 0) {
stopReplay()
sendSimulatedLocation(
latitude: latitude,
longitude: longitude,
altitude: altitude,
horizontalAccuracy: 5,
verticalAccuracy: 8,
speed: 0,
course: -1
)
print("[location] applied preset '\(name)' (\(latitude), \(longitude))")
}
/// Start replaying a list of simulated locations at a fixed interval.
func startReplay(
name: String,
points: [ReplayPoint],
intervalSeconds: Double = 1.5,
loop: Bool = true
) {
guard !points.isEmpty else {
print("[location] replay '\(name)' ignored: no points")
return
}
stopForwarding()
stopReplay()
replayName = name
let sleepNanos = UInt64((max(intervalSeconds, 0.1) * 1_000_000_000).rounded())
print(
"[location] starting replay '\(name)' (\(points.count) points, interval \(String(format: "%.1f", intervalSeconds))s, loop=\(loop))"
)
replayTask = Task { @MainActor [weak self] in
guard let self else { return }
defer {
self.replayTask = nil
self.replayName = nil
}
var index = 0
while !Task.isCancelled {
let point = points[index]
self.sendSimulatedLocation(
latitude: point.latitude,
longitude: point.longitude,
altitude: point.altitude,
horizontalAccuracy: point.horizontalAccuracy,
verticalAccuracy: point.verticalAccuracy,
speed: point.speed,
course: point.course
)
index += 1
if index >= points.count {
if loop {
index = 0
} else {
break
}
}
try? await Task.sleep(nanoseconds: sleepNanos)
}
if Task.isCancelled {
print("[location] replay cancelled: \(name)")
} else {
print("[location] replay finished: \(name)")
}
}
}
/// Stop an active replay task.
func stopReplay() {
guard let replayTask else { return }
replayTask.cancel()
self.replayTask = nil
if let replayName {
print("[location] stopped replay: \(replayName)")
}
replayName = nil
}
private func forward(_ location: CLLocation) {
lastLocation = location
guard control.isConnected else {
@@ -72,6 +192,40 @@ class VPhoneLocationProvider: NSObject {
course: location.course
)
}
private func sendSimulatedLocation(
latitude: Double,
longitude: Double,
altitude: Double,
horizontalAccuracy: Double,
verticalAccuracy: Double,
speed: Double,
course: Double
) {
let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
lastLocation = CLLocation(
coordinate: coordinate,
altitude: altitude,
horizontalAccuracy: horizontalAccuracy,
verticalAccuracy: verticalAccuracy,
timestamp: Date()
)
guard control.isConnected else {
print("[location] simulate: not connected, cached for later")
return
}
control.sendLocation(
latitude: latitude,
longitude: longitude,
altitude: altitude,
horizontalAccuracy: horizontalAccuracy,
verticalAccuracy: verticalAccuracy,
speed: speed,
course: course
)
}
}
// MARK: - CLLocationManagerDelegate Proxy

View File

@@ -12,6 +12,9 @@ class VPhoneMenuController {
var onFilesPressed: (() -> Void)?
var locationProvider: VPhoneLocationProvider?
var locationMenuItem: NSMenuItem?
var locationPresetMenuItem: NSMenuItem?
var locationReplayStartItem: NSMenuItem?
var locationReplayStopItem: NSMenuItem?
var screenRecorder: VPhoneScreenRecorder?
var recordingItem: NSMenuItem?
var signer: VPhoneSigner?

View File

@@ -1,16 +1,90 @@
import AppKit
private struct LocationPreset {
let title: String
let latitude: Double
let longitude: Double
let altitude: Double
}
private let locationPresets: [LocationPreset] = [
LocationPreset(
title: "Apple Park (Cupertino)",
latitude: 37.334606,
longitude: -122.009102,
altitude: 14
),
LocationPreset(
title: "SF Ferry Building",
latitude: 37.795490,
longitude: -122.393738,
altitude: 5
),
LocationPreset(
title: "Times Square (NYC)",
latitude: 40.758000,
longitude: -73.985500,
altitude: 12
),
LocationPreset(
title: "Shibuya Crossing (Tokyo)",
latitude: 35.659500,
longitude: 139.700500,
altitude: 38
),
]
private let locationReplayName = "Apple Park Loop"
private let locationReplayPoints: [VPhoneLocationProvider.ReplayPoint] = [
.init(latitude: 37.334606, longitude: -122.009102, altitude: 14, speed: 6.5, course: 240),
.init(latitude: 37.333660, longitude: -122.011700, altitude: 14, speed: 7.0, course: 255),
.init(latitude: 37.332500, longitude: -122.014200, altitude: 14, speed: 7.2, course: 300),
.init(latitude: 37.333300, longitude: -122.016000, altitude: 14, speed: 6.6, course: 20),
.init(latitude: 37.335100, longitude: -122.016300, altitude: 14, speed: 6.4, course: 55),
.init(latitude: 37.337000, longitude: -122.014100, altitude: 14, speed: 6.8, course: 95),
.init(latitude: 37.337600, longitude: -122.011200, altitude: 14, speed: 6.9, course: 130),
.init(latitude: 37.336500, longitude: -122.008900, altitude: 14, speed: 6.3, course: 175),
]
// MARK: - Location Menu
extension VPhoneMenuController {
func buildLocationMenu() -> NSMenuItem {
let item = NSMenuItem()
let menu = NSMenu(title: "Location")
let toggle = makeItem("Sync Host Location", action: #selector(toggleLocationSync))
toggle.state = .off
toggle.isEnabled = false
locationMenuItem = toggle
menu.addItem(toggle)
menu.addItem(NSMenuItem.separator())
let presets = NSMenuItem(title: "Preset Location", action: nil, keyEquivalent: "")
let presetsMenu = NSMenu(title: "Preset Location")
for (index, preset) in locationPresets.enumerated() {
let presetItem = makeItem(preset.title, action: #selector(setLocationPreset(_:)))
presetItem.tag = index
presetsMenu.addItem(presetItem)
}
presets.submenu = presetsMenu
presets.isEnabled = false
locationPresetMenuItem = presets
menu.addItem(presets)
menu.addItem(NSMenuItem.separator())
let replayStart = makeItem("Start Route Replay", action: #selector(startLocationReplay(_:)))
replayStart.isEnabled = false
locationReplayStartItem = replayStart
menu.addItem(replayStart)
let replayStop = makeItem("Stop Route Replay", action: #selector(stopLocationReplay(_:)))
replayStop.isEnabled = false
locationReplayStopItem = replayStop
menu.addItem(replayStop)
item.submenu = menu
return item
}
@@ -19,6 +93,8 @@ extension VPhoneMenuController {
/// Preserves the user's checkmark state across connect/disconnect cycles.
func updateLocationCapability(available: Bool) {
locationMenuItem?.isEnabled = available
locationPresetMenuItem?.isEnabled = available
refreshLocationReplayState(available: available)
}
@objc func toggleLocationSync() {
@@ -29,9 +105,59 @@ extension VPhoneMenuController {
item.state = .off
print("[location] sync toggled off by user")
} else {
locationProvider?.stopReplay()
locationProvider?.startForwarding()
item.state = .on
print("[location] sync toggled on by user")
}
refreshLocationReplayState(available: item.isEnabled)
}
@objc func setLocationPreset(_ sender: NSMenuItem) {
guard locationMenuItem?.isEnabled == true else { return }
guard sender.tag >= 0, sender.tag < locationPresets.count else { return }
let preset = locationPresets[sender.tag]
disableHostSyncForManualLocation()
locationProvider?.sendPreset(
name: preset.title,
latitude: preset.latitude,
longitude: preset.longitude,
altitude: preset.altitude
)
refreshLocationReplayState(available: true)
}
@objc func startLocationReplay(_: NSMenuItem) {
guard locationMenuItem?.isEnabled == true else { return }
disableHostSyncForManualLocation()
locationProvider?.startReplay(
name: locationReplayName,
points: locationReplayPoints,
intervalSeconds: 1.5,
loop: true
)
refreshLocationReplayState(available: true)
}
@objc func stopLocationReplay(_: NSMenuItem) {
locationProvider?.stopReplay()
refreshLocationReplayState(available: locationMenuItem?.isEnabled ?? false)
}
private func disableHostSyncForManualLocation() {
guard let hostSyncItem = locationMenuItem else { return }
if hostSyncItem.state == .on {
locationProvider?.stopForwarding()
hostSyncItem.state = .off
print("[location] host sync disabled for manual simulation")
}
}
private func refreshLocationReplayState(available: Bool) {
let replaying = locationProvider?.isReplaying ?? false
locationReplayStartItem?.isEnabled = available && !replaying
locationReplayStopItem?.isEnabled = available && replaying
locationReplayStartItem?.state = replaying ? .on : .off
locationReplayStopItem?.state = replaying ? .on : .off
}
}