Files
location-spoofer/App/MapHomeView.swift
T

1184 lines
54 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import MapKit
import UIKit
import CoreLocation
private struct SearchLocationResult: Identifiable {
let id = UUID()
let name: String
let subtitle: String
let coordinate: CLLocationCoordinate2D
}
private enum HomeSheet: String, Identifiable {
case settings, logs
var id: String { rawValue }
}
private enum SpoofState {
case idle, verifying, active
}
private struct RealtimeLocationRequestContext {
let intent: RealtimeLocationIntent
let source: String
let showFailureAlert: Bool
}
struct MapHomeView: View {
@ObservedObject var setup: SetupCoordinator
@StateObject private var favorites: FavoriteLocationStore
@StateObject private var actions = LocationActionCoordinator()
@ObservedObject private var proxy = ProxyManager.shared
@StateObject private var realtime = RealtimeLocationManager.shared
@StateObject private var mapState: MapLocationState
@ObservedObject private var net = NetworkMonitor.shared
@State private var searchText = ""
@State private var searchResults: [SearchLocationResult] = []
@State private var isSearching = false
@State private var searchRequestID: UInt64 = 0
@State private var searchError = ""
@State private var mapRuntimeDidStart = false
@State private var activeSheet: HomeSheet?
@State private var showEnableTip = false
@State private var showDisableTip = false
@State private var activeTip: TipKind?
@State private var manualHint = ""
private let tipPreferences = VirtualLocationTipPreferences()
@State private var editingFavorite: FavoriteLocation?
@State private var editName = ""
@State private var reverseGeocodeTask: Task<Void, Never>?
@State private var geocodeDebounceTask: Task<Void, Never>?
@State private var showLocationAlert = false
@State private var realtimeRequestTask: Task<Void, Never>?
@State private var realtimeRequestContext: RealtimeLocationRequestContext?
@State private var wifiChangeObserverToken: UUID?
@State private var wifiVerificationTask: Task<Void, Never>?
@State private var wifiVerificationID: UUID?
@State private var copyConfirmed = false
@State private var spoofState: SpoofState = .idle
@State private var locationOperationTask: Task<Void, Never>?
@State private var locationOperationID: UInt64 = 0
// 激活时的坐标(本地存,绕过 C 桥接层精度丢失)
@State private var activeSpoofLat: Double?
@State private var activeSpoofLon: Double?
init(setup: SetupCoordinator) {
self.setup = setup
let favoriteStore = FavoriteLocationStore()
_favorites = StateObject(wrappedValue: favoriteStore)
let savedCoord = LastCoordinateStore.load()
let initialZoom = savedCoord?.zoomMeters ?? ViewportStore.loadOrDefault()
let selectedFavorite = favoriteStore.selectedFavorite
let initialCoord: CLLocationCoordinate2D
let initialSource: MapSelectionSource
let initialName: String?
if let saved = savedCoord {
initialCoord = saved.coordinate(for: CoordinateConverter.currentMapCoordinateSystem)
if let selectedFavorite,
selectedFavorite.coordinatePair.matchesWGS84(
latitude: saved.coordinatePair.wgs84.latitude,
longitude: saved.coordinatePair.wgs84.longitude
) {
initialSource = .favorite(selectedFavorite.id)
initialName = selectedFavorite.name
} else {
initialSource = .initial
initialName = nil
}
} else if let selectedFavorite {
initialCoord = selectedFavorite.coordinatePair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem)
initialSource = .favorite(selectedFavorite.id)
initialName = selectedFavorite.name
} else {
// The fallback location is defined as WGS-84 and rendered in the
// coordinate system resolved by the startup gate.
initialCoord = CoordinateConverter.coordinatePair(
lat: 22.544577,
lon: 113.94114,
mapCoordinateSystem: .wgs84
).coordinate(for: CoordinateConverter.currentMapCoordinateSystem)
initialSource = .initial
initialName = nil
}
RuntimeLogger.info("APP", "地图", "初始化", details: [
"zoom": String(initialZoom),
"有缓存": String(savedCoord != nil),
"初始来源": String(describing: initialSource),
"地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue
])
_mapState = StateObject(wrappedValue: MapLocationState(
initialCoordinate: initialCoord,
initialViewportMeters: initialZoom,
initialSource: initialSource,
initialName: initialName
))
if let settings = WlocSettingsStore.load(), settings.enabled {
_spoofState = State(initialValue: .active)
_activeSpoofLat = State(initialValue: settings.latitude)
_activeSpoofLon = State(initialValue: settings.longitude)
}
}
var body: some View {
ZStack {
MapViewRepresentable(
selection: mapState.selection,
initialViewportMeters: mapState.viewportMeters,
cameraCommand: mapState.cameraCommand,
onRealtimeLocationChanged: { location in
handleNativeRealtimeLocation(location)
},
onUserCenterChanged: { coordinate, distance in
mapState.updateViewport(distanceMeters: distance)
let previousRevision = mapState.selection.revision
let revision = mapState.selectUserMapCenter(coordinate)
guard revision != previousRevision else { return }
let pair = CoordinatePair(
mapCoordinate: coordinate,
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem
)
LastCoordinateStore.save(
coordinatePair: pair,
zoomMeters: mapState.viewportMeters
)
favorites.select(nil)
scheduleGeocode(coordinate: pair.wgs84.coordinate, revision: revision)
},
onViewportChanged: { distance in
mapState.updateViewport(distanceMeters: distance)
},
onMapTap: { coordinate in
favorites.select(nil)
let revision = mapState.selectMapTap(coordinate)
let pair = CoordinatePair(
mapCoordinate: coordinate,
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem
)
LastCoordinateStore.save(
coordinatePair: pair,
zoomMeters: mapState.viewportMeters
)
scheduleGeocode(coordinate: pair.wgs84.coordinate, revision: revision)
},
onUserZoomChanged: { distance in
ViewportStore.save(distance)
LastCoordinateStore.updateZoom(distance)
},
onZoomIn: { mapState.zoom(by: 0.5) },
onZoomOut: { mapState.zoom(by: 2) }
)
.ignoresSafeArea(.container)
VStack(spacing: 10) {
topControls
if !searchResults.isEmpty || !searchError.isEmpty { searchResultList }
Spacer()
// 右下角按钮
HStack {
Spacer()
VStack(spacing: 12) {
Button {
if let url = URL(string: "maps://app") {
UIApplication.shared.open(url)
}
} label: {
Image(systemName: "map.fill")
.font(.system(size: 18, weight: .semibold))
.frame(width: 44, height: 44)
.background(.regularMaterial, in: Circle())
.shadow(color: .black.opacity(0.2), radius: 6, y: 3)
}
Button {
requestRealtimeLocation()
} label: {
if realtime.isRequesting {
ProgressView()
.frame(width: 44, height: 44)
.background(.regularMaterial, in: Circle())
.shadow(color: .black.opacity(0.2), radius: 6, y: 3)
} else {
Image(systemName: "location.fill")
.font(.system(size: 20, weight: .semibold))
.frame(width: 44, height: 44)
.background(.regularMaterial, in: Circle())
.shadow(color: .black.opacity(0.2), radius: 6, y: 3)
}
}
.disabled(realtimeRequestTask != nil || realtime.isRequesting)
}
}
.padding(.trailing, 16)
.padding(.bottom, 8)
bottomControls
}
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 12)
}
.navigationBarHidden(true)
.sheet(item: $activeSheet) { sheet in
NavigationView {
switch sheet {
case .settings: SettingsView(setup: setup, actions: actions)
case .logs: RuntimeLogsView(setup: setup, actions: actions, testFavorite: testFavorite)
}
}
}
.sheet(item: $activeTip) { kind in
TipSheetView(kind: kind)
}
.alert("无法直接跳转", isPresented: Binding(
get: { !manualHint.isEmpty },
set: { if !$0 { manualHint = "" } }
)) {
Button("知道了", role: .cancel) {}
} message: { Text(manualHint) }
.onAppear {
startMapRuntimeOnce()
// ContentView performs the startup environment test before this map
// view is constructed. Do not immediately run it again here.
registerWiFiChangeObserver()
}
.onDisappear {
if let token = wifiChangeObserverToken {
net.removeWiFiChangeObserver(token)
wifiChangeObserverToken = nil
}
wifiVerificationTask?.cancel()
wifiVerificationTask = nil
wifiVerificationID = nil
}
.onChange(of: proxy.isRunning) { running in
if !running && spoofState == .active {
spoofState = .idle
actions.clear()
}
}
.sheet(isPresented: $showEnableTip) { enableTipSheet }
.sheet(isPresented: $showDisableTip) { disableTipSheet }
.alert("定位失败", isPresented: $showLocationAlert) {
Button("打开设置") {
openSettings(.locationServices)
}
Button("知道了", role: .cancel) {}
} message: {
Text("无法获取当前定位,请检查定位服务是否已开启")
}
.alert("编辑收藏名称", isPresented: Binding(
get: { editingFavorite != nil },
set: { if !$0 { editingFavorite = nil } }
)) {
TextField("名称", text: $editName)
Button("保存") {
if let f = editingFavorite {
let name = editName.trimmingCharacters(in: .whitespacesAndNewlines)
let finalName = name.isEmpty ? f.name : name
favorites.rename(f.id, to: finalName)
mapState.updateExplicitName(finalName, forFavoriteID: f.id)
}
editingFavorite = nil
}
Button("取消", role: .cancel) { editingFavorite = nil }
} message: { Text("修改收藏地点名称") }
}
private var topControls: some View {
HStack(spacing: 10) {
HStack(spacing: 10) {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField("搜索地点或坐标", text: $searchText)
.textInputAutocapitalization(.never).autocorrectionDisabled().submitLabel(.search).onSubmit(doSearch)
if isSearching { ProgressView().controlSize(.small) }
else if !searchText.isEmpty {
Button {
searchRequestID &+= 1
isSearching = false
searchText = ""
searchResults = []
searchError = ""
} label: {
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
}.buttonStyle(.plain)
}
Button(action: doSearch) { Image(systemName: "arrow.right.circle.fill").font(.title3) }
.disabled(searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSearching)
}
.padding(.horizontal, 14).frame(height: 48)
.background(.regularMaterial, in: Capsule())
.shadow(color: .black.opacity(0.13), radius: 9, y: 4)
Menu {
Button { activeSheet = .logs } label: { Label("日志", systemImage: "list.bullet.rectangle") }
Button { activeSheet = .settings } label: { Label("设置", systemImage: "gearshape") }
} label: {
Image(systemName: "ellipsis").font(.system(size: 20, weight: .bold))
.frame(width: 48, height: 48)
.background(.regularMaterial, in: Circle())
.shadow(color: .black.opacity(0.13), radius: 9, y: 4)
.contentShape(Circle())
}.accessibilityLabel("更多")
}
}
// 搜索列表:动态高度,不写死
private var searchResultList: some View {
VStack(spacing: 0) {
if !searchError.isEmpty {
Text(searchError).font(.footnote).foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading).padding(12)
}
ForEach(searchResults) { r in
HStack(spacing: 8) {
Button { selectSearchResult(r) } label: {
HStack(spacing: 10) {
Image(systemName: "mappin.and.ellipse")
.foregroundStyle(.red)
VStack(alignment: .leading, spacing: 3) {
Text(r.name).font(.subheadline.weight(.semibold)).lineLimit(1)
if !r.subtitle.isEmpty { Text(r.subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.contentShape(Rectangle())
}.buttonStyle(.plain)
Button(role: .destructive) { deleteSearchResult(r) } label: {
Image(systemName: "trash").frame(width: 36, height: 36).contentShape(Rectangle())
}.buttonStyle(.plain).foregroundStyle(.red)
}
.padding(.horizontal, 12).padding(.vertical, 10)
if r.id != searchResults.last?.id { Divider().padding(.leading, 46) }
}
}
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.shadow(color: .black.opacity(0.12), radius: 8, y: 4)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
// 底部:当前选点 + 收藏 + 主控按钮
private var bottomControls: some View {
VStack(alignment: .leading, spacing: 12) {
// 当前选点
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(mapState.displayName ?? "当前选点").font(.subheadline.weight(.semibold)).lineLimit(1)
Text(String(format: "%.6f, %.6f", mapState.selection.coordinate.latitude, mapState.selection.coordinate.longitude))
.font(.caption.monospaced())
.foregroundStyle(copyConfirmed ? .green : .secondary)
.onTapGesture {
let text = String(format: "%.6f, %.6f", mapState.selection.coordinate.latitude, mapState.selection.coordinate.longitude)
UIPasteboard.general.string = text
RuntimeLogger.info("APP", "地图", "已复制坐标")
copyConfirmed = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copyConfirmed = false }
}
.overlay(alignment: .top) {
if copyConfirmed {
Text("已复制")
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(.green, in: Capsule())
.offset(y: -24)
}
}
}
Spacer()
// 帮助说明按钮
Button {
if actions.virtualLocationEnabled {
activeTip = .activation
} else {
activeTip = .deactivation
}
} label: {
Text(actions.virtualLocationEnabled ? "无法生效?" : "无法取消?")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.secondary.opacity(0.1), in: Capsule())
}
.buttonStyle(.plain)
// 收藏按钮
Button {
if favorites.selectedFavoriteID != nil {
favorites.select(nil)
return
}
let snapshot = currentSelectionFavorite
let favorite = favorites.save(
name: snapshot.name,
mapCoordinate: mapState.selection.coordinate,
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem,
accuracy: snapshot.accuracy
)
mapState.selectFavorite(
favorite.coordinatePair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem),
id: favorite.id,
name: favorite.name
)
} label: {
Image(systemName: favorites.selectedFavoriteID != nil ? "star.fill" : "star")
.font(.system(size: 18, weight: .semibold))
.frame(width: 38, height: 38)
.background((favorites.selectedFavoriteID != nil ? Color.yellow : Color.gray).opacity(0.18), in: Circle())
}
.buttonStyle(.plain)
.foregroundStyle(favorites.selectedFavoriteID != nil ? .orange : .gray)
.accessibilityLabel(favorites.selectedFavoriteID != nil ? "已收藏,点击取消收藏" : "收藏当前选点")
}
// 收藏
if favorites.favorites.isEmpty {
Text("搜索或点击地图选点后,保存为收藏。").font(.footnote).foregroundStyle(.secondary)
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) { ForEach(favorites.favorites) { f in favoriteChip(f) } }.padding(.vertical, 2)
}
}
// 主控按钮(带动画)
HStack(spacing: 10) {
Button(action: handleMainButtonTap) {
HStack(spacing: 6) {
if spoofState == .verifying {
ProgressView().tint(.white)
}
Text(spoofState == .active && needsSwitchButton ? "关闭" : buttonTitle)
.font(.headline).lineLimit(1)
}
.frame(maxWidth: needsSwitchButton ? nil : .infinity)
.frame(minWidth: needsSwitchButton ? 56 : nil)
.padding(.vertical, 14)
.padding(.horizontal, needsSwitchButton ? 12 : 14)
}
.background(buttonColor, in: RoundedRectangle(cornerRadius: 14))
.foregroundStyle(.white)
.disabled(spoofState == .verifying)
if needsSwitchButton {
Button {
beginLocationOperation()
} label: {
Label("切换到此处", systemImage: "arrow.triangle.swap")
.font(.body.weight(.medium)).lineLimit(1)
.frame(maxWidth: .infinity)
.padding(.vertical, 12).padding(.horizontal, 16)
}
.background(.blue, in: RoundedRectangle(cornerRadius: 14))
.foregroundStyle(.white)
.transition(.move(edge: .trailing).combined(with: .opacity))
}
}
.animation(.spring(response: 0.35, dampingFraction: 0.7), value: needsSwitchButton)
.padding(.top, 4)
}
.padding(16)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.shadow(color: .black.opacity(0.16), radius: 14, y: 6)
}
private var needsSwitchButton: Bool {
guard spoofState == .active,
let sLat = activeSpoofLat,
let sLon = activeSpoofLon else { return false }
return !currentSelectionFavorite.coordinatePair.matchesWGS84(
latitude: sLat,
longitude: sLon
)
}
private var buttonTitle: String {
switch spoofState {
case .idle: return "开始虚拟定位"
case .verifying: return "验证环境中…"
case .active: return "停止虚拟定位"
}
}
private var buttonColor: Color {
switch spoofState {
case .idle: return .blue
case .verifying: return .gray
case .active: return .green
}
}
private func handleMainButtonTap() {
switch spoofState {
case .idle:
beginLocationOperation()
case .active:
stopSpoofing()
case .verifying:
break
}
}
private func beginLocationOperation() {
guard spoofState != .verifying, locationOperationTask == nil else { return }
locationOperationID &+= 1
let operationID = locationOperationID
let selectionRevision = mapState.selection.revision
let target = currentSelectionFavorite
spoofState = .verifying
locationOperationTask = Task { @MainActor in
let result = await setup.runVerificationTest()
guard !Task.isCancelled,
operationID == locationOperationID,
selectionRevision == mapState.selection.revision else {
if operationID == locationOperationID {
spoofState = actions.virtualLocationEnabled ? .active : .idle
locationOperationTask = nil
}
return
}
if result.isSuccess {
let applied = actions.applyVerified(target)
spoofState = applied ? .active : .idle
if applied {
activeSpoofLat = target.latitude
activeSpoofLon = target.longitude
}
RuntimeLogger.info("APP", "定位", "验证结果", details: [
"success": "true",
"applied": String(applied),
"spoofState": String(describing: spoofState)
])
if applied {
let count = tipPreferences.recordSuccessfulOperation(.activation)
RuntimeLogger.info("APP", "提醒", "累计开启虚拟定位次数", details: [
"次数": String(count),
"可显示不再提醒": String(tipPreferences.canSuppress(.activation))
])
showEnableTip = tipPreferences.shouldPresentAutomaticTip(.activation)
}
} else {
spoofState = actions.virtualLocationEnabled ? .active : .idle
RuntimeLogger.warning("APP", "定位", "验证失败", details: [
"result": result.id,
"spoofState": String(describing: spoofState)
])
if result == .certNotTrusted {
RuntimeLogger.warning("APP", "定位", "开启前检测发现证书异常,进入证书安装引导")
activeTip = nil
setup.applyVerificationResult(result)
} else if let tip = result.tipKind {
activeTip = tip
}
}
locationOperationTask = nil
}
}
private func stopSpoofing() {
locationOperationTask?.cancel()
locationOperationTask = nil
locationOperationID &+= 1
actions.clear()
spoofState = .idle
activeSpoofLat = nil
activeSpoofLon = nil
let count = tipPreferences.recordSuccessfulOperation(.deactivation)
RuntimeLogger.info("APP", "提醒", "累计关闭虚拟定位次数", details: [
"次数": String(count),
"可显示不再提醒": String(tipPreferences.canSuppress(.deactivation))
])
showDisableTip = tipPreferences.shouldPresentAutomaticTip(.deactivation)
}
private func favoriteChip(_ f: FavoriteLocation) -> some View {
HStack(spacing: 0) {
Button { select(f) } label: {
Label(f.name, systemImage: favorites.selectedFavoriteID == f.id ? "checkmark.circle.fill" : "mappin")
.lineLimit(1).padding(.leading, 10).padding(.vertical, 8).padding(.trailing, 7).contentShape(Rectangle())
}.buttonStyle(.plain)
Divider().frame(height: 22)
Button {
editingFavorite = f
editName = f.name
} label: {
Image(systemName: "pencil").font(.caption2).frame(width: 32, height: 36).contentShape(Rectangle())
}.buttonStyle(.plain).foregroundStyle(.primary.opacity(0.55))
Divider().frame(height: 22)
Button(role: .destructive) { favorites.delete(f) } label: {
Image(systemName: "trash").font(.caption.weight(.semibold)).frame(width: 36, height: 36).contentShape(Rectangle())
}.buttonStyle(.plain).foregroundStyle(.red)
}
.background((favorites.selectedFavoriteID == f.id ? Color.red.opacity(0.14) : Color.secondary.opacity(0.12)), in: Capsule())
.overlay(Capsule().stroke(favorites.selectedFavoriteID == f.id ? Color.red.opacity(0.7) : Color.clear))
}
@MainActor
private func openSettings(_ destination: SystemSettingsDestination) {
SystemSettingsNavigator.open(destination) { fallbackHint in
if let fallbackHint { manualHint = fallbackHint }
}
}
private var currentSelectionFavorite: FavoriteLocation {
FavoriteLocation(
name: mapState.displayName ?? String(
format: "%.4f, %.4f",
mapState.selection.coordinate.latitude,
mapState.selection.coordinate.longitude
),
coordinatePair: .init(
mapCoordinate: mapState.selection.coordinate,
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem
),
accuracy: 25
)
}
private var testFavorite: FavoriteLocation { currentSelectionFavorite }
private func startMapRuntimeOnce() {
guard !mapRuntimeDidStart else { return }
mapRuntimeDidStart = true
let pair = LastCoordinateStore.load()?.coordinatePair
?? CoordinatePair(
mapCoordinate: mapState.selection.coordinate,
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem
)
scheduleGeocode(
coordinate: pair.wgs84.coordinate,
revision: mapState.selection.revision
)
}
private func reprojectMapSelection(for change: CoordinateConverter.MapCoordinateSystemChange) {
// Every current selection is persisted as a complete coordinate pair at
// its input boundary. Replaying the matching stored representation
// avoids a second GCJ/WGS conversion and its accumulated offset.
guard let stored = LastCoordinateStore.load() else {
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准切换时未找到当前选点缓存")
return
}
mapState.reprojectSelectionForMapCoordinateSystemChange(stored.coordinate(for: change.current))
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准切换后已使用缓存坐标对回显当前选点", details: [
"from": change.previous.rawValue,
"to": change.current.rawValue
])
}
private func registerWiFiChangeObserver() {
guard wifiChangeObserverToken == nil else { return }
wifiChangeObserverToken = net.observeWiFiChanges { [self] reason in
handleWiFiChange(reason: reason)
}
}
private func handleWiFiChange(reason: WiFiChangeReason) {
RuntimeLogger.info("APP", "WiFi", "检测到 Wi-Fi 网络变化", details: [
"原因": reason.rawValue,
"虚拟定位已开启": String(spoofState == .active)
])
guard spoofState == .active else { return }
if wifiVerificationTask != nil {
RuntimeLogger.info("APP", "WiFi", "网络仍在变化,重新计算环境检测等待时间")
}
wifiVerificationTask?.cancel()
let verificationID = UUID()
wifiVerificationID = verificationID
wifiVerificationTask = Task { @MainActor in
defer {
if wifiVerificationID == verificationID {
wifiVerificationTask = nil
wifiVerificationID = nil
}
}
let stabilizationNanoseconds: UInt64 = 5_000_000_000
RuntimeLogger.info("APP", "WiFi", "等待 Wi-Fi 连接稳定后检测", details: [
"等待秒数": "5",
"事件原因": reason.rawValue
])
do {
try await Task.sleep(nanoseconds: stabilizationNanoseconds)
} catch {
RuntimeLogger.debug("APP", "WiFi", "延时检测已被更新的网络事件取消")
return
}
guard !Task.isCancelled, spoofState == .active else { return }
guard net.isSatisfied, net.isWiFiEnabled else {
RuntimeLogger.warning("APP", "WiFi", "稳定等待结束后仍未连接 Wi-Fi,提示检查代理", details: [
"网络可用": String(net.isSatisfied),
"Wi-Fi接口": String(net.isWiFiEnabled)
])
activeTip = .proxySetup
return
}
// A manual diagnostics request can occupy the verifier for its full
// eight-second URL timeout. Wait long enough to run this check after
// it finishes instead of silently dropping the Wi-Fi-change check.
let maximumAttempts = 11
for attempt in 1...maximumAttempts {
RuntimeLogger.info("APP", "WiFi", "开始后台环境检测", details: [
"尝试": "\(attempt)/\(maximumAttempts)",
"SSID可读取": String(net.currentSSID != nil)
])
let result = await setup.runVerificationTest()
guard !Task.isCancelled, spoofState == .active else { return }
if result == .verificationInProgress, attempt < maximumAttempts {
RuntimeLogger.info("APP", "WiFi", "已有环境检测运行,1 秒后重试", details: [
"尝试": "\(attempt)/\(maximumAttempts)"
])
do {
try await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
return
}
continue
}
if result == .certNotTrusted {
RuntimeLogger.warning("APP", "WiFi", "后台环境检测发现证书异常,进入证书安装引导")
activeTip = nil
setup.applyVerificationResult(result)
return
}
let tip = result.wifiChangeReminderTipKind
RuntimeLogger.info("APP", "WiFi", "后台环境检测完成", details: [
"结果": result.id,
"success": String(result.isSuccess),
"提示": tip?.rawValue ?? "无"
])
if !result.isSuccess, let tip {
activeTip = tip
} else if result == .verificationInProgress {
RuntimeLogger.warning("APP", "WiFi", "环境检测连续被占用,本次不重复弹窗")
}
return
}
}
}
private func requestRealtimeLocation() {
let intent = mapState.beginRealtimeIntent()
RuntimeLogger.info("APP", "实时定位", "用户点击实时定位", details: [
"intentID": String(intent.id),
"选点revision": String(intent.selectionRevision),
"当前地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue,
"蓝点缓存存在": String(mapState.realtimeLocation != nil),
"CLLocationManager请求中": String(realtime.isRequesting)
])
// 蓝点存在就直接用,不限时(MKMapView 的 userLocation 只在位置变化时才更新)
if let loc = mapState.realtimeLocation {
RealtimeLocationTrace.log("实时定位按钮直接使用 MapKit 蓝点缓存", location: loc, details: [
"intentID": String(intent.id),
"来源": "MapLocationState.realtimeLocation"
])
acceptRealtimeLocation(loc.coordinate, intent: intent, source: "MapKit蓝点")
return
}
RuntimeLogger.info("APP", "实时定位", "MapKit 蓝点尚不可用,启动 CLLocationManager 兜底", details: [
"intentID": String(intent.id)
])
startRealtimeLocationRequest(
source: "CLLocationManager",
showFailureAlert: true,
intent: intent
)
}
private func handleNativeRealtimeLocation(_ location: CLLocation) {
RealtimeLocationTrace.log("主页收到 MapKit 蓝点回调", location: location, details: [
"存在待处理请求": String(realtimeRequestContext != nil),
"当前选点revision": String(mapState.selection.revision)
])
mapState.updateRealtimeLocation(location)
guard let context = realtimeRequestContext else {
RuntimeLogger.debug("APP", "实时定位", "蓝点已缓存;当前没有待处理的实时定位意图")
return
}
// MKMapView's MKUserLocation is the visible blue point. When it arrives,
// fulfill the pending intent from that exact sample and cancel the slower
// CLLocationManager fallback so the camera and dot cannot disagree.
realtimeRequestContext = nil
realtimeRequestTask?.cancel()
RuntimeLogger.info("APP", "实时定位", "MapKit 蓝点抢先完成请求,取消 CLLocationManager 兜底", details: [
"intentID": String(context.intent.id),
"原兜底来源": context.source
])
acceptRealtimeLocation(location.coordinate, intent: context.intent, source: "蓝点(途中)→\(context.source)")
}
private func startRealtimeLocationRequest(
source: String,
showFailureAlert: Bool,
intent suppliedIntent: RealtimeLocationIntent? = nil
) {
let intent = suppliedIntent ?? mapState.beginRealtimeIntent()
realtimeRequestContext = RealtimeLocationRequestContext(
intent: intent,
source: source,
showFailureAlert: showFailureAlert
)
RuntimeLogger.info("APP", "实时定位", "登记实时定位请求上下文", details: [
"intentID": String(intent.id),
"选点revision": String(intent.selectionRevision),
"来源": source,
"失败时提示": String(showFailureAlert),
"任务已存在": String(realtimeRequestTask != nil),
"manager请求中": String(realtime.isRequesting)
])
// A button tap during the startup request retargets that same in-flight
// Core Location request to the newer intent instead of being ignored.
guard realtimeRequestTask == nil, !realtime.isRequesting else {
RuntimeLogger.info("APP", "实时定位", "复用进行中的 CLLocationManager 请求并更新意图上下文", details: [
"intentID": String(intent.id)
])
return
}
realtimeRequestTask = Task { @MainActor in
defer {
realtimeRequestTask = nil
realtimeRequestContext = nil
}
guard let coordinate = await realtime.requestLocation() else {
RuntimeLogger.warning("APP", "实时定位", "CLLocationManager 兜底未返回坐标", details: [
"授权状态rawValue": String(realtime.authorizationStatus.rawValue),
"任务已取消": String(Task.isCancelled),
"上下文存在": String(realtimeRequestContext != nil)
])
guard let context = realtimeRequestContext,
context.showFailureAlert,
!Task.isCancelled,
mapState.selection.revision == context.intent.selectionRevision else { return }
RuntimeLogger.info("APP", "地图", "定位失败 status=\(realtime.authorizationStatus.rawValue)")
showLocationAlert = true
return
}
RealtimeLocationTrace.coordinate("主页收到 CLLocationManager 兜底坐标", coordinate: coordinate, details: [
"任务已取消": String(Task.isCancelled)
])
guard !Task.isCancelled, let context = realtimeRequestContext else {
RuntimeLogger.info("APP", "实时定位", "丢弃 CLLocationManager 结果:任务已取消或蓝点已抢先完成", details: [
"任务已取消": String(Task.isCancelled),
"上下文存在": String(realtimeRequestContext != nil)
])
return
}
acceptRealtimeLocation(coordinate, intent: context.intent, source: context.source)
}
}
private func acceptRealtimeLocation(
_ coordinate: CLLocationCoordinate2D,
intent: RealtimeLocationIntent,
source: String
) {
let currentViewport = mapState.viewportMeters
let previousMapCoordinateSystem = CoordinateConverter.currentMapCoordinateSystem
let usesDomesticStandard = CoordinateConverter.usesGCJ02ServiceArea(
lat: coordinate.latitude,
lon: coordinate.longitude
)
let mapCoordinateSystemChange = CoordinateConverter.correctMapCoordinateSystemUsingRealtime(coordinate)
if let change = mapCoordinateSystemChange {
reprojectMapSelection(for: change)
}
let pair = CoordinateConverter.coordinatePair(
lat: coordinate.latitude,
lon: coordinate.longitude,
mapCoordinateSystem: .wgs84
)
let accepted = mapState.acceptRealtimeLocation(
pair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem),
intent: intent
)
RuntimeLogger.info("APP", "实时定位", "实时定位坐标完成标准判断并提交到地图", details: [
"来源": source,
"intentID": String(intent.id),
"intent选点revision": String(intent.selectionRevision),
"当前选点revision": String(mapState.selection.revision),
"服务区域使用GCJ": String(usesDomesticStandard),
"修正前地图标准": previousMapCoordinateSystem.rawValue,
"修正后地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue,
"地图标准发生修正": String(mapCoordinateSystemChange != nil),
"accepted": String(accepted),
"显示坐标字段": CoordinateConverter.currentMapCoordinateSystem.rawValue,
"持久化字段": "WGS-84+GCJ-02"
])
RealtimeLocationTrace.coordinate("原始实时定位坐标(按 WGS-84", coordinate: coordinate, details: [
"来源": source
])
RealtimeLocationTrace.coordinate("地图实际显示坐标", coordinate: pair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem), details: [
"地图标准": CoordinateConverter.currentMapCoordinateSystem.rawValue
])
guard accepted else { return }
// 用点击时的缩放级别居中,不改变缩放
mapState.focusSelection(distanceMeters: currentViewport)
// Core Location is WGS-84; persist both forms once and render the active form.
LastCoordinateStore.save(coordinatePair: pair, zoomMeters: currentViewport)
favorites.select(nil)
scheduleGeocode(coordinate: coordinate, revision: mapState.selection.revision)
}
private func scheduleGeocode(coordinate: CLLocationCoordinate2D, revision: UInt64) {
geocodeDebounceTask?.cancel()
reverseGeocodeTask?.cancel()
geocodeDebounceTask = Task { @MainActor in
do {
try await Task.sleep(nanoseconds: 300_000_000)
} catch {
return
}
guard !Task.isCancelled, mapState.selection.revision == revision else { return }
reverseGeocode(coordinate, revision: revision)
}
}
private func reverseGeocode(_ coordinate: CLLocationCoordinate2D, revision: UInt64) {
reverseGeocodeTask?.cancel()
let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
reverseGeocodeTask = Task { @MainActor in
let retryDelays: [UInt64] = [0, 800_000_000, 1_600_000_000]
var lastError: Error?
for (attempt, delay) in retryDelays.enumerated() {
if delay > 0 {
do { try await Task.sleep(nanoseconds: delay) }
catch { return }
}
guard !Task.isCancelled, mapState.selection.revision == revision else { return }
do {
// 并⾏获取:CLGeocoder(地址结构化) + MKLocalSearch(地图显⽰名称)
// MKLocalSearch 在无结果时抛错,不可与 CLGeocoder 共用 try await 导致互相影响
async let clPlacemarks = CLGeocoder().reverseGeocodeLocation(location)
let mkRequest = MKLocalSearch.Request()
mkRequest.region = MKCoordinateRegion(center: coordinate, latitudinalMeters: 400, longitudinalMeters: 400)
let mkResponse = try? await MKLocalSearch(request: mkRequest).start()
let placemarks = try await clPlacemarks
guard !Task.isCancelled,
mapState.selection.revision == revision,
let placemark = placemarks.first else { return }
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
// 优先使⽤ MKLocalSearch 结果(与地图显⽰一致),CLGeocoder 作为 fallback
let poi = { () -> String? in
if let v = mapItemPOI?.trimmingCharacters(in: .whitespacesAndNewlines), !v.isEmpty { return v }
if let v = mapItemName?.trimmingCharacters(in: .whitespacesAndNewlines), !v.isEmpty { return v }
return placemark.areasOfInterest?.first ?? placemark.name
}()
let streetAddress = [placemark.thoroughfare, placemark.subThoroughfare]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: " ")
let descriptor = MapPlaceDescriptor(
pointOfInterest: poi,
streetAddress: streetAddress,
road: placemark.thoroughfare,
neighborhood: placemark.subLocality,
district: placemark.subLocality ?? placemark.subAdministrativeArea,
city: placemark.locality ?? placemark.subAdministrativeArea,
province: placemark.administrativeArea,
country: placemark.country
)
_ = mapState.acceptPlaceDescriptor(descriptor, selectionRevision: revision)
return
} catch {
guard !Task.isCancelled, mapState.selection.revision == revision else { return }
lastError = error
let nsError = error as NSError
let isNetworkError = nsError.domain == kCLErrorDomain
&& nsError.code == CLError.network.rawValue
guard isNetworkError, attempt < retryDelays.count - 1 else { break }
RuntimeLogger.info("APP", "Geocode", "反向地理编码网络失败,准备重试", details: [
"attempt": String(attempt + 1),
"revision": String(revision)
])
}
}
guard !Task.isCancelled,
mapState.selection.revision == revision,
let lastError else { return }
RuntimeLogger.warning("APP", "Geocode", "反向地理编码失败", details: [
"error": lastError.localizedDescription,
"revision": String(revision)
])
}
}
private func doSearch() {
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty, !isSearching else { return }
searchRequestID &+= 1
let requestID = searchRequestID
isSearching = true
searchError = ""
let request = MKLocalSearch.Request()
request.naturalLanguageQuery = query
MKLocalSearch(request: request).start { response, error in
DispatchQueue.main.async {
guard requestID == searchRequestID else { return }
isSearching = false
if let error {
searchResults = []
searchError = error.localizedDescription
return
}
searchResults = (response?.mapItems ?? []).prefix(6).map { item in
let r = SearchLocationResult(
name: item.name ?? "未命名",
subtitle: [item.placemark.locality, item.placemark.subLocality, item.placemark.thoroughfare]
.compactMap { $0 }
.filter { !$0.isEmpty }
.joined(separator: " · "),
coordinate: item.placemark.coordinate
)
RuntimeLogger.info("APP", "搜索", "获得搜索结果", details: [
"名称": r.name
])
return r
}
if searchResults.isEmpty { searchError = "没有找到相关地点" }
}
}
}
private func selectSearchResult(_ result: SearchLocationResult) {
geocodeDebounceTask?.cancel()
reverseGeocodeTask?.cancel()
favorites.select(nil)
mapState.selectSearchResult(result.coordinate, name: result.name)
LastCoordinateStore.save(
mapCoordinate: result.coordinate,
mapCoordinateSystem: CoordinateConverter.currentMapCoordinateSystem,
zoomMeters: mapState.viewportMeters
)
searchText = result.name
searchResults = []
searchError = ""
}
private func deleteSearchResult(_ result: SearchLocationResult) {
searchResults.removeAll { $0.id == result.id }
}
private func select(_ favorite: FavoriteLocation) {
geocodeDebounceTask?.cancel()
reverseGeocodeTask?.cancel()
favorites.select(favorite.id)
mapState.selectFavorite(
favorite.coordinatePair.coordinate(for: CoordinateConverter.currentMapCoordinateSystem),
id: favorite.id,
name: favorite.name
)
LastCoordinateStore.save(
coordinatePair: favorite.coordinatePair,
zoomMeters: mapState.viewportMeters
)
}
private var enableTipSheet: some View {
NavigationView {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
ActivationTipContent(dismiss: {})
}.padding(16)
}
.navigationTitle("虚拟定位已开启").navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .bottom) {
HStack(spacing: 12) {
if tipPreferences.canSuppress(.activation) {
Button {
tipPreferences.suppress(.activation)
showEnableTip = false
} label: {
Label("不再提醒", systemImage: "bell.slash.fill")
.font(.body.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.orange)
Button { showEnableTip = false } label: {
Text("知道了")
.font(.body.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.bordered)
.tint(.blue)
} else {
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)
}
}
}
private var disableTipSheet: some View {
NavigationView {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
DeactivationTipContent(dismiss: {})
RemoveProxyTipContent(dismiss: {})
}.padding(16)
}
.navigationTitle("虚拟定位已关闭").navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .bottom) {
HStack(spacing: 12) {
if tipPreferences.canSuppress(.deactivation) {
Button {
tipPreferences.suppress(.deactivation)
showDisableTip = false
} label: {
Label("不再提醒", systemImage: "bell.slash.fill")
.font(.body.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.orange)
Button { showDisableTip = false } label: {
Text("知道了")
.font(.body.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.bordered)
.tint(.blue)
} else {
Button { showDisableTip = false } label: {
Text("知道了")
.font(.body.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
}
}.padding(.horizontal, 16).padding(.bottom, 8)
}
}
}
}