diff --git a/App/ContentView.swift b/App/ContentView.swift index 268ea69..fb819b8 100644 --- a/App/ContentView.swift +++ b/App/ContentView.swift @@ -4,7 +4,6 @@ struct ContentView: View { @StateObject private var setup = SetupCoordinator() @ObservedObject private var net = NetworkMonitor.shared @State private var showSetup = false - @State private var showEnableTip = false @AppStorage("setupCompleted") private var setupCompleted = false var body: some View { @@ -12,7 +11,6 @@ struct ContentView: View { MapHomeView(setup: setup) } .task { - // 首次打开无标记:必须进引导页 if !setupCompleted { showSetup = true return @@ -21,10 +19,7 @@ struct ContentView: View { } .onChange(of: net.isAirplaneMode) { airplane in guard setupCompleted else { return } - if airplane { - showEnableTip = true - } else { - showEnableTip = false + if !airplane { Task { await setup.refreshTrust() } } } @@ -35,37 +30,5 @@ struct ContentView: View { showSetup = false }) } - // 设置页「进入引导页」入口联动 - .onChange(of: setup.needsSetup) { needs in - if needs { showSetup = true } - } - .sheet(isPresented: $showEnableTip) { - NavigationView { - VStack(spacing: 20) { - Image(systemName: "airplane") - .font(.system(size: 48)) - .foregroundStyle(.orange) - Text("飞行模式已开启") - .font(.title3.weight(.semibold)) - Text("Wi‑Fi 和蜂窝数据已关闭,虚拟定位无法生效。请关闭飞行模式后重试。") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - Button("知道了") { - showEnableTip = false - } - .buttonStyle(.borderedProminent) - .frame(maxWidth: .infinity) - } - .padding(30) - .navigationTitle("提示") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("完成") { showEnableTip = false } - } - } - } - } } } diff --git a/App/LocationActionCoordinator.swift b/App/LocationActionCoordinator.swift index 53996a5..4c762d1 100644 --- a/App/LocationActionCoordinator.swift +++ b/App/LocationActionCoordinator.swift @@ -56,9 +56,8 @@ final class LocationActionCoordinator: ObservableObject { } private func commit(_ favorite: FavoriteLocation) -> Bool { - // MKMapView 在中国地区使用高德瓦片(GCJ-02),返回的坐标是 GCJ-02。 - // 但 Apple wloc 定位服务使用 WGS-84,因此写入代理前需要转换为 WGS-84。 - let wgs = CoordinateConverter.gcj02ToWgs84(lat: favorite.latitude, lon: favorite.longitude) + // 统一转为 WGS-84 存储(地图取点可能为当前瓦片坐标系) + let wgs = CoordinateConverter.toStored(lat: favorite.latitude, lon: favorite.longitude) WlocSettingsStore.save(WlocSettings( longitude: wgs.lon, latitude: wgs.lat, @@ -71,6 +70,10 @@ final class LocationActionCoordinator: ObservableObject { enabled: true, accuracy: favorite.accuracy ) + RuntimeLogger.info("APP", "坐标转换", "写入代理: WGS-84", details: [ + "lat": String(wgs.lat), + "lon": String(wgs.lon) + ]) state = .idle virtualLocationEnabled = true message = "虚拟定位已开启" diff --git a/App/MapHomeView.swift b/App/MapHomeView.swift index 14b6902..d81f995 100644 --- a/App/MapHomeView.swift +++ b/App/MapHomeView.swift @@ -66,10 +66,11 @@ struct MapHomeView: View { self.setup = setup let savedCoord = LastCoordinateStore.load() let initialZoom = ViewportStore.loadOrDefault() - // 坐标:缓存 → 先给兜底深圳(启动后会由 initializeMap 按优先级覆盖) + // 持久化存的是 WGS-84,直接转当前瓦片坐标系显示 let initialCoord: CLLocationCoordinate2D if let coord = savedCoord?.coordinate { - initialCoord = coord + let display = CoordinateConverter.toDisplay(lat: coord.latitude, lon: coord.longitude) + initialCoord = CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon) } else { initialCoord = CLLocationCoordinate2D(latitude: 22.544577, longitude: 113.94114) } @@ -89,10 +90,12 @@ struct MapHomeView: View { }, onUserCenterChanged: { coordinate, distance in mapState.updateViewport(distanceMeters: distance) + CoordinateConverter.updateTileType(lat: coordinate.latitude, lon: coordinate.longitude) let previousRevision = mapState.selection.revision let revision = mapState.selectUserMapCenter(coordinate) guard revision != previousRevision else { return } - LastCoordinateStore.save(lat: coordinate.latitude, lon: coordinate.longitude) + let wgs = CoordinateConverter.toStored(lat: coordinate.latitude, lon: coordinate.longitude) + LastCoordinateStore.save(lat: wgs.lat, lon: wgs.lon) favorites.select(nil) scheduleGeocode(coordinate: coordinate, revision: revision) }, @@ -101,35 +104,19 @@ struct MapHomeView: View { }, onMapTap: { coordinate in favorites.select(nil) + CoordinateConverter.updateTileType(lat: coordinate.latitude, lon: coordinate.longitude) let revision = mapState.selectMapTap(coordinate) - LastCoordinateStore.save(lat: coordinate.latitude, lon: coordinate.longitude) + let wgs = CoordinateConverter.toStored(lat: coordinate.latitude, lon: coordinate.longitude) + LastCoordinateStore.save(lat: wgs.lat, lon: wgs.lon) scheduleGeocode(coordinate: coordinate, revision: revision) }, onUserZoomChanged: { distance in ViewportStore.save(distance) - } + }, + onZoomIn: { mapState.zoom(by: 0.5) }, + onZoomOut: { mapState.zoom(by: 2) } ) - .ignoresSafeArea() - .ignoresSafeArea(.keyboard) - .overlay { - Image(systemName: "mappin.and.ellipse") - .font(.system(size: 38, weight: .semibold)) - .symbolRenderingMode(.palette) - .foregroundStyle(.white, .red) - .shadow(color: .black.opacity(0.28), radius: 5, y: 3) - .offset(y: -19) - .allowsHitTesting(false) - .accessibilityHidden(true) - } - - HStack { - zoomControls - Spacer() - } - .padding(.leading, 16) - .padding(.top, 130) - .allowsHitTesting(true) - .allowsHitTesting(true) + .ignoresSafeArea(.container) VStack(spacing: 10) { topControls @@ -195,12 +182,21 @@ struct MapHomeView: View { )) { Button("知道了", role: .cancel) {} } message: { Text(manualHint) } - .onAppear(perform: initializeMap) + .onAppear { + initializeMap() + NetworkMonitor.shared.onWiFiChanged = { [self] in + guard spoofState == .active else { return } + Task { @MainActor in + let target = currentSelectionFavorite + let result = await setup.runVerificationTest(testLat: target.latitude, testLon: target.longitude) + if !result.isSuccess, let tip = result.tipKind { + activeTip = tip + } + } + } + } .onChange(of: net.isAirplaneMode) { airplane in - if airplane { - showEnableTip = true - } else { - showEnableTip = false + if !airplane { Task { await setup.refreshTrust() } } } @@ -362,14 +358,16 @@ struct MapHomeView: View { return } let snapshot = currentSelectionFavorite + let wgs = CoordinateConverter.toStored(lat: snapshot.latitude, lon: snapshot.longitude) let favorite = favorites.save( name: snapshot.name, - latitude: snapshot.latitude, - longitude: snapshot.longitude, + latitude: wgs.lat, + longitude: wgs.lon, accuracy: snapshot.accuracy ) + let display = CoordinateConverter.toDisplay(lat: favorite.latitude, lon: favorite.longitude) mapState.selectFavorite( - CLLocationCoordinate2D(latitude: favorite.latitude, longitude: favorite.longitude), + CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon), id: favorite.id, name: favorite.name ) @@ -391,15 +389,6 @@ struct MapHomeView: View { HStack(spacing: 8) { ForEach(favorites.favorites) { f in favoriteChip(f) } }.padding(.vertical, 2) } } - // 飞行模式 - if net.isAirplaneMode { - HStack { - Image(systemName: "airplane").foregroundStyle(.orange) - Text("飞行模式已开启").font(.caption).foregroundStyle(.orange) - Spacer() - Button("查看说明") { activeTip = .activation }.font(.caption).buttonStyle(.bordered).tint(.orange) - } - } // 主控按钮(带动画) HStack(spacing: 10) { Button(action: handleMainButtonTap) { @@ -504,12 +493,24 @@ struct MapHomeView: View { activeSpoofLat = target.latitude activeSpoofLon = target.longitude } + RuntimeLogger.info("APP", "定位", "验证结果", details: [ + "success": "true", + "applied": String(applied), + "spoofState": String(describing: spoofState) + ]) if applied && !activationTipDisabled { showEnableTip = true activationTipCount += 1 } } else { spoofState = actions.virtualLocationEnabled ? .active : .idle + RuntimeLogger.warning("APP", "定位", "验证失败", details: [ + "result": result.id, + "spoofState": String(describing: spoofState) + ]) + if let tip = result.tipKind { + activeTip = tip + } } locationOperationTask = nil } @@ -572,51 +573,14 @@ struct MapHomeView: View { private var testFavorite: FavoriteLocation { currentSelectionFavorite } - private var zoomControls: some View { - VStack(spacing: 0) { - Button { - mapState.zoom(by: 0.5) - } label: { - Image(systemName: "plus") - .font(.system(size: 22, weight: .bold)) - .frame(width: 52, height: 52) - } - .accessibilityLabel("放大地图") - - Divider().frame(width: 28) - - Text(MapZoomMath.viewportScaleLabel(distanceMeters: mapState.viewportMeters)) - .font(.system(size: 9, weight: .semibold, design: .rounded)) - .foregroundStyle(.secondary) - .lineLimit(1) - .minimumScaleFactor(0.7) - .frame(width: 54, height: 28) - .accessibilityLabel("当前地图范围") - .accessibilityValue(MapZoomMath.viewportScaleLabel(distanceMeters: mapState.viewportMeters)) - - Divider().frame(width: 28) - - Button { - mapState.zoom(by: 2) - } label: { - Image(systemName: "minus") - .font(.system(size: 22, weight: .bold)) - .frame(width: 52, height: 52) - } - .accessibilityLabel("缩小地图") - } - .buttonStyle(.plain) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 13, style: .continuous)) - .shadow(color: .black.opacity(0.18), radius: 7, y: 3) - } - private func initializeMap() { guard !mapDidInitialize else { return } mapDidInitialize = true if let selected = favorites.selectedFavorite { + let display = CoordinateConverter.toDisplay(lat: selected.latitude, lon: selected.longitude) mapState.selectFavorite( - CLLocationCoordinate2D(latitude: selected.latitude, longitude: selected.longitude), + CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon), id: selected.id, name: selected.name ) @@ -638,13 +602,13 @@ struct MapHomeView: View { private func requestRealtimeLocation() { let intent = mapState.beginRealtimeIntent() - if let nativeLocation = mapState.realtimeLocation, - abs(nativeLocation.timestamp.timeIntervalSinceNow) <= 30 { - acceptRealtimeLocation(nativeLocation.coordinate, intent: intent, source: "MapKit 实时位置") + // 蓝点存在就直接用,不限时(MKMapView 的 userLocation 只在位置变化时才更新) + if let loc = mapState.realtimeLocation { + acceptRealtimeLocation(loc.coordinate, intent: intent, source: "MapKit蓝点") return } startRealtimeLocationRequest( - source: "定位按钮兜底", + source: "CLLocationManager", showFailureAlert: true, intent: intent ) @@ -659,7 +623,7 @@ struct MapHomeView: View { // CLLocationManager fallback so the camera and dot cannot disagree. realtimeRequestContext = nil realtimeRequestTask?.cancel() - acceptRealtimeLocation(location.coordinate, intent: context.intent, source: "MapKit \(context.source)") + acceptRealtimeLocation(location.coordinate, intent: context.intent, source: "蓝点(途中)→\(context.source)") } private func startRealtimeLocationRequest( @@ -711,7 +675,9 @@ struct MapHomeView: View { guard accepted else { return } // 用点击时的缩放级别居中,不改变缩放 mapState.focusSelection(distanceMeters: currentViewport) - LastCoordinateStore.save(lat: coordinate.latitude, lon: coordinate.longitude) + CoordinateConverter.updateTileType(lat: coordinate.latitude, lon: coordinate.longitude) + let wgs = CoordinateConverter.toStored(lat: coordinate.latitude, lon: coordinate.longitude) + LastCoordinateStore.save(lat: wgs.lat, lon: wgs.lon) favorites.select(nil) scheduleGeocode(coordinate: coordinate, revision: mapState.selection.revision) } @@ -842,8 +808,10 @@ struct MapHomeView: View { geocodeDebounceTask?.cancel() reverseGeocodeTask?.cancel() favorites.select(nil) + CoordinateConverter.updateTileType(lat: result.coordinate.latitude, lon: result.coordinate.longitude) mapState.selectSearchResult(result.coordinate, name: result.name) - LastCoordinateStore.save(lat: result.coordinate.latitude, lon: result.coordinate.longitude) + let wgs = CoordinateConverter.toStored(lat: result.coordinate.latitude, lon: result.coordinate.longitude) + LastCoordinateStore.save(lat: wgs.lat, lon: wgs.lon) searchText = result.name searchResults = [] searchError = "" @@ -857,8 +825,9 @@ struct MapHomeView: View { geocodeDebounceTask?.cancel() reverseGeocodeTask?.cancel() favorites.select(favorite.id) + let display = CoordinateConverter.toDisplay(lat: favorite.latitude, lon: favorite.longitude) mapState.selectFavorite( - CLLocationCoordinate2D(latitude: favorite.latitude, longitude: favorite.longitude), + CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon), id: favorite.id, name: favorite.name ) diff --git a/App/MapViewRepresentable.swift b/App/MapViewRepresentable.swift index 261444e..97b0e36 100644 --- a/App/MapViewRepresentable.swift +++ b/App/MapViewRepresentable.swift @@ -38,6 +38,8 @@ struct MapViewRepresentable: UIViewRepresentable { let onViewportChanged: (CLLocationDistance) -> Void let onMapTap: (CLLocationCoordinate2D) -> Void let onUserZoomChanged: ((CLLocationDistance) -> Void)? + var onZoomIn: (() -> Void)? + var onZoomOut: (() -> Void)? func makeCoordinator() -> Coordinator { Coordinator(parent: self) } @@ -55,10 +57,83 @@ struct MapViewRepresentable: UIViewRepresentable { animated: false ) + // Center pin — positioned relative to geographic center, not Auto Layout + let pinSize: CGFloat = 38 + let sizeCfg = UIImage.SymbolConfiguration(pointSize: pinSize, weight: .semibold) + let paletteCfg = UIImage.SymbolConfiguration(paletteColors: [.white, .red]) + let pinImage = UIImage(systemName: "mappin", + withConfiguration: sizeCfg.applying(paletteCfg)) + let pin = UIImageView(image: pinImage) + pin.frame = CGRect(x: 0, y: 0, width: pinSize, height: pinSize) + pin.isUserInteractionEnabled = false + pin.layer.shadowColor = UIColor.black.cgColor + pin.layer.shadowOpacity = 0.28 + pin.layer.shadowRadius = 5 + pin.layer.shadowOffset = CGSize(width: 0, height: 3) + map.addSubview(pin) + context.coordinator.centerPin = pin + + // Zoom controls — UIKit subviews inside MKMapView, move with the map + let zoomStack = UIStackView() + zoomStack.axis = .vertical + zoomStack.spacing = 0 + zoomStack.translatesAutoresizingMaskIntoConstraints = false + zoomStack.alignment = .center + zoomStack.backgroundColor = UIColor.systemBackground.withAlphaComponent(0.8) + zoomStack.layer.cornerRadius = 13 + zoomStack.layer.shadowColor = UIColor.black.cgColor + zoomStack.layer.shadowOpacity = 0.18 + zoomStack.layer.shadowRadius = 7 + zoomStack.layer.shadowOffset = CGSize(width: 0, height: 3) + + let zoomInBtn = UIButton(type: .system) + zoomInBtn.setImage(UIImage(systemName: "plus", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .bold)), for: .normal) + zoomInBtn.addTarget(context.coordinator, action: #selector(Coordinator.zoomInTapped), for: .touchUpInside) + zoomInBtn.heightAnchor.constraint(equalToConstant: 52).isActive = true + zoomInBtn.widthAnchor.constraint(equalToConstant: 52).isActive = true + + let zoomLabel = UILabel() + let roundedDesc = UIFont.systemFont(ofSize: 9, weight: .semibold).fontDescriptor.withDesign(.rounded) + zoomLabel.font = roundedDesc.flatMap { UIFont(descriptor: $0, size: 9) } ?? .systemFont(ofSize: 9, weight: .semibold) + zoomLabel.textColor = .secondaryLabel + zoomLabel.textAlignment = .center + zoomLabel.adjustsFontSizeToFitWidth = true + zoomLabel.minimumScaleFactor = 0.7 + zoomLabel.text = MapZoomMath.viewportScaleLabel(distanceMeters: ViewportStore.loadOrDefault()) + zoomLabel.heightAnchor.constraint(equalToConstant: 28).isActive = true + context.coordinator.zoomLabel = zoomLabel + + let zoomOutBtn = UIButton(type: .system) + zoomOutBtn.setImage(UIImage(systemName: "minus", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .bold)), for: .normal) + zoomOutBtn.addTarget(context.coordinator, action: #selector(Coordinator.zoomOutTapped), for: .touchUpInside) + zoomOutBtn.heightAnchor.constraint(equalToConstant: 52).isActive = true + zoomOutBtn.widthAnchor.constraint(equalToConstant: 52).isActive = true + + let sep1 = UIView(); sep1.translatesAutoresizingMaskIntoConstraints = false + sep1.heightAnchor.constraint(equalToConstant: 0.5).isActive = true + sep1.backgroundColor = .separator + sep1.widthAnchor.constraint(equalToConstant: 28).isActive = true + let sep2 = UIView(); sep2.translatesAutoresizingMaskIntoConstraints = false + sep2.heightAnchor.constraint(equalToConstant: 0.5).isActive = true + sep2.backgroundColor = .separator + sep2.widthAnchor.constraint(equalToConstant: 28).isActive = true + + zoomStack.addArrangedSubview(zoomInBtn) + zoomStack.addArrangedSubview(sep1) + zoomStack.addArrangedSubview(zoomLabel) + zoomStack.addArrangedSubview(sep2) + zoomStack.addArrangedSubview(zoomOutBtn) + map.addSubview(zoomStack) + NSLayoutConstraint.activate([ + zoomStack.leadingAnchor.constraint(equalTo: map.safeAreaLayoutGuide.leadingAnchor, constant: 16), + zoomStack.topAnchor.constraint(equalTo: map.safeAreaLayoutGuide.topAnchor, constant: 130), + ]) + let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:))) tap.cancelsTouchesInView = false map.addGestureRecognizer(tap) context.coordinator.map = map + context.coordinator.setupKeyboardObservers() return map } @@ -76,11 +151,41 @@ struct MapViewRepresentable: UIViewRepresentable { private var activeCommandIsZoom = false private var regionChangeWasUserDriven = false private var isPinchZoom = false + weak var zoomLabel: UILabel? + weak var centerPin: UIImageView? + private let pinSize: CGFloat = 38 + // 蓝点实际大小从 MKUserLocationView 取,默认 20pt + private var userDotDiameter: CGFloat = 20 + + @objc func zoomInTapped() { parent.onZoomIn?() } + @objc func zoomOutTapped() { parent.onZoomOut?() } init(parent: MapViewRepresentable) { self.parent = parent } + private func updatePinPosition(on mapView: MKMapView) { + guard let pin = centerPin else { return } + let centerPt = mapView.convert(mapView.centerCoordinate, toPointTo: mapView) + // pin 尖在底边,上移自身一半 + 蓝点半径对齐 + pin.center = CGPoint( + x: centerPt.x, + y: centerPt.y - pinSize / 2 + 5 + ) + } + + func setupKeyboardObservers() { + let nc = NotificationCenter.default + nc.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { [weak self] _ in + guard let self, let map = self.map else { return } + self.updatePinPosition(on: map) + } + nc.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { [weak self] _ in + guard let self, let map = self.map else { return } + self.updatePinPosition(on: map) + } + } + func consume(_ command: MapCameraCommand?, on map: MKMapView) { guard let command, command.id != lastConsumedCommandID else { return } lastConsumedCommandID = command.id @@ -111,6 +216,16 @@ struct MapViewRepresentable: UIViewRepresentable { parent.onMapTap(map.convert(point, toCoordinateFrom: map)) } + func mapView(_ mapView: MKMapView, didAdd views: [MKAnnotationView]) { + for view in views where view.annotation is MKUserLocation { + // 取蓝点实际大小,隐藏精度圈 + userDotDiameter = view.bounds.width + for sub in view.subviews where sub.bounds.width > userDotDiameter + 4 { + sub.isHidden = true + } + } + } + func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) { guard let location = userLocation.location, CLLocationCoordinate2DIsValid(location.coordinate), @@ -143,6 +258,16 @@ struct MapViewRepresentable: UIViewRepresentable { func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { let distance = visibleVerticalDistance(in: mapView) parent.onViewportChanged(distance) + zoomLabel?.text = MapZoomMath.viewportScaleLabel(distanceMeters: distance) + updatePinPosition(on: mapView) + // 每次地图区域变化时更新瓦片坐标系类型 + let c = mapView.centerCoordinate + CoordinateConverter.updateTileType(lat: c.latitude, lon: c.longitude) + // 同步蓝点坐标(避免 delegate 更新不及时导致 mapState.realtimeLocation 为 nil) + if let ul = mapView.userLocation.location, + CLLocationCoordinate2DIsValid(ul.coordinate), ul.horizontalAccuracy >= 0 { + parent.onRealtimeLocationChanged(ul) + } let userZoomed: Bool if activeCameraCommandID != nil { diff --git a/App/ProxyManager.swift b/App/ProxyManager.swift index 48f4c7f..641c837 100644 --- a/App/ProxyManager.swift +++ b/App/ProxyManager.swift @@ -25,6 +25,9 @@ final class ProxyManager: ObservableObject { let lon = settings.flatMap { $0.enabled ? $0.longitude : nil } ?? 0 let enabled = (settings?.enabled ?? false) ? CInt(1) : CInt(0) let accuracy = CInt(settings?.accuracy ?? 25) + RuntimeLogger.info("APP", "坐标转换", "启动代理: WGS-84", details: [ + "lat": String(lat), "lon": String(lon) + ]) let result: UInt = authority.certPEM.withCString { cp in authority.keyPEM.withCString { kp in UInt(wloccore_startproxy(UnsafeMutablePointer(mutating: cp), UnsafeMutablePointer(mutating: kp), CDouble(lat), CDouble(lon), enabled, accuracy)) diff --git a/App/RealtimeLocationManager.swift b/App/RealtimeLocationManager.swift index f7b9899..3456ccd 100644 --- a/App/RealtimeLocationManager.swift +++ b/App/RealtimeLocationManager.swift @@ -61,7 +61,7 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag private let driver: RealtimeLocationDriving private let oneShotTimeoutNanoseconds: UInt64 private let fallbackTimeoutNanoseconds: UInt64 - private let cacheMaxAge: TimeInterval + private let cacheMaxAge: TimeInterval = 20 private var nextRequestID: UInt64 = 0 private var activeRequest: ActiveRequest? private var timeoutTask: Task? @@ -69,13 +69,11 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag init( driver: RealtimeLocationDriving, oneShotTimeoutNanoseconds: UInt64 = 1_500_000_000, - fallbackTimeoutNanoseconds: UInt64 = 5_000_000_000, - cacheMaxAge: TimeInterval = 20 + fallbackTimeoutNanoseconds: UInt64 = 5_000_000_000 ) { self.driver = driver self.oneShotTimeoutNanoseconds = oneShotTimeoutNanoseconds self.fallbackTimeoutNanoseconds = fallbackTimeoutNanoseconds - self.cacheMaxAge = cacheMaxAge authorizationStatus = driver.authorizationStatus super.init() driver.delegate = self @@ -105,11 +103,6 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag if let cached = freshestCachedLocation() { location = cached - RuntimeLogger.info("APP", "定位", "使用系统缓存实时定位", details: [ - "age": String(format: "%.2f", max(0, -cached.timestamp.timeIntervalSinceNow)), - "lat": String(cached.coordinate.latitude), - "lon": String(cached.coordinate.longitude) - ]) return cached.coordinate } diff --git a/App/SetupCoordinator.swift b/App/SetupCoordinator.swift index 059ced0..b67bc9f 100644 --- a/App/SetupCoordinator.swift +++ b/App/SetupCoordinator.swift @@ -54,15 +54,8 @@ final class SetupCoordinator: ObservableObject { message = "代理链路异常,未收到响应" return } - // 走同一套改写验证:Go Core 内模拟 Apple 响应并确认坐标被改写 - let patchResult = CoreBridge.testWlocPatch(lat: testLat, lon: testLon, accuracy: testAccuracy) - if patchResult.hasPrefix("ok:") { - trustState = .trusted - message = "✓ 定位环境正常(返回 \(status))" - } else { - trustState = .unavailable - message = "定位数据改写验证失败:\(patchResult)" - } + trustState = .trusted + message = "✓ 定位环境正常(返回 \(status))" } catch { trustState = .unavailable let ns = error as NSError @@ -107,7 +100,6 @@ final class SetupCoordinator: ObservableObject { log("======== 代理验证测试 ========") log("App 版本: \(appVersion)") log("系统版本: iOS \(UIDevice.current.systemVersion)") - log("测试目标: lat=\(testLat), lon=\(testLon)") log("") // Step A: Proxy running @@ -126,18 +118,6 @@ final class SetupCoordinator: ObservableObject { } collectProxyLogs(since: stepAStart, to: log) - // Verification must never overwrite a newer location action. - let previousCoordinates = proxy.coordinateSnapshot( - accuracy: WlocSettingsStore.load()?.accuracy ?? 25 - ) - var verificationCoordinateRevision: UInt64? - defer { - if let revision = verificationCoordinateRevision { - let restored = proxy.restoreCoords(previousCoordinates, ifUnchangedSince: revision) - log(restored ? " ↩ 已恢复验证前的代理坐标" : " ↩ 检测到更新位置,跳过旧坐标恢复") - } - } - // Step B: Combined CA + WiFi proxy check (single request) log("") log("[步骤 B] 检测证书与 WiFi 代理…") @@ -181,38 +161,6 @@ final class SetupCoordinator: ObservableObject { } collectProxyLogs(since: stepBStart, to: log) - // Step C: Write and verify coordinates - log("[步骤 C] 写入测试坐标并验证…") - guard let testCoordinateRevision = proxy.setCoordsIfUnchanged( - lat: testLat, - lon: testLon, - enabled: true, - accuracy: 25, - expectedRevision: previousCoordinates.revision - ) else { - log(" ↪ 检测到更新位置,取消过期验证坐标写入") - return .verificationSuperseded - } - verificationCoordinateRevision = testCoordinateRevision - let coords = proxy.getCoords() - if coords.enabled && abs(coords.lat - testLat) < 0.001 && abs(coords.lon - testLon) < 0.001 { - log(" ✓ 坐标写入成功: lat=\(coords.lat) lon=\(coords.lon)") - } else { - log(" ✗ 坐标验证失败: enabled=\(coords.enabled) lat=\(coords.lat) lon=\(coords.lon)") - return .coordinateWriteFailed("写入后回读不一致") - } - - // Step D: verify data rewriting via Go test patch - log("[步骤 D] 验证定位数据改写…") - let result = CoreBridge.testWlocPatch(lat: testLat, lon: testLon, accuracy: 25) - log(" \(result)") - if result.hasPrefix("ok:") { - log(" ✓ 定位数据改写验证通过") - } else { - log(" ✗ 定位数据改写失败") - return .patchFailed(result) - } - log("") log("======== 环境检测通过 ✓ ========") return .success diff --git a/README.md b/README.md index 70c8159..330a852 100644 --- a/README.md +++ b/README.md @@ -45,15 +45,23 @@ + + + + + + + + + - - - - + + +
应用主界面钉钉微信
iOS 虚拟定位应用主界面钉钉虚拟定位打卡微信虚拟定位
Apple 地图 高德地图 Apple Watch
iOS 虚拟定位应用主界面iPhone Location Spoofer Apple Maps 效果Fake GPS 高德地图定位效果Apple Watch 地区功能验证Apple Maps 定位效果高德地图定位效果Apple Watch 地区功能验证
diff --git a/Resources/PaopaoLocationSpoofer.entitlements b/Resources/PaopaoLocationSpoofer.entitlements index 62913c9..dbe601d 100644 --- a/Resources/PaopaoLocationSpoofer.entitlements +++ b/Resources/PaopaoLocationSpoofer.entitlements @@ -6,5 +6,7 @@ group.com.paopaolabs.location-spoofer + com.apple.developer.networking.wifi-info + \ No newline at end of file diff --git a/Scripts/impactor-entitlements-app.plist b/Scripts/impactor-entitlements-app.plist index 4bbdcc0..1342151 100644 --- a/Scripts/impactor-entitlements-app.plist +++ b/Scripts/impactor-entitlements-app.plist @@ -14,5 +14,7 @@ get-task-allow + com.apple.developer.networking.wifi-info + diff --git a/Shared/CoordinateConverter.swift b/Shared/CoordinateConverter.swift index 106f70d..56fa4b8 100644 --- a/Shared/CoordinateConverter.swift +++ b/Shared/CoordinateConverter.swift @@ -2,26 +2,105 @@ import Foundation /// GCJ-02 (火星坐标) ↔ WGS-84 坐标转换。 /// -/// 在中国地区,MKMapView 使用高德 (AutoNavi) 瓦片数据(GCJ-02 坐标系), -/// 因此从 MKMapView 的 `centerCoordinate`、`convert(point:toCoordinateFrom:)` -/// 等方法返回的坐标也是 GCJ-02。但 CoreLocation / CLLocationManager 返回的 -/// 以及 Apple wloc 定位服务使用的都是 WGS-84。 -/// -/// 虚拟定位的坐标流中: -/// - 地图 UI 层(显示、选点):GCJ-02(与瓦片一致) -/// - 代理写出层(wloc 响应改写):WGS-84 -/// -/// 因此需要在坐标从地图 UI 进入代理之前做 GCJ-02 → WGS-84 转换。 +/// MKMapView 根据实时定位动态切换瓦片源:中国境内用高德 GCJ-02,境外用 Apple WGS-84。 +/// App 内部统一以 WGS-84 存储,仅在地图交互时按当前瓦片类型双向转换。 enum CoordinateConverter { // 椭球参数 (Krasovsky 1940) private static let a = 6378245.0 private static let ee = 0.00669342162296594323 + /// 坐标类型 + enum CoordType: String { + case gcj02 = "GCJ-02" + case wgs84 = "WGS-84" + } + + // MARK: - 全局瓦片类型 + + /// 当前地图瓦片坐标系(每次从地图拿到坐标后更新) + @MainActor static var currentTileType = CoordType.gcj02 + + /// 从地图坐标推算当前瓦片类型并更新全局状态 + @MainActor + static func updateTileType(lat: Double, lon: Double) { + let type = detectType(lat: lat, lon: lon) + if type != currentTileType { + currentTileType = type + RuntimeLogger.info("APP", "坐标转换", "地图瓦片切换 → \(type.rawValue)", details: [ + "坐标": "\(lat), \(lon)" + ]) + } + } + + // MARK: - 存取转换 + + /// 地图坐标 → WGS-84 存储 + @MainActor + static func toStored(lat: Double, lon: Double) -> (lat: Double, lon: Double) { + updateTileType(lat: lat, lon: lon) + guard currentTileType == .gcj02 else { + RuntimeLogger.info("APP", "坐标转换", "toStored: WGS-84 → 不转", details: [ + "lat": String(lat), "lon": String(lon) + ]) + return (lat, lon) + } + let wgs = gcj02ToWgs84(lat: lat, lon: lon) + let d = distance(lat1: lat, lon1: lon, lat2: wgs.lat, lon2: wgs.lon) + RuntimeLogger.info("APP", "坐标转换", "toStored: GCJ-02 → WGS-84", details: [ + "原始": "\(lat), \(lon)", + "结果": "\(wgs.lat), \(wgs.lon)", + "偏移": String(format: "%.0fm", d) + ]) + return wgs + } + + /// WGS-84 存储 → 当前地图瓦片坐标系(显示用) + @MainActor + static func toDisplay(lat: Double, lon: Double) -> (lat: Double, lon: Double) { + guard currentTileType == .gcj02 else { + RuntimeLogger.info("APP", "坐标转换", "toDisplay: WGS-84 → 不转", details: [ + "lat": String(lat), "lon": String(lon) + ]) + return (lat, lon) + } + let gcj = wgs84ToGcj02(lat: lat, lon: lon) + let d = distance(lat1: lat, lon1: lon, lat2: gcj.lat, lon2: gcj.lon) + RuntimeLogger.info("APP", "坐标转换", "toDisplay: WGS-84 → GCJ-02", details: [ + "原始": "\(lat), \(lon)", + "结果": "\(gcj.lat), \(gcj.lon)", + "偏移": String(format: "%.0fm", d) + ]) + return gcj + } + + // MARK: - 类型检测 + + /// Haversine 距离(米) + static func distance(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double { + let r = 6371000.0 + let dLat = (lat2 - lat1) * .pi / 180.0 + let dLon = (lon2 - lon1) * .pi / 180.0 + let a = sin(dLat / 2) * sin(dLat / 2) + + cos(lat1 * .pi / 180.0) * cos(lat2 * .pi / 180.0) + * sin(dLon / 2) * sin(dLon / 2) + return r * 2 * atan2(sqrt(a), sqrt(1 - a)) + } + + /// MKMapView 瓦片坐标系:中国境内 GCJ-02,境外 WGS-84 + static func detectType(lat: Double, lon: Double) -> CoordType { + // GCJ-02 加密只在中国境内生效,用地理边界判断 + if lat > 17.5 && lat < 54.0 && lon > 72.5 && lon < 136.0 { + return .gcj02 + } + return .wgs84 + } + + // MARK: - 核心转换 + /// GCJ-02 → WGS-84(迭代法,精度优于 0.5 米) static func gcj02ToWgs84(lat: Double, lon: Double) -> (lat: Double, lon: Double) { var wgsLat = lat var wgsLon = lon - // 两次迭代足以收敛到亚米级精度 for _ in 0..<2 { let d = delta(lat: wgsLat, lon: wgsLon) wgsLat = lat - d.lat @@ -30,6 +109,14 @@ enum CoordinateConverter { return (wgsLat, wgsLon) } + /// WGS-84 → GCJ-02 + static func wgs84ToGcj02(lat: Double, lon: Double) -> (lat: Double, lon: Double) { + let d = delta(lat: lat, lon: lon) + return (lat + d.lat, lon + d.lon) + } + + // MARK: - 内部 + /// 计算偏移量 (WGS-84 → GCJ-02 的增量) private static func delta(lat: Double, lon: Double) -> (lat: Double, lon: Double) { let dLat = transformLat(x: lon - 105.0, y: lat - 35.0) diff --git a/Shared/NetworkMonitor.swift b/Shared/NetworkMonitor.swift index c1e7aab..0273121 100644 --- a/Shared/NetworkMonitor.swift +++ b/Shared/NetworkMonitor.swift @@ -1,5 +1,6 @@ import Network import Foundation +import SystemConfiguration.CaptiveNetwork @MainActor final class NetworkMonitor: ObservableObject { @@ -7,20 +8,58 @@ final class NetworkMonitor: ObservableObject { @Published private(set) var isSatisfied = true @Published private(set) var isWiFiEnabled = true + @Published private(set) var currentSSID: String? + + /// WiFi 重连或 SSID 变化时触发(仅虚拟定位激活时使用) + var onWiFiChanged: (() -> Void)? private let monitor = NWPathMonitor() + private var ssidTimer: Timer? + private var wasSatisfied = true private init() { monitor.pathUpdateHandler = { [weak self] path in let satisfied = path.status == .satisfied let wifi = path.usesInterfaceType(.wifi) Task { @MainActor in - self?.isSatisfied = satisfied - self?.isWiFiEnabled = wifi + guard let self else { return } + // 网络恢复连接 → 触发检测 + let reconnected = satisfied && !self.wasSatisfied && wifi + self.wasSatisfied = satisfied + self.isSatisfied = satisfied + self.isWiFiEnabled = wifi + if reconnected { + self.onWiFiChanged?() + } } } monitor.start(queue: .main) + startSSIDPolling() } var isAirplaneMode: Bool { !isSatisfied } + + private func startSSIDPolling() { + ssidTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self else { return } + let ssid = Self.fetchSSID() + if ssid != self.currentSSID, ssid != nil { + self.currentSSID = ssid + self.onWiFiChanged?() + } + } + } + } + + static func fetchSSID() -> String? { + guard let interfaces = CNCopySupportedInterfaces() as? [String] else { return nil } + for iface in interfaces { + if let info = CNCopyCurrentNetworkInfo(iface as CFString) as? [String: Any], + let ssid = info[kCNNetworkInfoKeySSID as String] as? String { + return ssid + } + } + return nil + } } diff --git a/images/Apple Map.jpg b/images/Apple Map.jpg index a829688..4f425ec 100644 Binary files a/images/Apple Map.jpg and b/images/Apple Map.jpg differ diff --git a/images/主界面.jpg b/images/主界面.jpg index 56a327a..ec29b26 100644 Binary files a/images/主界面.jpg and b/images/主界面.jpg differ diff --git a/images/微信.jpg b/images/微信.jpg new file mode 100644 index 0000000..1678cfe Binary files /dev/null and b/images/微信.jpg differ diff --git a/images/钉钉.jpg b/images/钉钉.jpg new file mode 100644 index 0000000..e945e79 Binary files /dev/null and b/images/钉钉.jpg differ diff --git a/images/高德地图.jpg b/images/高德地图.jpg index 2c8c7b9..b4c6078 100644 Binary files a/images/高德地图.jpg and b/images/高德地图.jpg differ