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