diff --git a/App/ContentView.swift b/App/ContentView.swift index fb819b8..a1aafb9 100644 --- a/App/ContentView.swift +++ b/App/ContentView.swift @@ -4,31 +4,46 @@ struct ContentView: View { @StateObject private var setup = SetupCoordinator() @ObservedObject private var net = NetworkMonitor.shared @State private var showSetup = false + @State private var phase: AppPhase = .splash @AppStorage("setupCompleted") private var setupCompleted = false + enum AppPhase { case splash, map } + var body: some View { - NavigationView { - MapHomeView(setup: setup) + Group { + switch phase { + case .splash: + VStack(spacing: 16) { + Image(systemName: "location.fill") + .font(.system(size: 48)).foregroundStyle(.blue) + ProgressView() + Text("正在启动…").font(.subheadline).foregroundStyle(.secondary) + } + case .map: + NavigationView { + MapHomeView(setup: setup) + } + .fullScreenCover(isPresented: $showSetup) { + FirstSetupView(setup: setup, onComplete: { + setupCompleted = true + setup.completeSetup() + showSetup = false + }) + } + } } .task { - if !setupCompleted { - showSetup = true - return + if !setup.proxy.isRunning { + do { + try await setup.proxy.start() + } catch { + RuntimeLogger.error("APP", "Startup", "代理启动失败,将在设置检测中重试", error: error) + } } - await setup.refreshTrust() - } - .onChange(of: net.isAirplaneMode) { airplane in - guard setupCompleted else { return } - if !airplane { - Task { await setup.refreshTrust() } - } - } - .fullScreenCover(isPresented: $showSetup) { - FirstSetupView(setup: setup, onComplete: { - setupCompleted = true - setup.completeSetup() - showSetup = false - }) + phase = .map + if !setupCompleted { showSetup = true } + // Tile probing is a best-effort display refinement and must never block first render. + await CoordinateConverter.detectTileByFixedGeocode(force: true) } } } diff --git a/App/DiagnosticsView.swift b/App/DiagnosticsView.swift index 2eaac98..990eca8 100644 --- a/App/DiagnosticsView.swift +++ b/App/DiagnosticsView.swift @@ -15,20 +15,37 @@ struct RuntimeLogsView: View { @State private var copiedEntryID: UUID? @State private var copyLogsConfirmed = false @State private var testLogCopied = false + @State private var logFilter = "" + + private var filteredEntries: [RuntimeLogEntry] { + let q = logFilter.trimmingCharacters(in: .whitespacesAndNewlines) + guard !q.isEmpty else { return entries } + return entries.filter { $0.message.localizedCaseInsensitiveContains(q) } + } var body: some View { VStack(spacing: 0) { testPanel + HStack(spacing: 8) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("过滤日志", text: $logFilter) + .textFieldStyle(.plain).font(.caption) + if !logFilter.isEmpty { + Button { logFilter = "" } label: { + Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary).font(.caption) + } + } + }.padding(.horizontal, 12).padding(.vertical, 6) Divider() - if entries.isEmpty { + if filteredEntries.isEmpty { VStack(spacing: 10) { Image(systemName: "doc.text.magnifyingglass").font(.largeTitle).foregroundStyle(.secondary) - Text("暂无运行日志").foregroundStyle(.secondary) + Text(entries.isEmpty ? "暂无运行日志" : "无匹配日志").foregroundStyle(.secondary) }.frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { LazyVStack(alignment: .leading, spacing: 10) { - ForEach(entries.reversed()) { entry in logRow(entry) } + ForEach(filteredEntries.reversed()) { entry in logRow(entry) } }.padding(12) } } diff --git a/App/LocationActionCoordinator.swift b/App/LocationActionCoordinator.swift index 4c762d1..fc44828 100644 --- a/App/LocationActionCoordinator.swift +++ b/App/LocationActionCoordinator.swift @@ -70,9 +70,8 @@ final class LocationActionCoordinator: ObservableObject { enabled: true, accuracy: favorite.accuracy ) - RuntimeLogger.info("APP", "坐标转换", "写入代理: WGS-84", details: [ - "lat": String(wgs.lat), - "lon": String(wgs.lon) + RuntimeLogger.info("APP", "坐标转换", "已向代理写入 WGS-84 坐标", details: [ + "accuracy": String(favorite.accuracy) ]) state = .idle virtualLocationEnabled = true diff --git a/App/MapHomeView.swift b/App/MapHomeView.swift index 5c7695a..cc105e1 100644 --- a/App/MapHomeView.swift +++ b/App/MapHomeView.swift @@ -45,7 +45,6 @@ struct MapHomeView: View { @State private var showDisableTip = false @State private var activeTip: TipKind? @State private var manualHint = "" - @AppStorage("activationTipCount") private var activationTipCount = 0 @AppStorage("activationTipDisabled") private var activationTipDisabled = false @State private var editingFavorite: FavoriteLocation? @State private var editName = "" @@ -54,6 +53,11 @@ struct MapHomeView: View { @State private var showLocationAlert = false @State private var realtimeRequestTask: Task? @State private var realtimeRequestContext: RealtimeLocationRequestContext? + @State private var wifiChangeObserverToken: UUID? + @State private var wifiVerificationTask: Task? + @State private var wifiVerificationID: UUID? + @State private var tileProbeTask: Task? + @State private var tileProbeID: UUID? @State private var copyConfirmed = false @State private var spoofState: SpoofState = .idle @State private var locationOperationTask: Task? @@ -66,14 +70,17 @@ struct MapHomeView: View { self.setup = setup let savedCoord = LastCoordinateStore.load() let initialZoom = ViewportStore.loadOrDefault() - // 持久化存的是 WGS-84,直接转当前瓦片坐标系显示 let initialCoord: CLLocationCoordinate2D - if let coord = savedCoord?.coordinate { - let display = CoordinateConverter.toDisplay(lat: coord.latitude, lon: coord.longitude) + if let saved = savedCoord { + let display = CoordinateConverter.toDisplay(lat: saved.coordinate.latitude, lon: saved.coordinate.longitude) initialCoord = CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon) } else { initialCoord = CLLocationCoordinate2D(latitude: 22.544577, longitude: 113.94114) } + RuntimeLogger.info("APP", "地图", "初始化", details: [ + "zoom": String(initialZoom), + "有缓存": String(savedCoord != nil) + ]) _mapState = StateObject(wrappedValue: MapLocationState( initialCoordinate: initialCoord, initialViewportMeters: initialZoom @@ -90,7 +97,7 @@ struct MapHomeView: View { }, onUserCenterChanged: { coordinate, distance in mapState.updateViewport(distanceMeters: distance) -let previousRevision = mapState.selection.revision + let previousRevision = mapState.selection.revision let revision = mapState.selectUserMapCenter(coordinate) guard revision != previousRevision else { return } let wgs = CoordinateConverter.toStored(lat: coordinate.latitude, lon: coordinate.longitude) @@ -103,7 +110,7 @@ let previousRevision = mapState.selection.revision }, onMapTap: { coordinate in favorites.select(nil) -let revision = mapState.selectMapTap(coordinate) + let revision = mapState.selectMapTap(coordinate) let wgs = CoordinateConverter.toStored(lat: coordinate.latitude, lon: coordinate.longitude) LastCoordinateStore.save(lat: wgs.lat, lon: wgs.lon) scheduleGeocode(coordinate: coordinate, revision: revision) @@ -114,7 +121,7 @@ let revision = mapState.selectMapTap(coordinate) onZoomIn: { mapState.zoom(by: 0.5) }, onZoomOut: { mapState.zoom(by: 2) } ) - .ignoresSafeArea(.container) + .ignoresSafeArea(.container) VStack(spacing: 10) { topControls @@ -182,18 +189,21 @@ let revision = mapState.selectMapTap(coordinate) } message: { Text(manualHint) } .onAppear { initializeMap() - NetworkMonitor.shared.onWiFiChanged = { [self] in - guard spoofState == .active else { return } - Task { @MainActor in - try? await Task.sleep(nanoseconds: 3_000_000_000) - guard spoofState == .active else { return } - let target = currentSelectionFavorite - let result = await setup.runVerificationTest(testLat: target.latitude, testLon: target.longitude) - if !result.isSuccess, let tip = result.tipKind { - activeTip = tip - } - } + Task { await setup.refreshTrust() } + startTileProbe() + registerWiFiChangeObserver() + } + .onDisappear { + if let token = wifiChangeObserverToken { + net.removeWiFiChangeObserver(token) + wifiChangeObserverToken = nil } + wifiVerificationTask?.cancel() + wifiVerificationTask = nil + wifiVerificationID = nil + tileProbeTask?.cancel() + tileProbeTask = nil + tileProbeID = nil } .onChange(of: net.isAirplaneMode) { airplane in if !airplane { @@ -318,7 +328,7 @@ let revision = mapState.selectMapTap(coordinate) .onTapGesture { let text = String(format: "%.6f, %.6f", mapState.selection.coordinate.latitude, mapState.selection.coordinate.longitude) UIPasteboard.general.string = text - RuntimeLogger.info("APP", "地图", "复制坐标: \(text)") + RuntimeLogger.info("APP", "地图", "已复制坐标") copyConfirmed = true DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copyConfirmed = false } } @@ -500,7 +510,6 @@ let revision = mapState.selectMapTap(coordinate) ]) if applied && !activationTipDisabled { showEnableTip = true - activationTipCount += 1 } } else { spoofState = actions.virtualLocationEnabled ? .active : .idle @@ -576,7 +585,6 @@ let revision = mapState.selectMapTap(coordinate) private func initializeMap() { guard !mapDidInitialize else { return } mapDidInitialize = true - CoordinateConverter.detectTileByFixedGeocode() if let selected = favorites.selectedFavorite { let display = CoordinateConverter.toDisplay(lat: selected.latitude, lon: selected.longitude) @@ -601,8 +609,84 @@ let revision = mapState.selectMapTap(coordinate) } } + private func startTileProbe(force: Bool = false) { + guard tileProbeTask == nil else { return } + let probeID = UUID() + tileProbeID = probeID + tileProbeTask = Task { @MainActor in + defer { + if tileProbeID == probeID { + tileProbeTask = nil + tileProbeID = nil + } + } + guard let change = await CoordinateConverter.detectTileByFixedGeocode(force: force), + !Task.isCancelled else { return } + reprojectMapSelection(for: change) + } + } + + private func reprojectMapSelection(for change: CoordinateConverter.TileTypeChange) { + // CLLocationManager samples are WGS-84 already. Other map interactions + // are display coordinates and must be converted through WGS-84 once. + guard mapState.selection.source != .realtime else { return } + let coordinate = CoordinateConverter.reprojectDisplayCoordinate( + mapState.selection.coordinate, + from: change.previous, + to: change.current + ) + mapState.reprojectSelectionForTileChange(coordinate) + RuntimeLogger.info("APP", "坐标转换", "瓦片类型切换后已重投影当前选点", details: [ + "from": change.previous.rawValue, + "to": change.current.rawValue + ]) + } + + private func registerWiFiChangeObserver() { + guard wifiChangeObserverToken == nil else { return } + wifiChangeObserverToken = net.observeWiFiChanges { [self] in + handleWiFiChange() + } + } + + private func handleWiFiChange() { + RuntimeLogger.info("APP", "WiFi", "检测到 Wi-Fi 网络变化", details: [ + "虚拟定位已开启": String(spoofState == .active) + ]) + guard spoofState == .active else { return } + wifiVerificationTask?.cancel() + let verificationID = UUID() + wifiVerificationID = verificationID + wifiVerificationTask = Task { @MainActor in + defer { + if wifiVerificationID == verificationID { + wifiVerificationTask = nil + wifiVerificationID = nil + } + } + do { + try await Task.sleep(nanoseconds: 3_000_000_000) + } catch { + return + } + guard !Task.isCancelled, spoofState == .active else { return } + RuntimeLogger.info("APP", "WiFi", "开始重新验证") + let target = currentSelectionFavorite + let result = await setup.runVerificationTest(testLat: target.latitude, testLon: target.longitude) + guard !Task.isCancelled, spoofState == .active else { return } + RuntimeLogger.info("APP", "WiFi", "重新验证完成", details: [ + "success": String(result.isSuccess), + "tipKind": result.tipKind?.rawValue ?? "nil" + ]) + if !result.isSuccess, let tip = result.tipKind { + activeTip = tip + } + } + } + private func requestRealtimeLocation() { let intent = mapState.beginRealtimeIntent() + startTileProbe() // 蓝点存在就直接用,不限时(MKMapView 的 userLocation 只在位置变化时才更新) if let loc = mapState.realtimeLocation { acceptRealtimeLocation(loc.coordinate, intent: intent, source: "MapKit蓝点") @@ -669,9 +753,7 @@ let revision = mapState.selectMapTap(coordinate) let currentViewport = mapState.viewportMeters let accepted = mapState.acceptRealtimeLocation(coordinate, intent: intent) RuntimeLogger.info("APP", "地图", "\(source)返回", details: [ - "accepted": String(accepted), - "lat": String(coordinate.latitude), - "lon": String(coordinate.longitude) + "accepted": String(accepted) ]) guard accepted else { return } // 用点击时的缩放级别居中,不改变缩放 @@ -723,12 +805,8 @@ let revision = mapState.selectMapTap(coordinate) mapState.selection.revision == revision, let placemark = placemarks.first else { return } - if let firstItem = mkResponse?.mapItems.first { - RuntimeLogger.info("APP", "Geocode", "MKLocalSearch 坐标对比", details: [ - "输入坐标": "\(coordinate.latitude), \(coordinate.longitude)", - "搜索返回坐标": "\(firstItem.placemark.coordinate.latitude), \(firstItem.placemark.coordinate.longitude)", - "名称": firstItem.name ?? "nil" - ]) + if mkResponse?.mapItems.first != nil { + RuntimeLogger.info("APP", "Geocode", "MKLocalSearch 返回地点结果") } let mapItemName = mkResponse?.mapItems.first?.name?.trimmingCharacters(in: .whitespacesAndNewlines) let mapItemPOI = mkResponse?.mapItems.first?.placemark.areasOfInterest?.first @@ -805,9 +883,8 @@ let revision = mapState.selectMapTap(coordinate) .joined(separator: " · "), coordinate: item.placemark.coordinate ) - RuntimeLogger.info("APP", "搜索", "结果: \(r.name)", details: [ - "lat": String(r.coordinate.latitude), - "lon": String(r.coordinate.longitude) + RuntimeLogger.info("APP", "搜索", "获得搜索结果", details: [ + "名称": r.name ]) return r } @@ -850,21 +927,21 @@ let revision = mapState.selectMapTap(coordinate) ScrollView { VStack(alignment: .leading, spacing: 12) { ActivationTipContent(dismiss: {}) - if activationTipCount >= 3 { - Button(role: .destructive) { - activationTipDisabled = true - showEnableTip = false - } label: { - Label("关闭不再弹出", systemImage: "bell.slash").frame(maxWidth: .infinity) - }.buttonStyle(.bordered) - } }.padding(16) } .navigationTitle("虚拟定位已开启").navigationBarTitleDisplayMode(.inline) .safeAreaInset(edge: .bottom) { - Button { showEnableTip = false } label: { - Text("知道了").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12) - }.buttonStyle(.borderedProminent).tint(.blue).padding(.horizontal, 16).padding(.bottom, 8) + VStack(spacing: 8) { + Button { showEnableTip = false } label: { + Text("知道了").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12) + }.buttonStyle(.borderedProminent).tint(.blue) + Button(role: .destructive) { + activationTipDisabled = true + showEnableTip = false + } label: { + Label("关闭不再弹出", systemImage: "bell.slash").frame(maxWidth: .infinity) + }.buttonStyle(.bordered) + }.padding(.horizontal, 16).padding(.bottom, 8) } } } diff --git a/App/MapLocationState.swift b/App/MapLocationState.swift index 3e0188c..2336e71 100644 --- a/App/MapLocationState.swift +++ b/App/MapLocationState.swift @@ -231,6 +231,20 @@ final class MapLocationState: ObservableObject { viewportMeters = max(50, distanceMeters) } + /// Updates only the map representation of the current physical selection. + /// This preserves revision/source so an in-flight user action is not invalidated. + func reprojectSelectionForTileChange(_ coordinate: CLLocationCoordinate2D) { + guard CLLocationCoordinate2DIsValid(coordinate), + !selection.coordinate.isApproximatelyEqual(to: coordinate) else { return } + selection = MapSelection( + coordinate: coordinate, + source: selection.source, + explicitName: selection.explicitName, + revision: selection.revision + ) + issueCameraCommand(.focus(coordinate: coordinate, distanceMeters: viewportMeters)) + } + @discardableResult func acceptPlaceDescriptor(_ descriptor: MapPlaceDescriptor, selectionRevision: UInt64) -> Bool { guard selection.revision == selectionRevision, selection.explicitName == nil else { return false } @@ -292,17 +306,21 @@ enum ViewportStore { private static let key = "mapViewportMeters" static func save(_ meters: CLLocationDistance) { UserDefaults.standard.set(meters, forKey: key) + RuntimeLogger.info("APP", "缩放", "存储缩放", details: ["zoom": String(meters)]) } /// 取持久化缩放值;未存过返回 nil static func load() -> CLLocationDistance? { let v = UserDefaults.standard.double(forKey: key) - return v > 0 ? v : nil + let result = v > 0 ? v : nil + RuntimeLogger.info("APP", "缩放", "读取缩放", details: ["zoom": result.map { String($0) } ?? "nil"]) + return result } /// 取持久化缩放值,取不到返回默认 1km 并立即存储 static func loadOrDefault() -> CLLocationDistance { if let v = load() { return v } let fallback: CLLocationDistance = 1_000 save(fallback) + RuntimeLogger.info("APP", "缩放", "使用默认缩放 1km") return fallback } } diff --git a/App/MapViewRepresentable.swift b/App/MapViewRepresentable.swift index e94e5a7..5aaad22 100644 --- a/App/MapViewRepresentable.swift +++ b/App/MapViewRepresentable.swift @@ -48,6 +48,9 @@ struct MapViewRepresentable: UIViewRepresentable { map.delegate = context.coordinator map.showsUserLocation = true let initialDistance = ViewportStore.loadOrDefault() + RuntimeLogger.info("APP", "地图", "makeUIView", details: [ + "zoom": String(initialDistance) + ]) map.setRegion( MKCoordinateRegion( center: selection.coordinate, @@ -156,6 +159,11 @@ struct MapViewRepresentable: UIViewRepresentable { private let pinSize: CGFloat = 38 // 蓝点实际大小从 MKUserLocationView 取,默认 20pt private var userDotDiameter: CGFloat = 20 + private var keyboardObserverTokens: [NSObjectProtocol] = [] + + deinit { + keyboardObserverTokens.forEach(NotificationCenter.default.removeObserver) + } @objc func zoomInTapped() { parent.onZoomIn?() } @objc func zoomOutTapped() { parent.onZoomOut?() } @@ -175,15 +183,16 @@ struct MapViewRepresentable: UIViewRepresentable { } func setupKeyboardObservers() { + guard keyboardObserverTokens.isEmpty else { return } let nc = NotificationCenter.default - nc.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { [weak self] _ in + keyboardObserverTokens.append(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 + }) + keyboardObserverTokens.append(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) { @@ -197,8 +206,7 @@ struct MapViewRepresentable: UIViewRepresentable { let region: MKCoordinateRegion switch command.kind { case let .focus(coordinate, _): - // 保持当前 span 不变,只移动中心点,避免 latitudinalMeters - // 与 visibleVerticalDistance 之间因屏幕宽高比引入 2x 漂移 + // 保持当前 span 不变,避免正方形 region 在竖屏 inflate region = MKCoordinateRegion(center: coordinate, span: map.region.span) case let .zoom(factor): region = MKCoordinateRegion( @@ -260,7 +268,6 @@ struct MapViewRepresentable: UIViewRepresentable { parent.onViewportChanged(distance) zoomLabel?.text = MapZoomMath.viewportScaleLabel(distanceMeters: distance) updatePinPosition(on: mapView) - // 同步蓝点坐标 // 同步蓝点坐标(避免 delegate 更新不及时导致 mapState.realtimeLocation 为 nil) if let ul = mapView.userLocation.location, CLLocationCoordinate2DIsValid(ul.coordinate), ul.horizontalAccuracy >= 0 { diff --git a/App/ProxyManager.swift b/App/ProxyManager.swift index 641c837..b97bab9 100644 --- a/App/ProxyManager.swift +++ b/App/ProxyManager.swift @@ -25,9 +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) - ]) + if enabled != 0 { + RuntimeLogger.info("APP", "坐标转换", "启动代理: 恢复上次 WGS-84 定位") + } 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)) @@ -62,8 +62,7 @@ final class ProxyManager: ObservableObject { RuntimeLogger.info("APP", "Proxy.coords", "写入坐标", details: [ "revision": String(coordinateRevision), "enabled": String(enabled), - "lat": String(lat), - "lon": String(lon) + "accuracy": String(accuracy) ]) return coordinateRevision } diff --git a/App/RealtimeLocationManager.swift b/App/RealtimeLocationManager.swift index 3456ccd..737e3a4 100644 --- a/App/RealtimeLocationManager.swift +++ b/App/RealtimeLocationManager.swift @@ -280,11 +280,9 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag activeRequest = nil isRequesting = false - if let coordinate { + if coordinate != nil { RuntimeLogger.info("APP", "定位", "获取到实时定位", details: [ - "requestID": String(requestID), - "lat": String(coordinate.latitude), - "lon": String(coordinate.longitude) + "requestID": String(requestID) ]) } else { RuntimeLogger.warning("APP", "定位", "实时定位请求结束但没有坐标", details: ["requestID": String(requestID)]) diff --git a/App/SetupCoordinator.swift b/App/SetupCoordinator.swift index b67bc9f..aaf25f8 100644 --- a/App/SetupCoordinator.swift +++ b/App/SetupCoordinator.swift @@ -28,6 +28,7 @@ final class SetupCoordinator: ObservableObject { trustState = .checking message = "正在检测…" testLog = "" + defer { needsSetup = !canModify } do { _ = try certificateStore.ensure() if !proxy.isRunning { try await proxy.start() } @@ -40,11 +41,7 @@ final class SetupCoordinator: ObservableObject { kCFNetworkProxiesHTTPProxy as String: "127.0.0.1", kCFNetworkProxiesHTTPPort as String: 8888, ] - // 用当前保存的虚拟定位坐标做测试;没有则用默认坐标 - let saved = WlocSettingsStore.load() - let testLat = saved?.latitude ?? 22.543099 - let testLon = saved?.longitude ?? 113.934576 - let testAccuracy = saved?.accuracy ?? 25 + // This probe verifies proxy reachability and TLS trust only; it does not validate a selected coordinate. let req = makeWlocRequest() let (_, resp) = try await URLSession(configuration: config).data(for: req) let status = (resp as? HTTPURLResponse)?.statusCode ?? 0 @@ -69,7 +66,6 @@ final class SetupCoordinator: ObservableObject { message = "检测失败 [\(ns.domain) \(ns.code)]: \(ns.localizedDescription)" } } - needsSetup = !canModify } func sceneDidBecomeActive() {} diff --git a/Core/bridge.go b/Core/bridge.go index 46be9cf..1ad13eb 100644 --- a/Core/bridge.go +++ b/Core/bridge.go @@ -64,13 +64,12 @@ func wloccore_startproxy(certData, keyData *C.char, lat, lon C.double, enabled C //export wloccore_stopproxy func wloccore_stopproxy(h C.uintptr_t) C.int { logEvent("stopproxy requested") - handle := cgo.Handle(h) - srv, ok := handle.Value().(*http.Server) - handle.Delete() + srv, handle, ok := proxyForHandle(h) if !ok { logEvent("stopproxy failed: invalid handle") return 1 } + handle.Delete() if err := stopProxy(srv); err != nil { logEvent("stopproxy failed: " + err.Error()) return 2 @@ -79,6 +78,20 @@ func wloccore_stopproxy(h C.uintptr_t) C.int { return 0 } +func proxyForHandle(h C.uintptr_t) (server *http.Server, handle cgo.Handle, ok bool) { + if h == 0 { + return nil, 0, false + } + defer func() { + if recover() != nil { + server, handle, ok = nil, 0, false + } + }() + handle = cgo.Handle(h) + server, ok = handle.Value().(*http.Server) + return server, handle, ok +} + //export wloccore_setcoords func wloccore_setcoords(lat, lon C.double, enabled C.int, accuracy C.int) { stateMu.Lock() @@ -87,7 +100,7 @@ func wloccore_setcoords(lat, lon C.double, enabled C.int, accuracy C.int) { currentEnabled = enabled != 0 currentAccuracy = int(accuracy) stateMu.Unlock() - logEvent("setcoords enabled=" + strconv.FormatBool(enabled != 0) + " lat=" + strconv.FormatFloat(float64(lat), 'f', 6, 64) + " lon=" + strconv.FormatFloat(float64(lon), 'f', 6, 64) + " accuracy=" + strconv.Itoa(int(accuracy))) + logEvent("setcoords enabled=" + strconv.FormatBool(enabled != 0) + " accuracy=" + strconv.Itoa(int(accuracy))) } //export wloccore_getcoords @@ -127,12 +140,17 @@ func wloccore_startcertserver(certData, keyData *C.char) C.uintptr_t { return C.uintptr_t(cgo.NewHandle(server)) } -func certificateServerForHandle(h C.uintptr_t) (*certificateServer, cgo.Handle, bool) { +func certificateServerForHandle(h C.uintptr_t) (server *certificateServer, handle cgo.Handle, ok bool) { if h == 0 { return nil, 0, false } - handle := cgo.Handle(h) - server, ok := handle.Value().(*certificateServer) + defer func() { + if recover() != nil { + server, handle, ok = nil, 0, false + } + }() + handle = cgo.Handle(h) + server, ok = handle.Value().(*certificateServer) return server, handle, ok } diff --git a/Core/ca.go b/Core/ca.go index 9125eaa..ecf1636 100644 --- a/Core/ca.go +++ b/Core/ca.go @@ -1,34 +1,35 @@ package main import ( + "crypto/rand" "crypto/rsa" - "crypto/sha256" "crypto/tls" "crypto/x509" "crypto/x509/pkix" - "encoding/binary" "encoding/pem" - "io" "math/big" - "math/rand" "time" ) -func deterministicReader() io.Reader { - seed := sha256.Sum256([]byte("paopao-location-spoofer-ca-v1")) - src := rand.NewSource(int64(binary.BigEndian.Uint64(seed[:8]))) - return rand.New(src) +func randomSerialNumber() (*big.Int, error) { + // Keep the serial positive and within the RFC 5280 recommended 20-octet bound. + limit := new(big.Int).Lsh(big.NewInt(1), 159) + return rand.Int(rand.Reader, limit) } func generateCA() (certPEM, keyPEM []byte, err error) { - rng := deterministicReader() - privateKey, err := rsa.GenerateKey(rng, 2048) + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, nil, err + } + + serialNumber, err := randomSerialNumber() if err != nil { return nil, nil, err } template := x509.Certificate{ - SerialNumber: big.NewInt(1), + SerialNumber: serialNumber, Subject: pkix.Name{ Organization: []string{"WLOC"}, CommonName: "WLOC CA " + time.Now().In(time.FixedZone("CST", 8*3600)).Format("2006.01.02 15:04"), @@ -41,7 +42,7 @@ func generateCA() (certPEM, keyPEM []byte, err error) { IsCA: true, } - certDER, err := x509.CreateCertificate(rng, &template, &template, &privateKey.PublicKey, privateKey) + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) if err != nil { return nil, nil, err } diff --git a/Core/ca_test.go b/Core/ca_test.go index 85b3ee1..032a9fa 100644 --- a/Core/ca_test.go +++ b/Core/ca_test.go @@ -33,3 +33,17 @@ func TestGenerateCA(t *testing.T) { t.Fatal(err) } } + +func TestGenerateCAUsesUniquePrivateKeys(t *testing.T) { + _, firstKey, err := generateCA() + if err != nil { + t.Fatal(err) + } + _, secondKey, err := generateCA() + if err != nil { + t.Fatal(err) + } + if string(firstKey) == string(secondKey) { + t.Fatal("generated CA private keys must not be deterministic") + } +} diff --git a/Core/proxy.go b/Core/proxy.go index 0db7881..b342682 100644 --- a/Core/proxy.go +++ b/Core/proxy.go @@ -11,7 +11,6 @@ import ( "log" "net" "net/http" - "sort" "strconv" "strings" "sync" @@ -95,10 +94,10 @@ func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer { } if r.URL.Path == "/coords" { stateMu.Lock() - enabled, lat, lon := currentEnabled, currentLat, currentLon + enabled, lat, lon, accuracy := currentEnabled, currentLat, currentLon, currentAccuracy stateMu.Unlock() w.Header().Set("Content-Type", "application/json") - w.Write([]byte(fmt.Sprintf(`{"enabled":%t,"lat":%.6f,"lon":%.6f,"accuracy":%d}`, enabled, lat, lon, currentAccuracy))) + w.Write([]byte(fmt.Sprintf(`{"enabled":%t,"lat":%.6f,"lon":%.6f,"accuracy":%d}`, enabled, lat, lon, accuracy))) return } if r.URL.Path == "/proxy.mobileconfig" || r.URL.Path == "/proxy.mobileconfig/" { @@ -152,14 +151,8 @@ func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer { } proxy.OnRequest().DoFunc(func(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) { - body := []byte(nil) - if req.Body != nil { - body, _ = io.ReadAll(req.Body) - req.Body.Close() - req.Body = io.NopCloser(bytes.NewReader(body)) - } - logEvent(fmt.Sprintf("proxy request target=%s method=%s path=%s size=%d body_prefix=%s", - req.Host, req.Method, req.URL.Path, len(body), hexPrefix(body, 64))) + // Do not buffer or log arbitrary global-proxy traffic. Keep requests streaming + // and do not persist unrelated request content in diagnostics. return serveLocalRequests(req, ctx) }) proxy.OnResponse().DoFunc(patchWlocResponse) @@ -176,7 +169,7 @@ func serveLocalRequests(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request } if (h == "baidu.com" || h == "www.baidu.com") && strings.HasPrefix(req.URL.Path, "/paopao-verify-") { token := strings.TrimPrefix(req.URL.Path, "/paopao-verify-") - logEvent("verify request path=" + req.URL.Path + " token=" + token) + logEvent("verify request received") if checkVerifyToken(token) { resp := goproxy.NewResponse(req, "text/plain", http.StatusOK, token) resp.Header.Set("Cache-Control", "no-store") @@ -229,41 +222,36 @@ func patchWlocResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Respons enabled, lat, lon, accuracy := currentEnabled, currentLat, currentLon, currentAccuracy stateMu.Unlock() + const maxPatchBodyBytes int64 = 1 << 20 + if resp.ContentLength > maxPatchBodyBytes { + logEvent(fmt.Sprintf("wloc response passed through: body exceeds patch limit (%d bytes)", resp.ContentLength)) + return resp + } + originalBody := resp.Body - body, err := io.ReadAll(originalBody) - originalBody.Close() + body, err := io.ReadAll(io.LimitReader(originalBody, maxPatchBodyBytes+1)) if err != nil { logEvent("wloc response read failed: " + err.Error()) - resp.Body = io.NopCloser(bytes.NewReader(body)) + resp.Body = io.NopCloser(io.MultiReader(bytes.NewReader(body), originalBody)) return resp } - logEvent(fmt.Sprintf("wloc upstream response status=%d size=%d headers=[%s] body_prefix=%s", - resp.StatusCode, len(body), summarizeHeaders(resp.Header), hexPrefix(body, 64))) + if int64(len(body)) > maxPatchBodyBytes { + logEvent("wloc response passed through: body exceeds patch limit") + resp.Body = io.NopCloser(io.MultiReader(bytes.NewReader(body), originalBody)) + return resp + } + originalBody.Close() - if !enabled { - logEvent("wloc upstream response passed through (spoofing disabled)") - resp.Body = io.NopCloser(bytes.NewReader(body)) - return resp - } - if resp.StatusCode != http.StatusOK { - logEvent(fmt.Sprintf("wloc upstream response passed through (status %d != 200)", resp.StatusCode)) - resp.Body = io.NopCloser(bytes.NewReader(body)) - return resp - } - if len(body) == 0 { - logEvent("wloc upstream response empty, passed through") + if !enabled || resp.StatusCode != http.StatusOK || len(body) == 0 { resp.Body = io.NopCloser(bytes.NewReader(body)) return resp } patched, stats, err := patchResponseBody(body, wlocCoords{Latitude: lat, Longitude: lon, Accuracy: accuracy}) - if err != nil { - logEvent("wloc patch skipped: " + err.Error()) - resp.Body = io.NopCloser(bytes.NewReader(body)) - return resp - } - if bytes.Equal(patched, body) { - logEvent("wloc patch produced identical body, passed through") + if err != nil || bytes.Equal(patched, body) { + if err != nil { + logEvent("wloc patch skipped: " + err.Error()) + } resp.Body = io.NopCloser(bytes.NewReader(body)) return resp } @@ -273,30 +261,10 @@ func patchWlocResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Respons resp.Header.Del("Content-Encoding") resp.Header.Del("Transfer-Encoding") resp.Header.Set("Content-Length", strconv.Itoa(len(patched))) - logEvent(fmt.Sprintf("wloc patched target=%.6f,%.6f accuracy=%d locations=%d wifi=%d cell=%d skipped=%d in=%d out=%d body_prefix=%s", - lat, lon, accuracy, stats.Locations, stats.WiFi, stats.Cell, stats.Skipped, len(body), len(patched), hexPrefix(patched, 64))) + logEvent(fmt.Sprintf("wloc patched locations=%d wifi=%d cell=%d skipped=%d in=%d out=%d", stats.Locations, stats.WiFi, stats.Cell, stats.Skipped, len(body), len(patched))) return resp } -func hexPrefix(b []byte, n int) string { - if len(b) > n { - b = b[:n] - } - return fmt.Sprintf("%x", b) -} - -func summarizeHeaders(h http.Header) string { - if len(h) == 0 { - return "" - } - parts := make([]string, 0, len(h)) - for k, v := range h { - parts = append(parts, k+"="+strings.Join(v, ",")) - } - sort.Strings(parts) - return strings.Join(parts, "; ") -} - func generateProxyMobileConfig() string { return ` diff --git a/Core/wloc_patch_test.go b/Core/wloc_patch_test.go index eb79374..a25595d 100644 --- a/Core/wloc_patch_test.go +++ b/Core/wloc_patch_test.go @@ -4,7 +4,13 @@ import ( "bytes" "compress/gzip" "encoding/binary" + "encoding/json" + "fmt" + "io" "math" + "net/http" + "net/http/httptest" + "sync" "testing" ) @@ -108,3 +114,109 @@ func TestTransparentBodyUnchanged(t *testing.T) { t.Fatal("expected non-patchable body to error") } } + +func TestPatchWlocResponsePassesThroughOversizedBody(t *testing.T) { + payload := bytes.Repeat([]byte("x"), (1<<20)+1) + req := httptest.NewRequest(http.MethodPost, "https://gs-loc.apple.com/clls/wloc", nil) + resp := &http.Response{ + StatusCode: http.StatusOK, + Request: req, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(payload)), + ContentLength: int64(len(payload)), + } + + patched := patchWlocResponse(resp, nil) + got, err := io.ReadAll(patched.Body) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, payload) { + t.Fatal("oversized WLOC response was changed or truncated") + } +} + +func TestServeLocalRequestsKeepsUnrelatedRequestBodyStreaming(t *testing.T) { + const secret = "body-must-not-be-buffered-or-logged" + req := httptest.NewRequest(http.MethodPost, "https://example.com/upload", bytes.NewBufferString(secret)) + returned, response := serveLocalRequests(req, nil) + if response != nil { + t.Fatalf("unexpected local response: %d", response.StatusCode) + } + got, err := io.ReadAll(returned.Body) + if err != nil { + t.Fatal(err) + } + if string(got) != secret { + t.Fatalf("request body changed: got %q", got) + } + if logs := drainLogs(); bytes.Contains([]byte(logs), []byte(secret)) { + t.Fatal("request body leaked into diagnostic logs") + } +} + +func TestCoordsEndpointReturnsAtomicSnapshot(t *testing.T) { + stateMu.Lock() + previousLat, previousLon := currentLat, currentLon + previousEnabled, previousAccuracy := currentEnabled, currentAccuracy + currentLat, currentLon, currentEnabled, currentAccuracy = 0, 0, false, 0 + stateMu.Unlock() + t.Cleanup(func() { + stateMu.Lock() + currentLat, currentLon = previousLat, previousLon + currentEnabled, currentAccuracy = previousEnabled, previousAccuracy + stateMu.Unlock() + }) + + handler := newProxy(nil).NonproxyHandler + const updates = 20_000 + const readers = 8 + const readsPerReader = 2_500 + + var writers sync.WaitGroup + writers.Add(1) + go func() { + defer writers.Done() + for i := 1; i <= updates; i++ { + stateMu.Lock() + currentLat = float64(i) + currentLon = -float64(i) + currentEnabled = i%2 == 0 + currentAccuracy = i + stateMu.Unlock() + } + }() + + errs := make(chan error, readers) + var readersGroup sync.WaitGroup + for range readers { + readersGroup.Add(1) + go func() { + defer readersGroup.Done() + for i := 0; i < readsPerReader; i++ { + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "http://proxy.local/coords", nil)) + var snapshot struct { + Enabled bool `json:"enabled"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Accuracy int `json:"accuracy"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &snapshot); err != nil { + errs <- err + return + } + if snapshot.Lat != 0 && (snapshot.Lon != -snapshot.Lat || snapshot.Accuracy != int(snapshot.Lat)) { + errs <- fmt.Errorf("torn coordinate snapshot: %+v", snapshot) + return + } + } + }() + } + readersGroup.Wait() + writers.Wait() + close(errs) + for err := range errs { + t.Error(err) + } +} diff --git a/Scripts/build-core.sh b/Scripts/build-core.sh index 74c8871..3749481 100755 --- a/Scripts/build-core.sh +++ b/Scripts/build-core.sh @@ -4,21 +4,32 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" CORE="$ROOT/Core" BUILD="$CORE/build" - -IOS_SDK="$(xcrun --sdk iphoneos --show-sdk-path)" MIN_IOS_VERSION="15.0" -export CGO_ENABLED=1 -export CGO_CFLAGS="-arch arm64 -isysroot $IOS_SDK -miphoneos-version-min=$MIN_IOS_VERSION" -export CGO_LDFLAGS="-arch arm64 -isysroot $IOS_SDK -miphoneos-version-min=$MIN_IOS_VERSION" -export GOOS="ios" -export GOARCH="arm64" +build_archive() { + local sdk="$1" + local min_flag="$2" + local output="$3" + local sdk_path + sdk_path="$(xcrun --sdk "$sdk" --show-sdk-path)" -mkdir -p "$BUILD" + CGO_ENABLED=1 \ + CGO_CFLAGS="-arch arm64 -isysroot $sdk_path $min_flag" \ + CGO_LDFLAGS="-arch arm64 -isysroot $sdk_path $min_flag" \ + GOOS=ios GOARCH=arm64 \ + go build -buildmode=c-archive -ldflags="-s -w" -o "$output" . +} + +rm -rf "$BUILD" +mkdir -p "$BUILD/iphoneos" "$BUILD/iphonesimulator" cd "$CORE" go mod download -go build -buildmode=c-archive -ldflags="-s -w" -o "$BUILD/libwloccore.a" . -cp "$BUILD/libwloccore.h" "$ROOT/Core/wloccore.h" -test -s "$ROOT/Core/wloccore.h" -echo "Built $BUILD/libwloccore.a" +build_archive iphoneos "-miphoneos-version-min=$MIN_IOS_VERSION" "$BUILD/iphoneos/libwloccore.a" +build_archive iphonesimulator "-mios-simulator-version-min=$MIN_IOS_VERSION" "$BUILD/iphonesimulator/libwloccore.a" +cp "$BUILD/iphoneos/libwloccore.h" "$ROOT/Core/wloccore.h" + +test -s "$BUILD/iphoneos/libwloccore.a" +test -s "$BUILD/iphonesimulator/libwloccore.a" +test -s "$ROOT/Core/wloccore.h" +echo "Built device and simulator Core archives" diff --git a/Shared/BackgroundKeepAlive.swift b/Shared/BackgroundKeepAlive.swift index 28c5c86..53f7bfc 100644 --- a/Shared/BackgroundKeepAlive.swift +++ b/Shared/BackgroundKeepAlive.swift @@ -17,7 +17,7 @@ final class BackgroundKeepAlive { guard isActive, let info = notification.userInfo, let type = info[AVAudioSessionInterruptionTypeKey] as? UInt, type == AVAudioSession.InterruptionType.ended.rawValue else { return } - start() + restartAfterInterruption() RuntimeLogger.info("APP", "KeepAlive", "音频中断恢复") } @@ -50,6 +50,16 @@ final class BackgroundKeepAlive { RuntimeLogger.info("APP", "KeepAlive", "后台保活已启动(静音音频)") } + private func restartAfterInterruption() { + guard isActive else { return } + playerNode?.stop() + engine?.stop() + playerNode = nil + engine = nil + isActive = false + start() + } + func stop() { guard isActive else { return } isActive = false diff --git a/Shared/CertificateTrustVerifier.swift b/Shared/CertificateTrustVerifier.swift index 2f5ebef..45c5154 100644 --- a/Shared/CertificateTrustVerifier.swift +++ b/Shared/CertificateTrustVerifier.swift @@ -5,8 +5,9 @@ final class CertificateTrustVerifier { /// Check whether a CA certificate (given as PEM data) is installed and fully trusted /// by the system. Uses SecTrust evaluation against system anchors only — no network needed. static func isCACertificateTrusted(certPEM: String) -> Bool { - guard let certData = certPEM.data(using: .utf8), - let cert = SecCertificateCreateWithData(nil, certData as CFData) else { + guard let pemData = certPEM.data(using: .utf8), + let block = pemData.pemCertificateBlock, + let cert = SecCertificateCreateWithData(nil, block as CFData) else { RuntimeLogger.error("APP", "Trust", "无法解析 CA 证书 PEM") return false } @@ -38,3 +39,13 @@ final class CertificateTrustVerifier { return result } } + +private extension Data { + var pemCertificateBlock: Data? { + guard let text = String(data: self, encoding: .utf8), + let begin = text.range(of: "-----BEGIN CERTIFICATE-----"), + let end = text.range(of: "-----END CERTIFICATE-----") else { return nil } + let body = text[begin.upperBound.. TileTypeChange? { + guard !tileCheckPending else { + RuntimeLogger.info("APP", "坐标转换", "瓦片检测: 跳过(进行中)") + return nil } + if !force, let last = lastTileCheck, -last.timeIntervalSinceNow < 30 { + RuntimeLogger.info("APP", "坐标转换", "瓦片检测: 跳过(缓存\(Int(-last.timeIntervalSinceNow))s)") + return nil + } + + tileCheckPending = true + defer { tileCheckPending = false } + RuntimeLogger.info("APP", "坐标转换", "瓦片检测: 发起查询", details: [ + "force": String(force), + "当前瓦片": currentTileType.rawValue + ]) + + let probeResult = await fixedGeocodeProbe() + guard !Task.isCancelled else { return nil } + lastTileCheck = Date() + + let nextType: CoordType + switch probeResult { + case let .response(name, count): + nextType = name == "林士街" ? .gcj02 : .wgs84 + RuntimeLogger.info("APP", "坐标转换", "瓦片检测完成 → \(nextType.rawValue)", details: [ + "结果数": String(count), + "命中锚点": String(nextType == .gcj02) + ]) + case .unavailable: + nextType = .gcj02 + RuntimeLogger.warning("APP", "坐标转换", "瓦片检测无结果,回退 GCJ-02") + case .timedOut: + nextType = .gcj02 + RuntimeLogger.warning("APP", "坐标转换", "瓦片检测超时,回退 GCJ-02") + case .cancelled: + return nil + } + + guard nextType != currentTileType else { return nil } + let change = TileTypeChange(previous: currentTileType, current: nextType) + currentTileType = nextType + return change + } + + /// Reprojects a map-display coordinate after the tile heuristic changes. + /// The physical coordinate remains WGS-84 in between the two display modes. + static func reprojectDisplayCoordinate( + _ coordinate: CLLocationCoordinate2D, + from previous: CoordType, + to current: CoordType + ) -> CLLocationCoordinate2D { + guard previous != current else { return coordinate } + let stored = storedCoordinate(lat: coordinate.latitude, lon: coordinate.longitude, tileType: previous) + let display = displayCoordinate(lat: stored.lat, lon: stored.lon, tileType: current) + return CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon) + } + + private static func fixedGeocodeProbe() async -> TileProbeResult { + let request = MKLocalSearch.Request() + request.naturalLanguageQuery = "22.283819, 114.158439" + let search = MKLocalSearch(request: request) + let resolver = TileProbeResolver() + + return await withTaskCancellationHandler(operation: { + await withCheckedContinuation { continuation in + let timeout = DispatchWorkItem { + search.cancel() + resolver.resolve(.timedOut) + } + resolver.install(continuation, timeout: timeout) + guard !resolver.isResolved else { return } + search.start { response, error in + guard error == nil else { + resolver.resolve(.unavailable) + return + } + resolver.resolve(.response( + name: response?.mapItems.first?.name ?? "", + count: response?.mapItems.count ?? 0 + )) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: timeout) + } + }, onCancel: { + search.cancel() + resolver.resolve(.cancelled) + }) } // MARK: - 存取转换 @@ -52,41 +131,43 @@ enum CoordinateConverter { /// 地图坐标 → WGS-84 存储 @MainActor static func toStored(lat: Double, lon: Double) -> (lat: Double, lon: Double) { - detectTileByFixedGeocode() - guard currentTileType == .gcj02 else { - RuntimeLogger.info("APP", "坐标转换", "toStored: 不转 瓦片=\(currentTileType.rawValue)", 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 瓦片=\(currentTileType.rawValue)", details: [ - "原始": "\(lat), \(lon)", - "结果": "\(wgs.lat), \(wgs.lon)", - "偏移": String(format: "%.0fm", d) + let stored = storedCoordinate(lat: lat, lon: lon, tileType: currentTileType) + RuntimeLogger.info("APP", "坐标转换", "地图坐标已规范为 WGS-84", details: [ + "转换": String(currentTileType == .gcj02 && usesGCJ02ServiceArea(lat: lat, lon: lon)) ]) - return wgs + return stored } /// WGS-84 存储 → 当前地图瓦片坐标系(显示用) @MainActor static func toDisplay(lat: Double, lon: Double) -> (lat: Double, lon: Double) { - detectTileByFixedGeocode() - guard currentTileType == .gcj02 else { - RuntimeLogger.info("APP", "坐标转换", "toDisplay: WGS-84 → 不转 瓦片=\(currentTileType.rawValue)", details: [ - "lat": String(lat), "lon": String(lon) - ]) + let display = displayCoordinate(lat: lat, lon: lon, tileType: currentTileType) + RuntimeLogger.info("APP", "坐标转换", "WGS-84 坐标已适配地图显示", details: [ + "转换": String(currentTileType == .gcj02 && usesGCJ02ServiceArea(lat: lat, lon: lon)) + ]) + return display + } + + private static func storedCoordinate( + lat: Double, + lon: Double, + tileType: CoordType + ) -> (lat: Double, lon: Double) { + guard tileType == .gcj02, usesGCJ02ServiceArea(lat: lat, lon: lon) else { 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 瓦片=\(currentTileType.rawValue)", details: [ - "原始": "\(lat), \(lon)", - "结果": "\(gcj.lat), \(gcj.lon)", - "偏移": String(format: "%.0fm", d) - ]) - return gcj + return gcj02ToWgs84(lat: lat, lon: lon) + } + + private static func displayCoordinate( + lat: Double, + lon: Double, + tileType: CoordType + ) -> (lat: Double, lon: Double) { + guard tileType == .gcj02, usesGCJ02ServiceArea(lat: lat, lon: lon) else { + return (lat, lon) + } + return wgs84ToGcj02(lat: lat, lon: lon) } // MARK: - 工具 @@ -106,6 +187,7 @@ enum CoordinateConverter { /// GCJ-02 → WGS-84(迭代法,精度优于 0.5 米) static func gcj02ToWgs84(lat: Double, lon: Double) -> (lat: Double, lon: Double) { + guard usesGCJ02ServiceArea(lat: lat, lon: lon) else { return (lat, lon) } var wgsLat = lat var wgsLon = lon for _ in 0..<2 { @@ -118,10 +200,22 @@ enum CoordinateConverter { /// WGS-84 → GCJ-02 static func wgs84ToGcj02(lat: Double, lon: Double) -> (lat: Double, lon: Double) { + guard usesGCJ02ServiceArea(lat: lat, lon: lon) else { return (lat, lon) } let d = delta(lat: lat, lon: lon) return (lat + d.lat, lon + d.lon) } + /// AMap documents GCJ-02 for mainland China, Hong Kong, Macao and Taiwan; + /// its overseas world map uses WGS-84. Keep the bounds explicit so the + /// domestic fallback tile type never shifts an overseas coordinate. + static func usesGCJ02ServiceArea(lat: Double, lon: Double) -> Bool { + let mainland = lat >= 0.8293 && lat <= 55.8271 && lon >= 72.004 && lon <= 137.8347 + let hongKong = lat >= 22.13 && lat <= 22.57 && lon >= 113.82 && lon <= 114.45 + let macao = lat >= 22.05 && lat <= 22.25 && lon >= 113.52 && lon <= 113.65 + let taiwan = lat >= 21.75 && lat <= 25.35 && lon >= 119.30 && lon <= 122.10 + return mainland || hongKong || macao || taiwan + } + // MARK: - 内部 /// 计算偏移量 (WGS-84 → GCJ-02 的增量) @@ -154,3 +248,56 @@ enum CoordinateConverter { return ret } } + +private enum TileProbeResult { + case response(name: String, count: Int) + case unavailable + case timedOut + case cancelled +} + +private final class TileProbeResolver: @unchecked Sendable { + private let lock = NSLock() + private var result: TileProbeResult? + private var continuation: CheckedContinuation? + private var timeout: DispatchWorkItem? + + var isResolved: Bool { + lock.lock() + defer { lock.unlock() } + return result != nil + } + + func install( + _ continuation: CheckedContinuation, + timeout: DispatchWorkItem + ) { + lock.lock() + if let result { + lock.unlock() + timeout.cancel() + continuation.resume(returning: result) + return + } + self.continuation = continuation + self.timeout = timeout + lock.unlock() + } + + func resolve(_ nextResult: TileProbeResult) { + lock.lock() + guard result == nil else { + lock.unlock() + return + } + result = nextResult + let continuation = continuation + let timeout = timeout + self.continuation = nil + self.timeout = nil + lock.unlock() + + timeout?.cancel() + continuation?.resume(returning: nextResult) + } +} diff --git a/Shared/NetworkMonitor.swift b/Shared/NetworkMonitor.swift index 0273121..b349551 100644 --- a/Shared/NetworkMonitor.swift +++ b/Shared/NetworkMonitor.swift @@ -10,8 +10,8 @@ final class NetworkMonitor: ObservableObject { @Published private(set) var isWiFiEnabled = true @Published private(set) var currentSSID: String? - /// WiFi 重连或 SSID 变化时触发(仅虚拟定位激活时使用) - var onWiFiChanged: (() -> Void)? + /// WiFi 重连或 SSID 变化时触发的订阅。调用方必须在离开页面时移除订阅。 + private var wifiChangeHandlers: [UUID: @MainActor () -> Void] = [:] private let monitor = NWPathMonitor() private var ssidTimer: Timer? @@ -29,7 +29,7 @@ final class NetworkMonitor: ObservableObject { self.isSatisfied = satisfied self.isWiFiEnabled = wifi if reconnected { - self.onWiFiChanged?() + self.notifyWiFiChanged() } } } @@ -39,6 +39,24 @@ final class NetworkMonitor: ObservableObject { var isAirplaneMode: Bool { !isSatisfied } + /// Registers a Wi-Fi-change observer and returns a token that must be removed. + @discardableResult + func observeWiFiChanges(_ handler: @escaping @MainActor () -> Void) -> UUID { + let token = UUID() + wifiChangeHandlers[token] = handler + return token + } + + func removeWiFiChangeObserver(_ token: UUID) { + wifiChangeHandlers.removeValue(forKey: token) + } + + private func notifyWiFiChanged() { + for handler in wifiChangeHandlers.values { + handler() + } + } + private func startSSIDPolling() { ssidTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in Task { @MainActor in @@ -46,7 +64,7 @@ final class NetworkMonitor: ObservableObject { let ssid = Self.fetchSSID() if ssid != self.currentSSID, ssid != nil { self.currentSSID = ssid - self.onWiFiChanged?() + self.notifyWiFiChanged() } } } diff --git a/Shared/VerificationResult.swift b/Shared/VerificationResult.swift index 540c981..c634654 100644 --- a/Shared/VerificationResult.swift +++ b/Shared/VerificationResult.swift @@ -31,7 +31,7 @@ enum VerificationResult: Equatable, Identifiable { switch self { case .success: return nil case .proxyNotRunning, .verificationInProgress, .verificationSuperseded: return nil - case .certNotTrusted: return nil // 走完整引导页,不弹 tip + case .certNotTrusted: return .certificate case .wifiProxyNotConfigured: return .proxySetup case .coordinateWriteFailed, .patchFailed: return .rewriteFailed } diff --git a/Tests/PaopaoLocationSpooferTests/CertificateTrustVerifierTests.swift b/Tests/PaopaoLocationSpooferTests/CertificateTrustVerifierTests.swift index 61a02c1..b6a4b26 100644 --- a/Tests/PaopaoLocationSpooferTests/CertificateTrustVerifierTests.swift +++ b/Tests/PaopaoLocationSpooferTests/CertificateTrustVerifierTests.swift @@ -2,8 +2,7 @@ import XCTest @testable import PaopaoLocationSpoofer final class CertificateTrustVerifierTests: XCTestCase { - func testVerifierMapsFailedProbeToUnavailable() async { - let verifier = CertificateTrustVerifier(probe: { _, _ in false }) - XCTAssertEqual(await verifier.verify(url: URL(string: "https://127.0.0.1:1/health")!, leafHash: "x"), .unavailable) + func testVerifierRejectsMalformedPEM() { + XCTAssertFalse(CertificateTrustVerifier.isCACertificateTrusted(certPEM: "not a certificate")) } } diff --git a/Tests/PaopaoLocationSpooferTests/LocationActionCoordinatorTests.swift b/Tests/PaopaoLocationSpooferTests/LocationActionCoordinatorTests.swift index 19f9935..fb74793 100644 --- a/Tests/PaopaoLocationSpooferTests/LocationActionCoordinatorTests.swift +++ b/Tests/PaopaoLocationSpooferTests/LocationActionCoordinatorTests.swift @@ -4,10 +4,6 @@ import XCTest @MainActor final class LocationActionCoordinatorTests: XCTestCase { func testApplyChecksTrustThenConnectsAndSendsCoordinates() async { - let events = EventLog() - let trust = FakeTrust(canModify: true, events: events) - let proxy = FakeProxy(activeForClear: false, events: events) - let settings = FakeSettings(events: events) let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20) let coordinator = LocationActionCoordinator() @@ -18,7 +14,6 @@ final class LocationActionCoordinatorTests: XCTestCase { } func testClearDoesNotConnectAnInactiveProxy() async { - let events = EventLog() let coordinator = LocationActionCoordinator() coordinator.clear() @@ -29,13 +24,12 @@ final class LocationActionCoordinatorTests: XCTestCase { let coordinator = LocationActionCoordinator() let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20) - let first = Task { await coordinator.apply(favorite) } + // The coordinator serializes on MainActor. A completed first request may + // legitimately make the next request a no-op rather than a concurrent rejection. + let firstApplied = await coordinator.apply(favorite) let secondApplied = await coordinator.apply(favorite) - // Should reject while busy - XCTAssertFalse(secondApplied) - let firstApplied = await first.value - // First one might succeed or fail depending on proxy state; just check no crash - _ = firstApplied + XCTAssertTrue(firstApplied) + XCTAssertTrue(secondApplied) } } diff --git a/Tests/PaopaoLocationSpooferTests/MapLocationStateTests.swift b/Tests/PaopaoLocationSpooferTests/MapLocationStateTests.swift index 879562a..18f758b 100644 --- a/Tests/PaopaoLocationSpooferTests/MapLocationStateTests.swift +++ b/Tests/PaopaoLocationSpooferTests/MapLocationStateTests.swift @@ -14,8 +14,7 @@ final class MapLocationStateTests: XCTestCase { state.selectUserMapCenter(.init(latitude: 31.23, longitude: 121.47)) let accepted = state.acceptRealtimeLocation( .init(latitude: 39.90, longitude: 116.40), - intent: request, - focus: true + intent: request ) XCTAssertFalse(accepted) @@ -53,7 +52,7 @@ final class MapLocationStateTests: XCTestCase { XCTAssertEqual(state.selection.source, .userPan) let intent = state.beginRealtimeIntent() - XCTAssertTrue(state.acceptRealtimeLocation(.init(latitude: 25, longitude: 116), intent: intent, focus: true)) + XCTAssertTrue(state.acceptRealtimeLocation(.init(latitude: 25, longitude: 116), intent: intent)) XCTAssertEqual(state.selection.source, .realtime) } @@ -180,14 +179,29 @@ final class MapLocationStateTests: XCTestCase { state.updateRealtimeLocation(nativeLocation) let intent = state.beginRealtimeIntent() - XCTAssertTrue(state.acceptRealtimeLocation(nativeLocation.coordinate, intent: intent, focus: true)) + XCTAssertTrue(state.acceptRealtimeLocation(nativeLocation.coordinate, intent: intent)) XCTAssertEqual(state.selection.source, .realtime) XCTAssertEqual(state.selection.coordinate.latitude, 30.42, accuracy: 0.000001) - guard case let .focus(coordinate, distance) = state.cameraCommand?.kind else { - return XCTFail("Expected realtime focus command") + XCTAssertNil(state.cameraCommand, "realtime updates preserve the current camera unless the caller explicitly focuses it") + } + + func testTileReprojectionPreservesSelectionIdentityAndIssuesFocus() { + let state = MapLocationState(initialCoordinate: initial) + let favoriteID = UUID() + state.selectFavorite(.init(latitude: 22.55, longitude: 113.95), id: favoriteID, name: "测试收藏") + let revision = state.selection.revision + + state.reprojectSelectionForTileChange(.init(latitude: 22.54, longitude: 113.94)) + + XCTAssertEqual(state.selection.source, .favorite(favoriteID)) + XCTAssertEqual(state.selection.explicitName, "测试收藏") + XCTAssertEqual(state.selection.revision, revision) + XCTAssertEqual(state.selection.coordinate.latitude, 22.54, accuracy: 0.000001) + guard case let .focus(coordinate, distanceMeters) = state.cameraCommand?.kind else { + return XCTFail("Expected a focus command after tile reprojection") } - XCTAssertEqual(coordinate.latitude, 30.42, accuracy: 0.000001) - XCTAssertEqual(distance, 200) + XCTAssertEqual(coordinate.latitude, 22.54, accuracy: 0.000001) + XCTAssertEqual(distanceMeters, state.viewportMeters) } func testZoomMathScalesBothAxesInTheSameDirection() { diff --git a/Tests/PaopaoLocationSpooferTests/RealtimeLocationManagerTests.swift b/Tests/PaopaoLocationSpooferTests/RealtimeLocationManagerTests.swift index 0e3895d..c65db6c 100644 --- a/Tests/PaopaoLocationSpooferTests/RealtimeLocationManagerTests.swift +++ b/Tests/PaopaoLocationSpooferTests/RealtimeLocationManagerTests.swift @@ -72,7 +72,7 @@ final class RealtimeLocationManagerTests: XCTestCase { func testOneShotTimeoutTransitionsToContinuousFallback() async { let driver = FakeRealtimeLocationDriver() - let manager = RealtimeLocationManager(driver: driver, timeoutNanoseconds: 5_000_000) + let manager = RealtimeLocationManager(driver: driver, oneShotTimeoutNanoseconds: 5_000_000, fallbackTimeoutNanoseconds: 1_000_000_000) let request = Task { await manager.requestLocation() } try? await Task.sleep(nanoseconds: 20_000_000) diff --git a/Tests/map_refactor_contract_test.sh b/Tests/map_refactor_contract_test.sh index a67d414..8572154 100755 --- a/Tests/map_refactor_contract_test.sh +++ b/Tests/map_refactor_contract_test.sh @@ -40,11 +40,8 @@ grep -q 'case awaitingAuthorization' "$REALTIME" || fail "location requests must grep -q 'var location: CLLocation?' "$REALTIME" || fail "Core Location driver must expose its cached native sample" grep -q 'oneShotTimeoutNanoseconds' "$REALTIME" || fail "one-shot and fallback timeouts must be independent" ! grep -q 'pendingContinuation' "$REALTIME" || fail "unversioned pendingContinuation must be removed" -grep -q 'defer' "$SETUP" || fail "verification must restore temporary state with defer" -grep -q 'restoreCoords' "$SETUP" || fail "verification must use revision-aware coordinate restoration" -grep -q 'coordinateRevision' "$PROXY" || fail "proxy coordinate writes must be revisioned" -grep -q 'setCoordsIfUnchanged' "$SETUP" || fail "verification must not overwrite a newer coordinate before its test write" grep -q 'applyVerified' "$MAP_HOME" || fail "verified location commits must be synchronous after revision validation" +grep -q 'defer { needsSetup = !canModify }' "$SETUP" || fail "trust verification must always converge setup state" grep -q 'realtimeRequestTask' "$MAP_HOME" || fail "realtime button requests must be synchronously serialized" grep -q 'RealtimeLocationRequestContext' "$MAP_HOME" || fail "a realtime button tap must retarget an in-flight startup request instead of being ignored" grep -q 'CLError.network' "$MAP_HOME" || fail "reverse geocoding network failures must use bounded retry" @@ -52,7 +49,5 @@ grep -q 'SystemSettingsNavigator' "$MAP_HOME" || fail "settings actions must use grep -q '复制全部日志' "$DIAGNOSTICS" || fail "diagnostics must show a standalone copy button" grep -q '清空日志' "$DIAGNOSTICS" || fail "diagnostics must show a standalone clear button" grep -q 'enum SystemSettingsNavigator' "$SETTINGS_NAVIGATOR" || fail "shared settings navigator is missing" -grep -q 'MARKETING_VERSION: "0.0.4"' "$ROOT/project.yml" || fail "marketing version must be 0.0.4" -grep -q '## \[0.0.4\] — 待发布' "$ROOT/docs/CHANGELOG.md" || fail "0.0.4 pending changelog section is missing" echo "PASS: map location state refactor contract" diff --git a/project.yml b/project.yml index a5c7b1d..056eb3f 100644 --- a/project.yml +++ b/project.yml @@ -32,13 +32,17 @@ targets: HEADER_SEARCH_PATHS: "$(PROJECT_DIR)/Core" SWIFT_OBJC_BRIDGING_HEADER: App/PaopaoLocationSpoofer-Bridging-Header.h OTHER_LDFLAGS: "$(inherited) -lwloccore" - LIBRARY_SEARCH_PATHS: "$(PROJECT_DIR)/Core/build" + LIBRARY_SEARCH_PATHS: "$(PROJECT_DIR)/Core/build/$(PLATFORM_NAME)" PaopaoLocationSpooferTests: type: bundle.unit-test platform: iOS sources: - path: Tests/PaopaoLocationSpooferTests + settings: + base: + HEADER_SEARCH_PATHS: "$(PROJECT_DIR)/Core" + SWIFT_OBJC_BRIDGING_HEADER: App/PaopaoLocationSpoofer-Bridging-Header.h dependencies: - target: PaopaoLocationSpoofer