diff --git a/StikJIT/StikJIT-Bridging-Header.h b/StikJIT/StikJIT-Bridging-Header.h index df9c501..1f665e2 100644 --- a/StikJIT/StikJIT-Bridging-Header.h +++ b/StikJIT/StikJIT-Bridging-Header.h @@ -7,3 +7,4 @@ #include "idevice/heartbeat.h" #include "JSSupport/JSSupport.h" #include "idevice/ideviceinfo.h" +#include "idevice/ls.h" diff --git a/StikJIT/Views/MainTabView.swift b/StikJIT/Views/MainTabView.swift index ecdeb0d..1903c54 100644 --- a/StikJIT/Views/MainTabView.swift +++ b/StikJIT/Views/MainTabView.swift @@ -38,6 +38,12 @@ struct MainTabView: View { .tabItem { Label("Info", systemImage: "info.circle.fill") } + #if DEBUG + LocationSimulatorView(deviceIp: "10.7.0.2") + .tabItem { + Label("Location", systemImage: "globe.americas") + } + #endif SettingsView() .tabItem { Label("Settings", systemImage: "gearshape.fill") diff --git a/StikJIT/Views/MapSelectionView.swift b/StikJIT/Views/MapSelectionView.swift new file mode 100644 index 0000000..c9959f1 --- /dev/null +++ b/StikJIT/Views/MapSelectionView.swift @@ -0,0 +1,242 @@ +// +// MapSelectionView.swift +// StikDebug +// +// Created by Stephen on 8/3/25. +// + +import SwiftUI +import MapKit + +struct MapSelectionView: UIViewRepresentable { + @Binding var coordinate: CLLocationCoordinate2D? + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, MKMapViewDelegate { + var parent: MapSelectionView + + init(_ parent: MapSelectionView) { + self.parent = parent + } + + @objc func handleLongPress(_ gesture: UILongPressGestureRecognizer) { + guard gesture.state == .began, + let map = gesture.view as? MKMapView + else { return } + + let point = gesture.location(in: map) + parent.coordinate = map.convert(point, toCoordinateFrom: map) + } + } + + func makeUIView(context: Context) -> MKMapView { + let mapView = MKMapView(frame: .zero) + mapView.delegate = context.coordinator + + let longPress = UILongPressGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleLongPress(_:)) + ) + mapView.addGestureRecognizer(longPress) + + return mapView + } + + func updateUIView(_ uiView: MKMapView, context: Context) { + uiView.removeAnnotations(uiView.annotations) + + if let coord = coordinate { + let annotation = MKPointAnnotation() + annotation.coordinate = coord + uiView.addAnnotation(annotation) + + let region = MKCoordinateRegion( + center: coord, + latitudinalMeters: 500, + longitudinalMeters: 500 + ) + uiView.setRegion(region, animated: true) + } + } +} + +struct LocationSimulatorView: View { + @State private var coordinate: CLLocationCoordinate2D? + @State private var status = "" + @State private var showKeepOpenError = false + @State private var searchQuery = "" + @State private var searchResults: [MKMapItem] = [] + + let deviceIp: String + + private var pairingFilePath: String { + FileManager.default + .urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent("pairingFile.plist") + .path + } + + var body: some View { + VStack(spacing: 16) { + Text("Location Simulator") + .font(.largeTitle.weight(.bold)) + .padding(.top) + + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + TextField("Search for a place", text: $searchQuery, onCommit: performSearch) + .font(.body) + .autocapitalization(.none) + .disableAutocorrection(true) + } + .padding(12) + .background(Color(UIColor.secondarySystemBackground)) + .cornerRadius(12) + .shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1) + .padding(.horizontal) + + if !searchResults.isEmpty { + ScrollView { + VStack(spacing: 0) { + ForEach(searchResults, id: \.self) { item in + Button(action: { select(item: item) }) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(item.name ?? "Unknown") + .font(.body) + Text(item.placemark.title ?? "") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + } + .padding(.vertical, 8) + .padding(.horizontal) + } + Divider() + } + } + .background(RoundedRectangle(cornerRadius: 12) + .fill(Color(UIColor.systemBackground))) + .shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1) + .padding(.horizontal) + } + .frame(maxHeight: 200) + } + + MapSelectionView(coordinate: $coordinate) + .frame(height: 360) + .cornerRadius(12) + .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2) + .padding(.horizontal) + + if let coord = coordinate { + VStack(alignment: .leading, spacing: 4) { + Text("Selected Coordinates") + .font(.headline) + Text(String(format: "%.6f, %.6f", coord.latitude, coord.longitude)) + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding() + .frame(maxWidth: .infinity) + .background(RoundedRectangle(cornerRadius: 12) + .fill(Color(UIColor.secondarySystemBackground))) + .shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1) + .padding(.horizontal) + } else { + Text("Long-press on the map or search above to pick a location.") + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.horizontal) + } + + HStack(spacing: 12) { + Button(action: simulate) { + Label("Simulate", systemImage: "location.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(coordinate == nil) + + Button(action: clear) { + Label("Clear", systemImage: "xmark.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(coordinate == nil) + } + .padding(.horizontal) + + if !status.isEmpty { + Text(status) + .font(.callout) + .foregroundColor(status.contains("failed") ? .red : .green) + .padding(.horizontal) + .transition(.opacity) + } + + Spacer() + } + .padding(.bottom) + .overlay(overlayErrorView) + } + + private func performSearch() { + guard !searchQuery.isEmpty else { return } + let request = MKLocalSearch.Request() + request.naturalLanguageQuery = searchQuery + MKLocalSearch(request: request).start { response, _ in + searchResults = response?.mapItems ?? [] + } + } + + private func select(item: MKMapItem) { + coordinate = item.placemark.coordinate + status = "" + searchResults = [] + searchQuery = item.name ?? "" + } + + private func simulate() { + guard let coord = coordinate else { return } + let code = simulate_location( + deviceIp, + coord.latitude, + coord.longitude, + pairingFilePath + ) + if code == 0 { + status = "Simulation running…" + showKeepOpenError = true + } else { + status = "Simulation failed (code \(code))." + } + } + + private func clear() { + let code = clear_simulated_location() + status = code == 0 ? "Cleared simulation." : "Clear failed (code \(code))." + showKeepOpenError = false + } + + @ViewBuilder + private var overlayErrorView: some View { + if showKeepOpenError { + CustomErrorView( + title: "Keep App Open", + message: "Your simulation will stop if the app goes to the background. Please keep the app open to continue.", + onDismiss: { showKeepOpenError = false }, + primaryButtonText: "OK", + showSecondaryButton: false, + messageType: .info + ) + .transition(.opacity.combined(with: .scale)) + .zIndex(1) + } + } +} diff --git a/StikJIT/idevice/ls.c b/StikJIT/idevice/ls.c new file mode 100644 index 0000000..70c67ba --- /dev/null +++ b/StikJIT/idevice/ls.c @@ -0,0 +1,143 @@ +// +// ls.c +// StikDebug +// +// Created by Stephen on 8/3/25. +// + +#include "ls.h" +#include "idevice.h" +#include +#include +#include +#include +#include + +static IdevicePairingFile *g_pairing = NULL; +static IdeviceProviderHandle *g_provider = NULL; +static CoreDeviceProxyHandle *g_core_device = NULL; +static AdapterHandle *g_adapter = NULL; +static RsdHandshakeHandle *g_handshake = NULL; +static RemoteServerHandle *g_remote_server = NULL; +static LocationSimulationHandle *g_location_sim = NULL; + +static void cleanup_on_error(void) { + if (g_location_sim) { location_simulation_free(g_location_sim); g_location_sim = NULL; } + if (g_remote_server) { remote_server_free(g_remote_server); g_remote_server = NULL; } + if (g_handshake) { rsd_handshake_free(g_handshake); g_handshake = NULL; } + if (g_adapter) { adapter_free(g_adapter); g_adapter = NULL; } + if (g_core_device) { core_device_proxy_free(g_core_device); g_core_device = NULL; } + if (g_provider) { idevice_provider_free(g_provider); g_provider = NULL; } + if (g_pairing) { idevice_pairing_file_free(g_pairing); g_pairing = NULL; } +} + +int simulate_location(const char *device_ip, + double latitude, + double longitude, + const char *pairing_file) +{ + idevice_init_logger(Debug, Disabled, NULL); + IdeviceFfiError *err = NULL; + + struct sockaddr_in addr = { .sin_family = AF_INET, + .sin_port = htons(LOCKDOWN_PORT) }; + if (inet_pton(AF_INET, device_ip, &addr.sin_addr) != 1) { + return IPA_ERR_INVALID_IP; + } + + if ((err = idevice_pairing_file_read(pairing_file, &g_pairing))) { + idevice_error_free(err); + return IPA_ERR_PAIRING_READ; + } + + if ((err = idevice_tcp_provider_new((struct sockaddr *)&addr, + g_pairing, + "LocationSimCLI", + &g_provider))) + { + idevice_error_free(err); + cleanup_on_error(); + return IPA_ERR_PROVIDER_CREATE; + } + + if ((err = core_device_proxy_connect(g_provider, &g_core_device))) { + idevice_error_free(err); + cleanup_on_error(); + return IPA_ERR_CORE_DEVICE; + } + idevice_provider_free(g_provider); + g_provider = NULL; + + uint16_t rsd_port; + if ((err = core_device_proxy_get_server_rsd_port(g_core_device, + &rsd_port))) + { + idevice_error_free(err); + cleanup_on_error(); + return IPA_ERR_RSD_PORT; + } + + if ((err = core_device_proxy_create_tcp_adapter(g_core_device, + &g_adapter))) + { + idevice_error_free(err); + cleanup_on_error(); + return IPA_ERR_ADAPTER_CREATE; + } + + AdapterStreamHandle *stream = NULL; + if ((err = adapter_connect(g_adapter, rsd_port, &stream))) { + idevice_error_free(err); + cleanup_on_error(); + return IPA_ERR_STREAM; + } + + if ((err = rsd_handshake_new(stream, &g_handshake))) { + idevice_error_free(err); + adapter_close(stream); + cleanup_on_error(); + return IPA_ERR_HANDSHAKE; + } + + if ((err = remote_server_connect_rsd(g_adapter, + g_handshake, + &g_remote_server))) + { + idevice_error_free(err); + cleanup_on_error(); + return IPA_ERR_REMOTE_SERVER; + } + + if ((err = location_simulation_new(g_remote_server, + &g_location_sim))) { + idevice_error_free(err); + cleanup_on_error(); + return IPA_ERR_LOCATION_SIM; + } + + if ((err = location_simulation_set(g_location_sim, + latitude, + longitude))) { + idevice_error_free(err); + cleanup_on_error(); + return IPA_ERR_LOCATION_SET; + } + + return IPA_OK; +} + +int clear_simulated_location(void) +{ + IdeviceFfiError *err = NULL; + if (!g_location_sim) return IPA_ERR_LOCATION_CLEAR; + + err = location_simulation_clear(g_location_sim); + location_simulation_free(g_location_sim); + g_location_sim = NULL; + + if (g_remote_server) { remote_server_free(g_remote_server); g_remote_server = NULL; } + if (g_handshake) { rsd_handshake_free(g_handshake); g_handshake = NULL; } + if (g_adapter) { adapter_free(g_adapter); g_adapter = NULL; } + + return err ? IPA_ERR_LOCATION_CLEAR : IPA_OK; +} diff --git a/StikJIT/idevice/ls.h b/StikJIT/idevice/ls.h new file mode 100644 index 0000000..6e771d9 --- /dev/null +++ b/StikJIT/idevice/ls.h @@ -0,0 +1,55 @@ +// +// ls.h +// StikDebug +// +// Created by Stephen on 8/3/25. +// + +#ifndef LS_H +#define LS_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Success / error codes +#define IPA_OK 0 +#define IPA_ERR_INVALID_IP 1 +#define IPA_ERR_PAIRING_READ 2 +#define IPA_ERR_PROVIDER_CREATE 3 +#define IPA_ERR_CORE_DEVICE 4 +#define IPA_ERR_RSD_PORT 5 +#define IPA_ERR_ADAPTER_CREATE 6 +#define IPA_ERR_STREAM 7 +#define IPA_ERR_HANDSHAKE 8 +#define IPA_ERR_REMOTE_SERVER 9 +#define IPA_ERR_LOCATION_SIM 10 +#define IPA_ERR_LOCATION_SET 11 +#define IPA_ERR_LOCATION_CLEAR 12 + +/** + * Simulate the device’s location. + * @param device_ip IP address of the device. + * @param latitude Latitude to simulate. + * @param longitude Longitude to simulate. + * @param pairing_file Path to the pairing file. + * @return IPA_OK (0) on success, or one of the IPA_ERR_* codes. + */ +int simulate_location(const char *device_ip, + double latitude, + double longitude, + const char *pairing_file); + +/** + * Clear any simulated location. + * @return IPA_OK (0) on success, or IPA_ERR_LOCATION_CLEAR. + */ +int clear_simulated_location(void); + +#ifdef __cplusplus +} +#endif + +#endif /* LS_H */