mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-04-05 04:59:05 +08:00
Merge pull request #106 from TastyHeadphones/codex/location-presets-route-replay
Add location presets and route replay controls
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user