mirror of
https://github.com/xweiba/location-spoofer.git
synced 2026-09-21 22:30:46 +08:00
fix: harden startup proxy and coordinate handling
This commit is contained in:
+28
-13
@@ -4,25 +4,25 @@ 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 phase: AppPhase = .splash
|
||||||
@AppStorage("setupCompleted") private var setupCompleted = false
|
@AppStorage("setupCompleted") private var setupCompleted = false
|
||||||
|
|
||||||
|
enum AppPhase { case splash, map }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
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 {
|
NavigationView {
|
||||||
MapHomeView(setup: setup)
|
MapHomeView(setup: setup)
|
||||||
}
|
}
|
||||||
.task {
|
|
||||||
if !setupCompleted {
|
|
||||||
showSetup = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await setup.refreshTrust()
|
|
||||||
}
|
|
||||||
.onChange(of: net.isAirplaneMode) { airplane in
|
|
||||||
guard setupCompleted else { return }
|
|
||||||
if !airplane {
|
|
||||||
Task { await setup.refreshTrust() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.fullScreenCover(isPresented: $showSetup) {
|
.fullScreenCover(isPresented: $showSetup) {
|
||||||
FirstSetupView(setup: setup, onComplete: {
|
FirstSetupView(setup: setup, onComplete: {
|
||||||
setupCompleted = true
|
setupCompleted = true
|
||||||
@@ -31,4 +31,19 @@ struct ContentView: View {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
if !setup.proxy.isRunning {
|
||||||
|
do {
|
||||||
|
try await setup.proxy.start()
|
||||||
|
} catch {
|
||||||
|
RuntimeLogger.error("APP", "Startup", "代理启动失败,将在设置检测中重试", error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,20 +15,37 @@ struct RuntimeLogsView: View {
|
|||||||
@State private var copiedEntryID: UUID?
|
@State private var copiedEntryID: UUID?
|
||||||
@State private var copyLogsConfirmed = false
|
@State private var copyLogsConfirmed = false
|
||||||
@State private var testLogCopied = 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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
testPanel
|
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()
|
Divider()
|
||||||
if entries.isEmpty {
|
if filteredEntries.isEmpty {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Image(systemName: "doc.text.magnifyingglass").font(.largeTitle).foregroundStyle(.secondary)
|
Image(systemName: "doc.text.magnifyingglass").font(.largeTitle).foregroundStyle(.secondary)
|
||||||
Text("暂无运行日志").foregroundStyle(.secondary)
|
Text(entries.isEmpty ? "暂无运行日志" : "无匹配日志").foregroundStyle(.secondary)
|
||||||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 10) {
|
LazyVStack(alignment: .leading, spacing: 10) {
|
||||||
ForEach(entries.reversed()) { entry in logRow(entry) }
|
ForEach(filteredEntries.reversed()) { entry in logRow(entry) }
|
||||||
}.padding(12)
|
}.padding(12)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,9 +70,8 @@ final class LocationActionCoordinator: ObservableObject {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
accuracy: favorite.accuracy
|
accuracy: favorite.accuracy
|
||||||
)
|
)
|
||||||
RuntimeLogger.info("APP", "坐标转换", "写入代理: WGS-84", details: [
|
RuntimeLogger.info("APP", "坐标转换", "已向代理写入 WGS-84 坐标", details: [
|
||||||
"lat": String(wgs.lat),
|
"accuracy": String(favorite.accuracy)
|
||||||
"lon": String(wgs.lon)
|
|
||||||
])
|
])
|
||||||
state = .idle
|
state = .idle
|
||||||
virtualLocationEnabled = true
|
virtualLocationEnabled = true
|
||||||
|
|||||||
+117
-40
@@ -45,7 +45,6 @@ struct MapHomeView: View {
|
|||||||
@State private var showDisableTip = false
|
@State private var showDisableTip = false
|
||||||
@State private var activeTip: TipKind?
|
@State private var activeTip: TipKind?
|
||||||
@State private var manualHint = ""
|
@State private var manualHint = ""
|
||||||
@AppStorage("activationTipCount") private var activationTipCount = 0
|
|
||||||
@AppStorage("activationTipDisabled") private var activationTipDisabled = false
|
@AppStorage("activationTipDisabled") private var activationTipDisabled = false
|
||||||
@State private var editingFavorite: FavoriteLocation?
|
@State private var editingFavorite: FavoriteLocation?
|
||||||
@State private var editName = ""
|
@State private var editName = ""
|
||||||
@@ -54,6 +53,11 @@ struct MapHomeView: View {
|
|||||||
@State private var showLocationAlert = false
|
@State private var showLocationAlert = false
|
||||||
@State private var realtimeRequestTask: Task<Void, Never>?
|
@State private var realtimeRequestTask: Task<Void, Never>?
|
||||||
@State private var realtimeRequestContext: RealtimeLocationRequestContext?
|
@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 tileProbeTask: Task<Void, Never>?
|
||||||
|
@State private var tileProbeID: UUID?
|
||||||
@State private var copyConfirmed = false
|
@State private var copyConfirmed = false
|
||||||
@State private var spoofState: SpoofState = .idle
|
@State private var spoofState: SpoofState = .idle
|
||||||
@State private var locationOperationTask: Task<Void, Never>?
|
@State private var locationOperationTask: Task<Void, Never>?
|
||||||
@@ -66,14 +70,17 @@ 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()
|
||||||
// 持久化存的是 WGS-84,直接转当前瓦片坐标系显示
|
|
||||||
let initialCoord: CLLocationCoordinate2D
|
let initialCoord: CLLocationCoordinate2D
|
||||||
if let coord = savedCoord?.coordinate {
|
if let saved = savedCoord {
|
||||||
let display = CoordinateConverter.toDisplay(lat: coord.latitude, lon: coord.longitude)
|
let display = CoordinateConverter.toDisplay(lat: saved.coordinate.latitude, lon: saved.coordinate.longitude)
|
||||||
initialCoord = CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon)
|
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)
|
||||||
}
|
}
|
||||||
|
RuntimeLogger.info("APP", "地图", "初始化", details: [
|
||||||
|
"zoom": String(initialZoom),
|
||||||
|
"有缓存": String(savedCoord != nil)
|
||||||
|
])
|
||||||
_mapState = StateObject(wrappedValue: MapLocationState(
|
_mapState = StateObject(wrappedValue: MapLocationState(
|
||||||
initialCoordinate: initialCoord,
|
initialCoordinate: initialCoord,
|
||||||
initialViewportMeters: initialZoom
|
initialViewportMeters: initialZoom
|
||||||
@@ -90,7 +97,7 @@ struct MapHomeView: View {
|
|||||||
},
|
},
|
||||||
onUserCenterChanged: { coordinate, distance in
|
onUserCenterChanged: { coordinate, distance in
|
||||||
mapState.updateViewport(distanceMeters: distance)
|
mapState.updateViewport(distanceMeters: distance)
|
||||||
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 }
|
||||||
let wgs = CoordinateConverter.toStored(lat: coordinate.latitude, lon: coordinate.longitude)
|
let wgs = CoordinateConverter.toStored(lat: coordinate.latitude, lon: coordinate.longitude)
|
||||||
@@ -103,7 +110,7 @@ let previousRevision = mapState.selection.revision
|
|||||||
},
|
},
|
||||||
onMapTap: { coordinate in
|
onMapTap: { coordinate in
|
||||||
favorites.select(nil)
|
favorites.select(nil)
|
||||||
let revision = mapState.selectMapTap(coordinate)
|
let revision = mapState.selectMapTap(coordinate)
|
||||||
let wgs = CoordinateConverter.toStored(lat: coordinate.latitude, lon: coordinate.longitude)
|
let wgs = CoordinateConverter.toStored(lat: coordinate.latitude, lon: coordinate.longitude)
|
||||||
LastCoordinateStore.save(lat: wgs.lat, lon: wgs.lon)
|
LastCoordinateStore.save(lat: wgs.lat, lon: wgs.lon)
|
||||||
scheduleGeocode(coordinate: coordinate, revision: revision)
|
scheduleGeocode(coordinate: coordinate, revision: revision)
|
||||||
@@ -182,18 +189,21 @@ let revision = mapState.selectMapTap(coordinate)
|
|||||||
} message: { Text(manualHint) }
|
} message: { Text(manualHint) }
|
||||||
.onAppear {
|
.onAppear {
|
||||||
initializeMap()
|
initializeMap()
|
||||||
NetworkMonitor.shared.onWiFiChanged = { [self] in
|
Task { await setup.refreshTrust() }
|
||||||
guard spoofState == .active else { return }
|
startTileProbe()
|
||||||
Task { @MainActor in
|
registerWiFiChangeObserver()
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.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
|
.onChange(of: net.isAirplaneMode) { airplane in
|
||||||
if !airplane {
|
if !airplane {
|
||||||
@@ -318,7 +328,7 @@ let revision = mapState.selectMapTap(coordinate)
|
|||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
let text = String(format: "%.6f, %.6f", mapState.selection.coordinate.latitude, mapState.selection.coordinate.longitude)
|
let text = String(format: "%.6f, %.6f", mapState.selection.coordinate.latitude, mapState.selection.coordinate.longitude)
|
||||||
UIPasteboard.general.string = text
|
UIPasteboard.general.string = text
|
||||||
RuntimeLogger.info("APP", "地图", "复制坐标: \(text)")
|
RuntimeLogger.info("APP", "地图", "已复制坐标")
|
||||||
copyConfirmed = true
|
copyConfirmed = true
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copyConfirmed = false }
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copyConfirmed = false }
|
||||||
}
|
}
|
||||||
@@ -500,7 +510,6 @@ let revision = mapState.selectMapTap(coordinate)
|
|||||||
])
|
])
|
||||||
if applied && !activationTipDisabled {
|
if applied && !activationTipDisabled {
|
||||||
showEnableTip = true
|
showEnableTip = true
|
||||||
activationTipCount += 1
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
spoofState = actions.virtualLocationEnabled ? .active : .idle
|
spoofState = actions.virtualLocationEnabled ? .active : .idle
|
||||||
@@ -576,7 +585,6 @@ let revision = mapState.selectMapTap(coordinate)
|
|||||||
private func initializeMap() {
|
private func initializeMap() {
|
||||||
guard !mapDidInitialize else { return }
|
guard !mapDidInitialize else { return }
|
||||||
mapDidInitialize = true
|
mapDidInitialize = true
|
||||||
CoordinateConverter.detectTileByFixedGeocode()
|
|
||||||
|
|
||||||
if let selected = favorites.selectedFavorite {
|
if let selected = favorites.selectedFavorite {
|
||||||
let display = CoordinateConverter.toDisplay(lat: selected.latitude, lon: selected.longitude)
|
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() {
|
private func requestRealtimeLocation() {
|
||||||
let intent = mapState.beginRealtimeIntent()
|
let intent = mapState.beginRealtimeIntent()
|
||||||
|
startTileProbe()
|
||||||
// 蓝点存在就直接用,不限时(MKMapView 的 userLocation 只在位置变化时才更新)
|
// 蓝点存在就直接用,不限时(MKMapView 的 userLocation 只在位置变化时才更新)
|
||||||
if let loc = mapState.realtimeLocation {
|
if let loc = mapState.realtimeLocation {
|
||||||
acceptRealtimeLocation(loc.coordinate, intent: intent, source: "MapKit蓝点")
|
acceptRealtimeLocation(loc.coordinate, intent: intent, source: "MapKit蓝点")
|
||||||
@@ -669,9 +753,7 @@ let revision = mapState.selectMapTap(coordinate)
|
|||||||
let currentViewport = mapState.viewportMeters
|
let currentViewport = mapState.viewportMeters
|
||||||
let accepted = mapState.acceptRealtimeLocation(coordinate, intent: intent)
|
let accepted = mapState.acceptRealtimeLocation(coordinate, intent: intent)
|
||||||
RuntimeLogger.info("APP", "地图", "\(source)返回", details: [
|
RuntimeLogger.info("APP", "地图", "\(source)返回", details: [
|
||||||
"accepted": String(accepted),
|
"accepted": String(accepted)
|
||||||
"lat": String(coordinate.latitude),
|
|
||||||
"lon": String(coordinate.longitude)
|
|
||||||
])
|
])
|
||||||
guard accepted else { return }
|
guard accepted else { return }
|
||||||
// 用点击时的缩放级别居中,不改变缩放
|
// 用点击时的缩放级别居中,不改变缩放
|
||||||
@@ -723,12 +805,8 @@ let revision = mapState.selectMapTap(coordinate)
|
|||||||
mapState.selection.revision == revision,
|
mapState.selection.revision == revision,
|
||||||
let placemark = placemarks.first else { return }
|
let placemark = placemarks.first else { return }
|
||||||
|
|
||||||
if let firstItem = mkResponse?.mapItems.first {
|
if mkResponse?.mapItems.first != nil {
|
||||||
RuntimeLogger.info("APP", "Geocode", "MKLocalSearch 坐标对比", details: [
|
RuntimeLogger.info("APP", "Geocode", "MKLocalSearch 返回地点结果")
|
||||||
"输入坐标": "\(coordinate.latitude), \(coordinate.longitude)",
|
|
||||||
"搜索返回坐标": "\(firstItem.placemark.coordinate.latitude), \(firstItem.placemark.coordinate.longitude)",
|
|
||||||
"名称": firstItem.name ?? "nil"
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
let mapItemName = mkResponse?.mapItems.first?.name?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let mapItemName = mkResponse?.mapItems.first?.name?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let mapItemPOI = mkResponse?.mapItems.first?.placemark.areasOfInterest?.first
|
let mapItemPOI = mkResponse?.mapItems.first?.placemark.areasOfInterest?.first
|
||||||
@@ -805,9 +883,8 @@ let revision = mapState.selectMapTap(coordinate)
|
|||||||
.joined(separator: " · "),
|
.joined(separator: " · "),
|
||||||
coordinate: item.placemark.coordinate
|
coordinate: item.placemark.coordinate
|
||||||
)
|
)
|
||||||
RuntimeLogger.info("APP", "搜索", "结果: \(r.name)", details: [
|
RuntimeLogger.info("APP", "搜索", "获得搜索结果", details: [
|
||||||
"lat": String(r.coordinate.latitude),
|
"名称": r.name
|
||||||
"lon": String(r.coordinate.longitude)
|
|
||||||
])
|
])
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@@ -850,21 +927,21 @@ let revision = mapState.selectMapTap(coordinate)
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
ActivationTipContent(dismiss: {})
|
ActivationTipContent(dismiss: {})
|
||||||
if activationTipCount >= 3 {
|
}.padding(16)
|
||||||
|
}
|
||||||
|
.navigationTitle("虚拟定位已开启").navigationBarTitleDisplayMode(.inline)
|
||||||
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
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) {
|
Button(role: .destructive) {
|
||||||
activationTipDisabled = true
|
activationTipDisabled = true
|
||||||
showEnableTip = false
|
showEnableTip = false
|
||||||
} label: {
|
} label: {
|
||||||
Label("关闭不再弹出", systemImage: "bell.slash").frame(maxWidth: .infinity)
|
Label("关闭不再弹出", systemImage: "bell.slash").frame(maxWidth: .infinity)
|
||||||
}.buttonStyle(.bordered)
|
}.buttonStyle(.bordered)
|
||||||
}
|
}.padding(.horizontal, 16).padding(.bottom, 8)
|
||||||
}.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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,6 +231,20 @@ final class MapLocationState: ObservableObject {
|
|||||||
viewportMeters = max(50, distanceMeters)
|
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
|
@discardableResult
|
||||||
func acceptPlaceDescriptor(_ descriptor: MapPlaceDescriptor, selectionRevision: UInt64) -> Bool {
|
func acceptPlaceDescriptor(_ descriptor: MapPlaceDescriptor, selectionRevision: UInt64) -> Bool {
|
||||||
guard selection.revision == selectionRevision, selection.explicitName == nil else { return false }
|
guard selection.revision == selectionRevision, selection.explicitName == nil else { return false }
|
||||||
@@ -292,17 +306,21 @@ enum ViewportStore {
|
|||||||
private static let key = "mapViewportMeters"
|
private static let key = "mapViewportMeters"
|
||||||
static func save(_ meters: CLLocationDistance) {
|
static func save(_ meters: CLLocationDistance) {
|
||||||
UserDefaults.standard.set(meters, forKey: key)
|
UserDefaults.standard.set(meters, forKey: key)
|
||||||
|
RuntimeLogger.info("APP", "缩放", "存储缩放", details: ["zoom": String(meters)])
|
||||||
}
|
}
|
||||||
/// 取持久化缩放值;未存过返回 nil
|
/// 取持久化缩放值;未存过返回 nil
|
||||||
static func load() -> CLLocationDistance? {
|
static func load() -> CLLocationDistance? {
|
||||||
let v = UserDefaults.standard.double(forKey: key)
|
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 并立即存储
|
/// 取持久化缩放值,取不到返回默认 1km 并立即存储
|
||||||
static func loadOrDefault() -> CLLocationDistance {
|
static func loadOrDefault() -> CLLocationDistance {
|
||||||
if let v = load() { return v }
|
if let v = load() { return v }
|
||||||
let fallback: CLLocationDistance = 1_000
|
let fallback: CLLocationDistance = 1_000
|
||||||
save(fallback)
|
save(fallback)
|
||||||
|
RuntimeLogger.info("APP", "缩放", "使用默认缩放 1km")
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ struct MapViewRepresentable: UIViewRepresentable {
|
|||||||
map.delegate = context.coordinator
|
map.delegate = context.coordinator
|
||||||
map.showsUserLocation = true
|
map.showsUserLocation = true
|
||||||
let initialDistance = ViewportStore.loadOrDefault()
|
let initialDistance = ViewportStore.loadOrDefault()
|
||||||
|
RuntimeLogger.info("APP", "地图", "makeUIView", details: [
|
||||||
|
"zoom": String(initialDistance)
|
||||||
|
])
|
||||||
map.setRegion(
|
map.setRegion(
|
||||||
MKCoordinateRegion(
|
MKCoordinateRegion(
|
||||||
center: selection.coordinate,
|
center: selection.coordinate,
|
||||||
@@ -156,6 +159,11 @@ struct MapViewRepresentable: UIViewRepresentable {
|
|||||||
private let pinSize: CGFloat = 38
|
private let pinSize: CGFloat = 38
|
||||||
// 蓝点实际大小从 MKUserLocationView 取,默认 20pt
|
// 蓝点实际大小从 MKUserLocationView 取,默认 20pt
|
||||||
private var userDotDiameter: CGFloat = 20
|
private var userDotDiameter: CGFloat = 20
|
||||||
|
private var keyboardObserverTokens: [NSObjectProtocol] = []
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
keyboardObserverTokens.forEach(NotificationCenter.default.removeObserver)
|
||||||
|
}
|
||||||
|
|
||||||
@objc func zoomInTapped() { parent.onZoomIn?() }
|
@objc func zoomInTapped() { parent.onZoomIn?() }
|
||||||
@objc func zoomOutTapped() { parent.onZoomOut?() }
|
@objc func zoomOutTapped() { parent.onZoomOut?() }
|
||||||
@@ -175,15 +183,16 @@ struct MapViewRepresentable: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setupKeyboardObservers() {
|
func setupKeyboardObservers() {
|
||||||
|
guard keyboardObserverTokens.isEmpty else { return }
|
||||||
let nc = NotificationCenter.default
|
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 }
|
guard let self, let map = self.map else { return }
|
||||||
self.updatePinPosition(on: map)
|
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 }
|
guard let self, let map = self.map else { return }
|
||||||
self.updatePinPosition(on: map)
|
self.updatePinPosition(on: map)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func consume(_ command: MapCameraCommand?, on map: MKMapView) {
|
func consume(_ command: MapCameraCommand?, on map: MKMapView) {
|
||||||
@@ -197,8 +206,7 @@ struct MapViewRepresentable: UIViewRepresentable {
|
|||||||
let region: MKCoordinateRegion
|
let region: MKCoordinateRegion
|
||||||
switch command.kind {
|
switch command.kind {
|
||||||
case let .focus(coordinate, _):
|
case let .focus(coordinate, _):
|
||||||
// 保持当前 span 不变,只移动中心点,避免 latitudinalMeters
|
// 保持当前 span 不变,避免正方形 region 在竖屏 inflate
|
||||||
// 与 visibleVerticalDistance 之间因屏幕宽高比引入 2x 漂移
|
|
||||||
region = MKCoordinateRegion(center: coordinate, span: map.region.span)
|
region = MKCoordinateRegion(center: coordinate, span: map.region.span)
|
||||||
case let .zoom(factor):
|
case let .zoom(factor):
|
||||||
region = MKCoordinateRegion(
|
region = MKCoordinateRegion(
|
||||||
@@ -260,7 +268,6 @@ struct MapViewRepresentable: UIViewRepresentable {
|
|||||||
parent.onViewportChanged(distance)
|
parent.onViewportChanged(distance)
|
||||||
zoomLabel?.text = MapZoomMath.viewportScaleLabel(distanceMeters: distance)
|
zoomLabel?.text = MapZoomMath.viewportScaleLabel(distanceMeters: distance)
|
||||||
updatePinPosition(on: mapView)
|
updatePinPosition(on: mapView)
|
||||||
// 同步蓝点坐标
|
|
||||||
// 同步蓝点坐标(避免 delegate 更新不及时导致 mapState.realtimeLocation 为 nil)
|
// 同步蓝点坐标(避免 delegate 更新不及时导致 mapState.realtimeLocation 为 nil)
|
||||||
if let ul = mapView.userLocation.location,
|
if let ul = mapView.userLocation.location,
|
||||||
CLLocationCoordinate2DIsValid(ul.coordinate), ul.horizontalAccuracy >= 0 {
|
CLLocationCoordinate2DIsValid(ul.coordinate), ul.horizontalAccuracy >= 0 {
|
||||||
|
|||||||
@@ -25,9 +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: [
|
if enabled != 0 {
|
||||||
"lat": String(lat), "lon": String(lon)
|
RuntimeLogger.info("APP", "坐标转换", "启动代理: 恢复上次 WGS-84 定位")
|
||||||
])
|
}
|
||||||
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))
|
||||||
@@ -62,8 +62,7 @@ final class ProxyManager: ObservableObject {
|
|||||||
RuntimeLogger.info("APP", "Proxy.coords", "写入坐标", details: [
|
RuntimeLogger.info("APP", "Proxy.coords", "写入坐标", details: [
|
||||||
"revision": String(coordinateRevision),
|
"revision": String(coordinateRevision),
|
||||||
"enabled": String(enabled),
|
"enabled": String(enabled),
|
||||||
"lat": String(lat),
|
"accuracy": String(accuracy)
|
||||||
"lon": String(lon)
|
|
||||||
])
|
])
|
||||||
return coordinateRevision
|
return coordinateRevision
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,11 +280,9 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
|
|||||||
activeRequest = nil
|
activeRequest = nil
|
||||||
isRequesting = false
|
isRequesting = false
|
||||||
|
|
||||||
if let coordinate {
|
if coordinate != nil {
|
||||||
RuntimeLogger.info("APP", "定位", "获取到实时定位", details: [
|
RuntimeLogger.info("APP", "定位", "获取到实时定位", details: [
|
||||||
"requestID": String(requestID),
|
"requestID": String(requestID)
|
||||||
"lat": String(coordinate.latitude),
|
|
||||||
"lon": String(coordinate.longitude)
|
|
||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
RuntimeLogger.warning("APP", "定位", "实时定位请求结束但没有坐标", details: ["requestID": String(requestID)])
|
RuntimeLogger.warning("APP", "定位", "实时定位请求结束但没有坐标", details: ["requestID": String(requestID)])
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ final class SetupCoordinator: ObservableObject {
|
|||||||
trustState = .checking
|
trustState = .checking
|
||||||
message = "正在检测…"
|
message = "正在检测…"
|
||||||
testLog = ""
|
testLog = ""
|
||||||
|
defer { needsSetup = !canModify }
|
||||||
do {
|
do {
|
||||||
_ = try certificateStore.ensure()
|
_ = try certificateStore.ensure()
|
||||||
if !proxy.isRunning { try await proxy.start() }
|
if !proxy.isRunning { try await proxy.start() }
|
||||||
@@ -40,11 +41,7 @@ final class SetupCoordinator: ObservableObject {
|
|||||||
kCFNetworkProxiesHTTPProxy as String: "127.0.0.1",
|
kCFNetworkProxiesHTTPProxy as String: "127.0.0.1",
|
||||||
kCFNetworkProxiesHTTPPort as String: 8888,
|
kCFNetworkProxiesHTTPPort as String: 8888,
|
||||||
]
|
]
|
||||||
// 用当前保存的虚拟定位坐标做测试;没有则用默认坐标
|
// This probe verifies proxy reachability and TLS trust only; it does not validate a selected coordinate.
|
||||||
let saved = WlocSettingsStore.load()
|
|
||||||
let testLat = saved?.latitude ?? 22.543099
|
|
||||||
let testLon = saved?.longitude ?? 113.934576
|
|
||||||
let testAccuracy = saved?.accuracy ?? 25
|
|
||||||
let req = makeWlocRequest()
|
let req = makeWlocRequest()
|
||||||
let (_, resp) = try await URLSession(configuration: config).data(for: req)
|
let (_, resp) = try await URLSession(configuration: config).data(for: req)
|
||||||
let status = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
let status = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
@@ -69,7 +66,6 @@ final class SetupCoordinator: ObservableObject {
|
|||||||
message = "检测失败 [\(ns.domain) \(ns.code)]: \(ns.localizedDescription)"
|
message = "检测失败 [\(ns.domain) \(ns.code)]: \(ns.localizedDescription)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
needsSetup = !canModify
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sceneDidBecomeActive() {}
|
func sceneDidBecomeActive() {}
|
||||||
|
|||||||
+25
-7
@@ -64,13 +64,12 @@ func wloccore_startproxy(certData, keyData *C.char, lat, lon C.double, enabled C
|
|||||||
//export wloccore_stopproxy
|
//export wloccore_stopproxy
|
||||||
func wloccore_stopproxy(h C.uintptr_t) C.int {
|
func wloccore_stopproxy(h C.uintptr_t) C.int {
|
||||||
logEvent("stopproxy requested")
|
logEvent("stopproxy requested")
|
||||||
handle := cgo.Handle(h)
|
srv, handle, ok := proxyForHandle(h)
|
||||||
srv, ok := handle.Value().(*http.Server)
|
|
||||||
handle.Delete()
|
|
||||||
if !ok {
|
if !ok {
|
||||||
logEvent("stopproxy failed: invalid handle")
|
logEvent("stopproxy failed: invalid handle")
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
handle.Delete()
|
||||||
if err := stopProxy(srv); err != nil {
|
if err := stopProxy(srv); err != nil {
|
||||||
logEvent("stopproxy failed: " + err.Error())
|
logEvent("stopproxy failed: " + err.Error())
|
||||||
return 2
|
return 2
|
||||||
@@ -79,6 +78,20 @@ func wloccore_stopproxy(h C.uintptr_t) C.int {
|
|||||||
return 0
|
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
|
//export wloccore_setcoords
|
||||||
func wloccore_setcoords(lat, lon C.double, enabled C.int, accuracy C.int) {
|
func wloccore_setcoords(lat, lon C.double, enabled C.int, accuracy C.int) {
|
||||||
stateMu.Lock()
|
stateMu.Lock()
|
||||||
@@ -87,7 +100,7 @@ func wloccore_setcoords(lat, lon C.double, enabled C.int, accuracy C.int) {
|
|||||||
currentEnabled = enabled != 0
|
currentEnabled = enabled != 0
|
||||||
currentAccuracy = int(accuracy)
|
currentAccuracy = int(accuracy)
|
||||||
stateMu.Unlock()
|
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
|
//export wloccore_getcoords
|
||||||
@@ -127,12 +140,17 @@ func wloccore_startcertserver(certData, keyData *C.char) C.uintptr_t {
|
|||||||
return C.uintptr_t(cgo.NewHandle(server))
|
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 {
|
if h == 0 {
|
||||||
return nil, 0, false
|
return nil, 0, false
|
||||||
}
|
}
|
||||||
handle := cgo.Handle(h)
|
defer func() {
|
||||||
server, ok := handle.Value().(*certificateServer)
|
if recover() != nil {
|
||||||
|
server, handle, ok = nil, 0, false
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
handle = cgo.Handle(h)
|
||||||
|
server, ok = handle.Value().(*certificateServer)
|
||||||
return server, handle, ok
|
return server, handle, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-12
@@ -1,34 +1,35 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
"encoding/binary"
|
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"io"
|
|
||||||
"math/big"
|
"math/big"
|
||||||
"math/rand"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func deterministicReader() io.Reader {
|
func randomSerialNumber() (*big.Int, error) {
|
||||||
seed := sha256.Sum256([]byte("paopao-location-spoofer-ca-v1"))
|
// Keep the serial positive and within the RFC 5280 recommended 20-octet bound.
|
||||||
src := rand.NewSource(int64(binary.BigEndian.Uint64(seed[:8])))
|
limit := new(big.Int).Lsh(big.NewInt(1), 159)
|
||||||
return rand.New(src)
|
return rand.Int(rand.Reader, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateCA() (certPEM, keyPEM []byte, err error) {
|
func generateCA() (certPEM, keyPEM []byte, err error) {
|
||||||
rng := deterministicReader()
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
privateKey, err := rsa.GenerateKey(rng, 2048)
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
serialNumber, err := randomSerialNumber()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
template := x509.Certificate{
|
template := x509.Certificate{
|
||||||
SerialNumber: big.NewInt(1),
|
SerialNumber: serialNumber,
|
||||||
Subject: pkix.Name{
|
Subject: pkix.Name{
|
||||||
Organization: []string{"WLOC"},
|
Organization: []string{"WLOC"},
|
||||||
CommonName: "WLOC CA " + time.Now().In(time.FixedZone("CST", 8*3600)).Format("2006.01.02 15:04"),
|
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,
|
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 {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,3 +33,17 @@ func TestGenerateCA(t *testing.T) {
|
|||||||
t.Fatal(err)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+22
-54
@@ -11,7 +11,6 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -95,10 +94,10 @@ func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer {
|
|||||||
}
|
}
|
||||||
if r.URL.Path == "/coords" {
|
if r.URL.Path == "/coords" {
|
||||||
stateMu.Lock()
|
stateMu.Lock()
|
||||||
enabled, lat, lon := currentEnabled, currentLat, currentLon
|
enabled, lat, lon, accuracy := currentEnabled, currentLat, currentLon, currentAccuracy
|
||||||
stateMu.Unlock()
|
stateMu.Unlock()
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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
|
return
|
||||||
}
|
}
|
||||||
if r.URL.Path == "/proxy.mobileconfig" || r.URL.Path == "/proxy.mobileconfig/" {
|
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) {
|
proxy.OnRequest().DoFunc(func(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
|
||||||
body := []byte(nil)
|
// Do not buffer or log arbitrary global-proxy traffic. Keep requests streaming
|
||||||
if req.Body != nil {
|
// and do not persist unrelated request content in diagnostics.
|
||||||
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)))
|
|
||||||
return serveLocalRequests(req, ctx)
|
return serveLocalRequests(req, ctx)
|
||||||
})
|
})
|
||||||
proxy.OnResponse().DoFunc(patchWlocResponse)
|
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-") {
|
if (h == "baidu.com" || h == "www.baidu.com") && strings.HasPrefix(req.URL.Path, "/paopao-verify-") {
|
||||||
token := strings.TrimPrefix(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) {
|
if checkVerifyToken(token) {
|
||||||
resp := goproxy.NewResponse(req, "text/plain", http.StatusOK, token)
|
resp := goproxy.NewResponse(req, "text/plain", http.StatusOK, token)
|
||||||
resp.Header.Set("Cache-Control", "no-store")
|
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
|
enabled, lat, lon, accuracy := currentEnabled, currentLat, currentLon, currentAccuracy
|
||||||
stateMu.Unlock()
|
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
|
originalBody := resp.Body
|
||||||
body, err := io.ReadAll(originalBody)
|
body, err := io.ReadAll(io.LimitReader(originalBody, maxPatchBodyBytes+1))
|
||||||
originalBody.Close()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logEvent("wloc response read failed: " + err.Error())
|
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
|
return resp
|
||||||
}
|
}
|
||||||
logEvent(fmt.Sprintf("wloc upstream response status=%d size=%d headers=[%s] body_prefix=%s",
|
if int64(len(body)) > maxPatchBodyBytes {
|
||||||
resp.StatusCode, len(body), summarizeHeaders(resp.Header), hexPrefix(body, 64)))
|
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 {
|
if !enabled || resp.StatusCode != http.StatusOK || len(body) == 0 {
|
||||||
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")
|
|
||||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
patched, stats, err := patchResponseBody(body, wlocCoords{Latitude: lat, Longitude: lon, Accuracy: accuracy})
|
patched, stats, err := patchResponseBody(body, wlocCoords{Latitude: lat, Longitude: lon, Accuracy: accuracy})
|
||||||
|
if err != nil || bytes.Equal(patched, body) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logEvent("wloc patch skipped: " + err.Error())
|
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")
|
|
||||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
@@ -273,30 +261,10 @@ func patchWlocResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Respons
|
|||||||
resp.Header.Del("Content-Encoding")
|
resp.Header.Del("Content-Encoding")
|
||||||
resp.Header.Del("Transfer-Encoding")
|
resp.Header.Del("Transfer-Encoding")
|
||||||
resp.Header.Set("Content-Length", strconv.Itoa(len(patched)))
|
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",
|
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)))
|
||||||
lat, lon, accuracy, stats.Locations, stats.WiFi, stats.Cell, stats.Skipped, len(body), len(patched), hexPrefix(patched, 64)))
|
|
||||||
return resp
|
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 {
|
func generateProxyMobileConfig() string {
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"math"
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,3 +114,109 @@ func TestTransparentBodyUnchanged(t *testing.T) {
|
|||||||
t.Fatal("expected non-patchable body to error")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+23
-12
@@ -4,21 +4,32 @@ set -euo pipefail
|
|||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
CORE="$ROOT/Core"
|
CORE="$ROOT/Core"
|
||||||
BUILD="$CORE/build"
|
BUILD="$CORE/build"
|
||||||
|
|
||||||
IOS_SDK="$(xcrun --sdk iphoneos --show-sdk-path)"
|
|
||||||
MIN_IOS_VERSION="15.0"
|
MIN_IOS_VERSION="15.0"
|
||||||
|
|
||||||
export CGO_ENABLED=1
|
build_archive() {
|
||||||
export CGO_CFLAGS="-arch arm64 -isysroot $IOS_SDK -miphoneos-version-min=$MIN_IOS_VERSION"
|
local sdk="$1"
|
||||||
export CGO_LDFLAGS="-arch arm64 -isysroot $IOS_SDK -miphoneos-version-min=$MIN_IOS_VERSION"
|
local min_flag="$2"
|
||||||
export GOOS="ios"
|
local output="$3"
|
||||||
export GOARCH="arm64"
|
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"
|
cd "$CORE"
|
||||||
go mod download
|
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"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ final class BackgroundKeepAlive {
|
|||||||
guard isActive, let info = notification.userInfo,
|
guard isActive, let info = notification.userInfo,
|
||||||
let type = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
let type = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||||
type == AVAudioSession.InterruptionType.ended.rawValue else { return }
|
type == AVAudioSession.InterruptionType.ended.rawValue else { return }
|
||||||
start()
|
restartAfterInterruption()
|
||||||
RuntimeLogger.info("APP", "KeepAlive", "音频中断恢复")
|
RuntimeLogger.info("APP", "KeepAlive", "音频中断恢复")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +50,16 @@ final class BackgroundKeepAlive {
|
|||||||
RuntimeLogger.info("APP", "KeepAlive", "后台保活已启动(静音音频)")
|
RuntimeLogger.info("APP", "KeepAlive", "后台保活已启动(静音音频)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func restartAfterInterruption() {
|
||||||
|
guard isActive else { return }
|
||||||
|
playerNode?.stop()
|
||||||
|
engine?.stop()
|
||||||
|
playerNode = nil
|
||||||
|
engine = nil
|
||||||
|
isActive = false
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
guard isActive else { return }
|
guard isActive else { return }
|
||||||
isActive = false
|
isActive = false
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ final class CertificateTrustVerifier {
|
|||||||
/// Check whether a CA certificate (given as PEM data) is installed and fully trusted
|
/// 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.
|
/// by the system. Uses SecTrust evaluation against system anchors only — no network needed.
|
||||||
static func isCACertificateTrusted(certPEM: String) -> Bool {
|
static func isCACertificateTrusted(certPEM: String) -> Bool {
|
||||||
guard let certData = certPEM.data(using: .utf8),
|
guard let pemData = certPEM.data(using: .utf8),
|
||||||
let cert = SecCertificateCreateWithData(nil, certData as CFData) else {
|
let block = pemData.pemCertificateBlock,
|
||||||
|
let cert = SecCertificateCreateWithData(nil, block as CFData) else {
|
||||||
RuntimeLogger.error("APP", "Trust", "无法解析 CA 证书 PEM")
|
RuntimeLogger.error("APP", "Trust", "无法解析 CA 证书 PEM")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -38,3 +39,13 @@ final class CertificateTrustVerifier {
|
|||||||
return result
|
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..<end.lowerBound].components(separatedBy: .whitespacesAndNewlines).joined()
|
||||||
|
return Data(base64Encoded: body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
|
import MapKit
|
||||||
|
|
||||||
/// GCJ-02 (火星坐标) ↔ WGS-84 坐标转换。
|
/// GCJ-02 (火星坐标) ↔ WGS-84 坐标转换。
|
||||||
///
|
///
|
||||||
@@ -18,33 +19,111 @@ enum CoordinateConverter {
|
|||||||
|
|
||||||
// MARK: - 全局瓦片类型
|
// MARK: - 全局瓦片类型
|
||||||
|
|
||||||
/// 当前地图瓦片坐标系
|
struct TileTypeChange: Equatable {
|
||||||
|
let previous: CoordType
|
||||||
|
let current: CoordType
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前地图瓦片坐标系。探测不可用时使用国内 GCJ-02 作为兜底。
|
||||||
@MainActor static var currentTileType = CoordType.gcj02
|
@MainActor static var currentTileType = CoordType.gcj02
|
||||||
/// 瓦片检测缓存
|
|
||||||
@MainActor private static var lastTileCheck: Date?
|
@MainActor private static var lastTileCheck: Date?
|
||||||
@MainActor private static var tileCheckPending = false
|
@MainActor private static var tileCheckPending = false
|
||||||
|
|
||||||
/// 固定坐标反查:返回"林士街"=GCJ-02高德瓦片,否则=WGS-84(30s缓存)
|
/// Uses one fixed, known reference result as a best-effort tile heuristic.
|
||||||
|
///
|
||||||
|
/// MapKit does not expose a supported public API for its active tile CRS. A
|
||||||
|
/// timeout, empty response, or search error is therefore not evidence for
|
||||||
|
/// WGS-84: those cases deliberately fall back to the domestic GCJ-02 mode.
|
||||||
@MainActor
|
@MainActor
|
||||||
static func detectTileByFixedGeocode() {
|
@discardableResult
|
||||||
guard !tileCheckPending else { return }
|
static func detectTileByFixedGeocode(force: Bool = false) async -> TileTypeChange? {
|
||||||
if let last = lastTileCheck, -last.timeIntervalSinceNow < 30 { return }
|
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
|
tileCheckPending = true
|
||||||
let loc = CLLocation(latitude: 22.283819, longitude: 114.158439)
|
|
||||||
CLGeocoder().reverseGeocodeLocation(loc) { placemarks, _ in
|
|
||||||
Task { @MainActor in
|
|
||||||
defer { tileCheckPending = false }
|
defer { tileCheckPending = false }
|
||||||
let name = placemarks?.first?.name ?? ""
|
RuntimeLogger.info("APP", "坐标转换", "瓦片检测: 发起查询", details: [
|
||||||
let newType: CoordType = (name == "林士街") ? .gcj02 : .wgs84
|
"force": String(force),
|
||||||
RuntimeLogger.info("APP", "坐标转换", "固定坐标反查 → \(newType.rawValue)", details: [
|
"当前瓦片": currentTileType.rawValue
|
||||||
"名称": name
|
|
||||||
])
|
])
|
||||||
if newType != currentTileType {
|
|
||||||
currentTileType = newType
|
let probeResult = await fixedGeocodeProbe()
|
||||||
}
|
guard !Task.isCancelled else { return nil }
|
||||||
lastTileCheck = Date()
|
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: - 存取转换
|
// MARK: - 存取转换
|
||||||
@@ -52,41 +131,43 @@ enum CoordinateConverter {
|
|||||||
/// 地图坐标 → WGS-84 存储
|
/// 地图坐标 → WGS-84 存储
|
||||||
@MainActor
|
@MainActor
|
||||||
static func toStored(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
static func toStored(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
||||||
detectTileByFixedGeocode()
|
let stored = storedCoordinate(lat: lat, lon: lon, tileType: currentTileType)
|
||||||
guard currentTileType == .gcj02 else {
|
RuntimeLogger.info("APP", "坐标转换", "地图坐标已规范为 WGS-84", details: [
|
||||||
RuntimeLogger.info("APP", "坐标转换", "toStored: 不转 瓦片=\(currentTileType.rawValue)", details: [
|
"转换": String(currentTileType == .gcj02 && usesGCJ02ServiceArea(lat: lat, lon: lon))
|
||||||
"lat": String(lat), "lon": String(lon)
|
|
||||||
])
|
])
|
||||||
return (lat, lon)
|
return stored
|
||||||
}
|
|
||||||
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)
|
|
||||||
])
|
|
||||||
return wgs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// WGS-84 存储 → 当前地图瓦片坐标系(显示用)
|
/// WGS-84 存储 → 当前地图瓦片坐标系(显示用)
|
||||||
@MainActor
|
@MainActor
|
||||||
static func toDisplay(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
static func toDisplay(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
||||||
detectTileByFixedGeocode()
|
let display = displayCoordinate(lat: lat, lon: lon, tileType: currentTileType)
|
||||||
guard currentTileType == .gcj02 else {
|
RuntimeLogger.info("APP", "坐标转换", "WGS-84 坐标已适配地图显示", details: [
|
||||||
RuntimeLogger.info("APP", "坐标转换", "toDisplay: WGS-84 → 不转 瓦片=\(currentTileType.rawValue)", details: [
|
"转换": String(currentTileType == .gcj02 && usesGCJ02ServiceArea(lat: lat, lon: lon))
|
||||||
"lat": String(lat), "lon": String(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)
|
return (lat, lon)
|
||||||
}
|
}
|
||||||
let gcj = wgs84ToGcj02(lat: lat, lon: lon)
|
return gcj02ToWgs84(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)",
|
private static func displayCoordinate(
|
||||||
"结果": "\(gcj.lat), \(gcj.lon)",
|
lat: Double,
|
||||||
"偏移": String(format: "%.0fm", d)
|
lon: Double,
|
||||||
])
|
tileType: CoordType
|
||||||
return gcj
|
) -> (lat: Double, lon: Double) {
|
||||||
|
guard tileType == .gcj02, usesGCJ02ServiceArea(lat: lat, lon: lon) else {
|
||||||
|
return (lat, lon)
|
||||||
|
}
|
||||||
|
return wgs84ToGcj02(lat: lat, lon: lon)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 工具
|
// MARK: - 工具
|
||||||
@@ -106,6 +187,7 @@ enum CoordinateConverter {
|
|||||||
|
|
||||||
/// 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) {
|
||||||
|
guard usesGCJ02ServiceArea(lat: lat, lon: lon) else { return (lat, lon) }
|
||||||
var wgsLat = lat
|
var wgsLat = lat
|
||||||
var wgsLon = lon
|
var wgsLon = lon
|
||||||
for _ in 0..<2 {
|
for _ in 0..<2 {
|
||||||
@@ -118,10 +200,22 @@ enum CoordinateConverter {
|
|||||||
|
|
||||||
/// WGS-84 → GCJ-02
|
/// WGS-84 → GCJ-02
|
||||||
static func wgs84ToGcj02(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
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)
|
let d = delta(lat: lat, lon: lon)
|
||||||
return (lat + d.lat, lon + d.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: - 内部
|
// MARK: - 内部
|
||||||
|
|
||||||
/// 计算偏移量 (WGS-84 → GCJ-02 的增量)
|
/// 计算偏移量 (WGS-84 → GCJ-02 的增量)
|
||||||
@@ -154,3 +248,56 @@ enum CoordinateConverter {
|
|||||||
return ret
|
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<TileProbeResult, Never>?
|
||||||
|
private var timeout: DispatchWorkItem?
|
||||||
|
|
||||||
|
var isResolved: Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return result != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func install(
|
||||||
|
_ continuation: CheckedContinuation<TileProbeResult, Never>,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ final class NetworkMonitor: ObservableObject {
|
|||||||
@Published private(set) var isWiFiEnabled = true
|
@Published private(set) var isWiFiEnabled = true
|
||||||
@Published private(set) var currentSSID: String?
|
@Published private(set) var currentSSID: String?
|
||||||
|
|
||||||
/// WiFi 重连或 SSID 变化时触发(仅虚拟定位激活时使用)
|
/// WiFi 重连或 SSID 变化时触发的订阅。调用方必须在离开页面时移除订阅。
|
||||||
var onWiFiChanged: (() -> Void)?
|
private var wifiChangeHandlers: [UUID: @MainActor () -> Void] = [:]
|
||||||
|
|
||||||
private let monitor = NWPathMonitor()
|
private let monitor = NWPathMonitor()
|
||||||
private var ssidTimer: Timer?
|
private var ssidTimer: Timer?
|
||||||
@@ -29,7 +29,7 @@ final class NetworkMonitor: ObservableObject {
|
|||||||
self.isSatisfied = satisfied
|
self.isSatisfied = satisfied
|
||||||
self.isWiFiEnabled = wifi
|
self.isWiFiEnabled = wifi
|
||||||
if reconnected {
|
if reconnected {
|
||||||
self.onWiFiChanged?()
|
self.notifyWiFiChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,6 +39,24 @@ final class NetworkMonitor: ObservableObject {
|
|||||||
|
|
||||||
var isAirplaneMode: Bool { !isSatisfied }
|
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() {
|
private func startSSIDPolling() {
|
||||||
ssidTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
ssidTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -46,7 +64,7 @@ final class NetworkMonitor: ObservableObject {
|
|||||||
let ssid = Self.fetchSSID()
|
let ssid = Self.fetchSSID()
|
||||||
if ssid != self.currentSSID, ssid != nil {
|
if ssid != self.currentSSID, ssid != nil {
|
||||||
self.currentSSID = ssid
|
self.currentSSID = ssid
|
||||||
self.onWiFiChanged?()
|
self.notifyWiFiChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ enum VerificationResult: Equatable, Identifiable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .success: return nil
|
case .success: return nil
|
||||||
case .proxyNotRunning, .verificationInProgress, .verificationSuperseded: return nil
|
case .proxyNotRunning, .verificationInProgress, .verificationSuperseded: return nil
|
||||||
case .certNotTrusted: return nil // 走完整引导页,不弹 tip
|
case .certNotTrusted: return .certificate
|
||||||
case .wifiProxyNotConfigured: return .proxySetup
|
case .wifiProxyNotConfigured: return .proxySetup
|
||||||
case .coordinateWriteFailed, .patchFailed: return .rewriteFailed
|
case .coordinateWriteFailed, .patchFailed: return .rewriteFailed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import XCTest
|
|||||||
@testable import PaopaoLocationSpoofer
|
@testable import PaopaoLocationSpoofer
|
||||||
|
|
||||||
final class CertificateTrustVerifierTests: XCTestCase {
|
final class CertificateTrustVerifierTests: XCTestCase {
|
||||||
func testVerifierMapsFailedProbeToUnavailable() async {
|
func testVerifierRejectsMalformedPEM() {
|
||||||
let verifier = CertificateTrustVerifier(probe: { _, _ in false })
|
XCTAssertFalse(CertificateTrustVerifier.isCACertificateTrusted(certPEM: "not a certificate"))
|
||||||
XCTAssertEqual(await verifier.verify(url: URL(string: "https://127.0.0.1:1/health")!, leafHash: "x"), .unavailable)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,6 @@ import XCTest
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class LocationActionCoordinatorTests: XCTestCase {
|
final class LocationActionCoordinatorTests: XCTestCase {
|
||||||
func testApplyChecksTrustThenConnectsAndSendsCoordinates() async {
|
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 favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
|
||||||
let coordinator = LocationActionCoordinator()
|
let coordinator = LocationActionCoordinator()
|
||||||
|
|
||||||
@@ -18,7 +14,6 @@ final class LocationActionCoordinatorTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testClearDoesNotConnectAnInactiveProxy() async {
|
func testClearDoesNotConnectAnInactiveProxy() async {
|
||||||
let events = EventLog()
|
|
||||||
let coordinator = LocationActionCoordinator()
|
let coordinator = LocationActionCoordinator()
|
||||||
|
|
||||||
coordinator.clear()
|
coordinator.clear()
|
||||||
@@ -29,13 +24,12 @@ final class LocationActionCoordinatorTests: XCTestCase {
|
|||||||
let coordinator = LocationActionCoordinator()
|
let coordinator = LocationActionCoordinator()
|
||||||
let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 20)
|
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)
|
let secondApplied = await coordinator.apply(favorite)
|
||||||
// Should reject while busy
|
XCTAssertTrue(firstApplied)
|
||||||
XCTAssertFalse(secondApplied)
|
XCTAssertTrue(secondApplied)
|
||||||
let firstApplied = await first.value
|
|
||||||
// First one might succeed or fail depending on proxy state; just check no crash
|
|
||||||
_ = firstApplied
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ final class MapLocationStateTests: XCTestCase {
|
|||||||
state.selectUserMapCenter(.init(latitude: 31.23, longitude: 121.47))
|
state.selectUserMapCenter(.init(latitude: 31.23, longitude: 121.47))
|
||||||
let accepted = state.acceptRealtimeLocation(
|
let accepted = state.acceptRealtimeLocation(
|
||||||
.init(latitude: 39.90, longitude: 116.40),
|
.init(latitude: 39.90, longitude: 116.40),
|
||||||
intent: request,
|
intent: request
|
||||||
focus: true
|
|
||||||
)
|
)
|
||||||
|
|
||||||
XCTAssertFalse(accepted)
|
XCTAssertFalse(accepted)
|
||||||
@@ -53,7 +52,7 @@ final class MapLocationStateTests: XCTestCase {
|
|||||||
XCTAssertEqual(state.selection.source, .userPan)
|
XCTAssertEqual(state.selection.source, .userPan)
|
||||||
|
|
||||||
let intent = state.beginRealtimeIntent()
|
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)
|
XCTAssertEqual(state.selection.source, .realtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,14 +179,29 @@ final class MapLocationStateTests: XCTestCase {
|
|||||||
state.updateRealtimeLocation(nativeLocation)
|
state.updateRealtimeLocation(nativeLocation)
|
||||||
let intent = state.beginRealtimeIntent()
|
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.source, .realtime)
|
||||||
XCTAssertEqual(state.selection.coordinate.latitude, 30.42, accuracy: 0.000001)
|
XCTAssertEqual(state.selection.coordinate.latitude, 30.42, accuracy: 0.000001)
|
||||||
guard case let .focus(coordinate, distance) = state.cameraCommand?.kind else {
|
XCTAssertNil(state.cameraCommand, "realtime updates preserve the current camera unless the caller explicitly focuses it")
|
||||||
return XCTFail("Expected realtime focus command")
|
|
||||||
}
|
}
|
||||||
XCTAssertEqual(coordinate.latitude, 30.42, accuracy: 0.000001)
|
|
||||||
XCTAssertEqual(distance, 200)
|
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, 22.54, accuracy: 0.000001)
|
||||||
|
XCTAssertEqual(distanceMeters, state.viewportMeters)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testZoomMathScalesBothAxesInTheSameDirection() {
|
func testZoomMathScalesBothAxesInTheSameDirection() {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ final class RealtimeLocationManagerTests: XCTestCase {
|
|||||||
|
|
||||||
func testOneShotTimeoutTransitionsToContinuousFallback() async {
|
func testOneShotTimeoutTransitionsToContinuousFallback() async {
|
||||||
let driver = FakeRealtimeLocationDriver()
|
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() }
|
let request = Task { await manager.requestLocation() }
|
||||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
|||||||
@@ -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 '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 'oneShotTimeoutNanoseconds' "$REALTIME" || fail "one-shot and fallback timeouts must be independent"
|
||||||
! grep -q 'pendingContinuation' "$REALTIME" || fail "unversioned pendingContinuation must be removed"
|
! 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 '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 '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 '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"
|
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 copy button"
|
||||||
grep -q '清空日志' "$DIAGNOSTICS" || fail "diagnostics must show a standalone clear 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 '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"
|
echo "PASS: map location state refactor contract"
|
||||||
|
|||||||
+5
-1
@@ -32,13 +32,17 @@ targets:
|
|||||||
HEADER_SEARCH_PATHS: "$(PROJECT_DIR)/Core"
|
HEADER_SEARCH_PATHS: "$(PROJECT_DIR)/Core"
|
||||||
SWIFT_OBJC_BRIDGING_HEADER: App/PaopaoLocationSpoofer-Bridging-Header.h
|
SWIFT_OBJC_BRIDGING_HEADER: App/PaopaoLocationSpoofer-Bridging-Header.h
|
||||||
OTHER_LDFLAGS: "$(inherited) -lwloccore"
|
OTHER_LDFLAGS: "$(inherited) -lwloccore"
|
||||||
LIBRARY_SEARCH_PATHS: "$(PROJECT_DIR)/Core/build"
|
LIBRARY_SEARCH_PATHS: "$(PROJECT_DIR)/Core/build/$(PLATFORM_NAME)"
|
||||||
|
|
||||||
PaopaoLocationSpooferTests:
|
PaopaoLocationSpooferTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
platform: iOS
|
platform: iOS
|
||||||
sources:
|
sources:
|
||||||
- path: Tests/PaopaoLocationSpooferTests
|
- path: Tests/PaopaoLocationSpooferTests
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
HEADER_SEARCH_PATHS: "$(PROJECT_DIR)/Core"
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER: App/PaopaoLocationSpoofer-Bridging-Header.h
|
||||||
dependencies:
|
dependencies:
|
||||||
- target: PaopaoLocationSpoofer
|
- target: PaopaoLocationSpoofer
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user