fix: harden startup proxy and coordinate handling

This commit is contained in:
xweiba
2026-08-06 12:17:23 +08:00
parent 881270a21a
commit 0e8e83fd33
26 changed files with 708 additions and 266 deletions
+34 -19
View File
@@ -4,31 +4,46 @@ struct ContentView: View {
@StateObject private var setup = SetupCoordinator()
@ObservedObject private var net = NetworkMonitor.shared
@State private var showSetup = false
@State private var phase: AppPhase = .splash
@AppStorage("setupCompleted") private var setupCompleted = false
enum AppPhase { case splash, map }
var body: some View {
NavigationView {
MapHomeView(setup: setup)
Group {
switch phase {
case .splash:
VStack(spacing: 16) {
Image(systemName: "location.fill")
.font(.system(size: 48)).foregroundStyle(.blue)
ProgressView()
Text("正在启动…").font(.subheadline).foregroundStyle(.secondary)
}
case .map:
NavigationView {
MapHomeView(setup: setup)
}
.fullScreenCover(isPresented: $showSetup) {
FirstSetupView(setup: setup, onComplete: {
setupCompleted = true
setup.completeSetup()
showSetup = false
})
}
}
}
.task {
if !setupCompleted {
showSetup = true
return
if !setup.proxy.isRunning {
do {
try await setup.proxy.start()
} catch {
RuntimeLogger.error("APP", "Startup", "代理启动失败,将在设置检测中重试", error: error)
}
}
await setup.refreshTrust()
}
.onChange(of: net.isAirplaneMode) { airplane in
guard setupCompleted else { return }
if !airplane {
Task { await setup.refreshTrust() }
}
}
.fullScreenCover(isPresented: $showSetup) {
FirstSetupView(setup: setup, onComplete: {
setupCompleted = true
setup.completeSetup()
showSetup = false
})
phase = .map
if !setupCompleted { showSetup = true }
// Tile probing is a best-effort display refinement and must never block first render.
await CoordinateConverter.detectTileByFixedGeocode(force: true)
}
}
}
+20 -3
View File
@@ -15,20 +15,37 @@ struct RuntimeLogsView: View {
@State private var copiedEntryID: UUID?
@State private var copyLogsConfirmed = false
@State private var testLogCopied = false
@State private var logFilter = ""
private var filteredEntries: [RuntimeLogEntry] {
let q = logFilter.trimmingCharacters(in: .whitespacesAndNewlines)
guard !q.isEmpty else { return entries }
return entries.filter { $0.message.localizedCaseInsensitiveContains(q) }
}
var body: some View {
VStack(spacing: 0) {
testPanel
HStack(spacing: 8) {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField("过滤日志", text: $logFilter)
.textFieldStyle(.plain).font(.caption)
if !logFilter.isEmpty {
Button { logFilter = "" } label: {
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary).font(.caption)
}
}
}.padding(.horizontal, 12).padding(.vertical, 6)
Divider()
if entries.isEmpty {
if filteredEntries.isEmpty {
VStack(spacing: 10) {
Image(systemName: "doc.text.magnifyingglass").font(.largeTitle).foregroundStyle(.secondary)
Text("暂无运行日志").foregroundStyle(.secondary)
Text(entries.isEmpty ? "暂无运行日志" : "无匹配日志").foregroundStyle(.secondary)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 10) {
ForEach(entries.reversed()) { entry in logRow(entry) }
ForEach(filteredEntries.reversed()) { entry in logRow(entry) }
}.padding(12)
}
}
+2 -3
View File
@@ -70,9 +70,8 @@ final class LocationActionCoordinator: ObservableObject {
enabled: true,
accuracy: favorite.accuracy
)
RuntimeLogger.info("APP", "坐标转换", "写入代理: WGS-84", details: [
"lat": String(wgs.lat),
"lon": String(wgs.lon)
RuntimeLogger.info("APP", "坐标转换", "已向代理写入 WGS-84 坐标", details: [
"accuracy": String(favorite.accuracy)
])
state = .idle
virtualLocationEnabled = true
+121 -44
View File
@@ -45,7 +45,6 @@ struct MapHomeView: View {
@State private var showDisableTip = false
@State private var activeTip: TipKind?
@State private var manualHint = ""
@AppStorage("activationTipCount") private var activationTipCount = 0
@AppStorage("activationTipDisabled") private var activationTipDisabled = false
@State private var editingFavorite: FavoriteLocation?
@State private var editName = ""
@@ -54,6 +53,11 @@ struct MapHomeView: View {
@State private var showLocationAlert = false
@State private var realtimeRequestTask: Task<Void, Never>?
@State private var realtimeRequestContext: RealtimeLocationRequestContext?
@State private var wifiChangeObserverToken: UUID?
@State private var wifiVerificationTask: Task<Void, Never>?
@State private var wifiVerificationID: UUID?
@State private var tileProbeTask: Task<Void, Never>?
@State private var tileProbeID: UUID?
@State private var copyConfirmed = false
@State private var spoofState: SpoofState = .idle
@State private var locationOperationTask: Task<Void, Never>?
@@ -66,14 +70,17 @@ struct MapHomeView: View {
self.setup = setup
let savedCoord = LastCoordinateStore.load()
let initialZoom = ViewportStore.loadOrDefault()
// 持久化存的是 WGS-84,直接转当前瓦片坐标系显示
let initialCoord: CLLocationCoordinate2D
if let coord = savedCoord?.coordinate {
let display = CoordinateConverter.toDisplay(lat: coord.latitude, lon: coord.longitude)
if let saved = savedCoord {
let display = CoordinateConverter.toDisplay(lat: saved.coordinate.latitude, lon: saved.coordinate.longitude)
initialCoord = CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon)
} else {
initialCoord = CLLocationCoordinate2D(latitude: 22.544577, longitude: 113.94114)
}
RuntimeLogger.info("APP", "地图", "初始化", details: [
"zoom": String(initialZoom),
"有缓存": String(savedCoord != nil)
])
_mapState = StateObject(wrappedValue: MapLocationState(
initialCoordinate: initialCoord,
initialViewportMeters: initialZoom
@@ -90,7 +97,7 @@ struct MapHomeView: View {
},
onUserCenterChanged: { coordinate, distance in
mapState.updateViewport(distanceMeters: distance)
let previousRevision = mapState.selection.revision
let previousRevision = mapState.selection.revision
let revision = mapState.selectUserMapCenter(coordinate)
guard revision != previousRevision else { return }
let wgs = CoordinateConverter.toStored(lat: coordinate.latitude, lon: coordinate.longitude)
@@ -103,7 +110,7 @@ let previousRevision = mapState.selection.revision
},
onMapTap: { coordinate in
favorites.select(nil)
let revision = mapState.selectMapTap(coordinate)
let revision = mapState.selectMapTap(coordinate)
let wgs = CoordinateConverter.toStored(lat: coordinate.latitude, lon: coordinate.longitude)
LastCoordinateStore.save(lat: wgs.lat, lon: wgs.lon)
scheduleGeocode(coordinate: coordinate, revision: revision)
@@ -114,7 +121,7 @@ let revision = mapState.selectMapTap(coordinate)
onZoomIn: { mapState.zoom(by: 0.5) },
onZoomOut: { mapState.zoom(by: 2) }
)
.ignoresSafeArea(.container)
.ignoresSafeArea(.container)
VStack(spacing: 10) {
topControls
@@ -182,18 +189,21 @@ let revision = mapState.selectMapTap(coordinate)
} message: { Text(manualHint) }
.onAppear {
initializeMap()
NetworkMonitor.shared.onWiFiChanged = { [self] in
guard spoofState == .active else { return }
Task { @MainActor in
try? await Task.sleep(nanoseconds: 3_000_000_000)
guard spoofState == .active else { return }
let target = currentSelectionFavorite
let result = await setup.runVerificationTest(testLat: target.latitude, testLon: target.longitude)
if !result.isSuccess, let tip = result.tipKind {
activeTip = tip
}
}
Task { await setup.refreshTrust() }
startTileProbe()
registerWiFiChangeObserver()
}
.onDisappear {
if let token = wifiChangeObserverToken {
net.removeWiFiChangeObserver(token)
wifiChangeObserverToken = nil
}
wifiVerificationTask?.cancel()
wifiVerificationTask = nil
wifiVerificationID = nil
tileProbeTask?.cancel()
tileProbeTask = nil
tileProbeID = nil
}
.onChange(of: net.isAirplaneMode) { airplane in
if !airplane {
@@ -318,7 +328,7 @@ let revision = mapState.selectMapTap(coordinate)
.onTapGesture {
let text = String(format: "%.6f, %.6f", mapState.selection.coordinate.latitude, mapState.selection.coordinate.longitude)
UIPasteboard.general.string = text
RuntimeLogger.info("APP", "地图", "复制坐标: \(text)")
RuntimeLogger.info("APP", "地图", "已复制坐标")
copyConfirmed = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copyConfirmed = false }
}
@@ -500,7 +510,6 @@ let revision = mapState.selectMapTap(coordinate)
])
if applied && !activationTipDisabled {
showEnableTip = true
activationTipCount += 1
}
} else {
spoofState = actions.virtualLocationEnabled ? .active : .idle
@@ -576,7 +585,6 @@ let revision = mapState.selectMapTap(coordinate)
private func initializeMap() {
guard !mapDidInitialize else { return }
mapDidInitialize = true
CoordinateConverter.detectTileByFixedGeocode()
if let selected = favorites.selectedFavorite {
let display = CoordinateConverter.toDisplay(lat: selected.latitude, lon: selected.longitude)
@@ -601,8 +609,84 @@ let revision = mapState.selectMapTap(coordinate)
}
}
private func startTileProbe(force: Bool = false) {
guard tileProbeTask == nil else { return }
let probeID = UUID()
tileProbeID = probeID
tileProbeTask = Task { @MainActor in
defer {
if tileProbeID == probeID {
tileProbeTask = nil
tileProbeID = nil
}
}
guard let change = await CoordinateConverter.detectTileByFixedGeocode(force: force),
!Task.isCancelled else { return }
reprojectMapSelection(for: change)
}
}
private func reprojectMapSelection(for change: CoordinateConverter.TileTypeChange) {
// CLLocationManager samples are WGS-84 already. Other map interactions
// are display coordinates and must be converted through WGS-84 once.
guard mapState.selection.source != .realtime else { return }
let coordinate = CoordinateConverter.reprojectDisplayCoordinate(
mapState.selection.coordinate,
from: change.previous,
to: change.current
)
mapState.reprojectSelectionForTileChange(coordinate)
RuntimeLogger.info("APP", "坐标转换", "瓦片类型切换后已重投影当前选点", details: [
"from": change.previous.rawValue,
"to": change.current.rawValue
])
}
private func registerWiFiChangeObserver() {
guard wifiChangeObserverToken == nil else { return }
wifiChangeObserverToken = net.observeWiFiChanges { [self] in
handleWiFiChange()
}
}
private func handleWiFiChange() {
RuntimeLogger.info("APP", "WiFi", "检测到 Wi-Fi 网络变化", details: [
"虚拟定位已开启": String(spoofState == .active)
])
guard spoofState == .active else { return }
wifiVerificationTask?.cancel()
let verificationID = UUID()
wifiVerificationID = verificationID
wifiVerificationTask = Task { @MainActor in
defer {
if wifiVerificationID == verificationID {
wifiVerificationTask = nil
wifiVerificationID = nil
}
}
do {
try await Task.sleep(nanoseconds: 3_000_000_000)
} catch {
return
}
guard !Task.isCancelled, spoofState == .active else { return }
RuntimeLogger.info("APP", "WiFi", "开始重新验证")
let target = currentSelectionFavorite
let result = await setup.runVerificationTest(testLat: target.latitude, testLon: target.longitude)
guard !Task.isCancelled, spoofState == .active else { return }
RuntimeLogger.info("APP", "WiFi", "重新验证完成", details: [
"success": String(result.isSuccess),
"tipKind": result.tipKind?.rawValue ?? "nil"
])
if !result.isSuccess, let tip = result.tipKind {
activeTip = tip
}
}
}
private func requestRealtimeLocation() {
let intent = mapState.beginRealtimeIntent()
startTileProbe()
// 蓝点存在就直接用,不限时(MKMapView 的 userLocation 只在位置变化时才更新)
if let loc = mapState.realtimeLocation {
acceptRealtimeLocation(loc.coordinate, intent: intent, source: "MapKit蓝点")
@@ -669,9 +753,7 @@ let revision = mapState.selectMapTap(coordinate)
let currentViewport = mapState.viewportMeters
let accepted = mapState.acceptRealtimeLocation(coordinate, intent: intent)
RuntimeLogger.info("APP", "地图", "\(source)返回", details: [
"accepted": String(accepted),
"lat": String(coordinate.latitude),
"lon": String(coordinate.longitude)
"accepted": String(accepted)
])
guard accepted else { return }
// 用点击时的缩放级别居中,不改变缩放
@@ -723,12 +805,8 @@ let revision = mapState.selectMapTap(coordinate)
mapState.selection.revision == revision,
let placemark = placemarks.first else { return }
if let firstItem = mkResponse?.mapItems.first {
RuntimeLogger.info("APP", "Geocode", "MKLocalSearch 坐标对比", details: [
"输入坐标": "\(coordinate.latitude), \(coordinate.longitude)",
"搜索返回坐标": "\(firstItem.placemark.coordinate.latitude), \(firstItem.placemark.coordinate.longitude)",
"名称": firstItem.name ?? "nil"
])
if mkResponse?.mapItems.first != nil {
RuntimeLogger.info("APP", "Geocode", "MKLocalSearch 返回地点结果")
}
let mapItemName = mkResponse?.mapItems.first?.name?.trimmingCharacters(in: .whitespacesAndNewlines)
let mapItemPOI = mkResponse?.mapItems.first?.placemark.areasOfInterest?.first
@@ -805,9 +883,8 @@ let revision = mapState.selectMapTap(coordinate)
.joined(separator: " · "),
coordinate: item.placemark.coordinate
)
RuntimeLogger.info("APP", "搜索", "结果: \(r.name)", details: [
"lat": String(r.coordinate.latitude),
"lon": String(r.coordinate.longitude)
RuntimeLogger.info("APP", "搜索", "获得搜索结果", details: [
"名称": r.name
])
return r
}
@@ -850,21 +927,21 @@ let revision = mapState.selectMapTap(coordinate)
ScrollView {
VStack(alignment: .leading, spacing: 12) {
ActivationTipContent(dismiss: {})
if activationTipCount >= 3 {
Button(role: .destructive) {
activationTipDisabled = true
showEnableTip = false
} label: {
Label("关闭不再弹出", systemImage: "bell.slash").frame(maxWidth: .infinity)
}.buttonStyle(.bordered)
}
}.padding(16)
}
.navigationTitle("虚拟定位已开启").navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .bottom) {
Button { showEnableTip = false } label: {
Text("知道了").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12)
}.buttonStyle(.borderedProminent).tint(.blue).padding(.horizontal, 16).padding(.bottom, 8)
VStack(spacing: 8) {
Button { showEnableTip = false } label: {
Text("知道了").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12)
}.buttonStyle(.borderedProminent).tint(.blue)
Button(role: .destructive) {
activationTipDisabled = true
showEnableTip = false
} label: {
Label("关闭不再弹出", systemImage: "bell.slash").frame(maxWidth: .infinity)
}.buttonStyle(.bordered)
}.padding(.horizontal, 16).padding(.bottom, 8)
}
}
}
+19 -1
View File
@@ -231,6 +231,20 @@ final class MapLocationState: ObservableObject {
viewportMeters = max(50, distanceMeters)
}
/// Updates only the map representation of the current physical selection.
/// This preserves revision/source so an in-flight user action is not invalidated.
func reprojectSelectionForTileChange(_ coordinate: CLLocationCoordinate2D) {
guard CLLocationCoordinate2DIsValid(coordinate),
!selection.coordinate.isApproximatelyEqual(to: coordinate) else { return }
selection = MapSelection(
coordinate: coordinate,
source: selection.source,
explicitName: selection.explicitName,
revision: selection.revision
)
issueCameraCommand(.focus(coordinate: coordinate, distanceMeters: viewportMeters))
}
@discardableResult
func acceptPlaceDescriptor(_ descriptor: MapPlaceDescriptor, selectionRevision: UInt64) -> Bool {
guard selection.revision == selectionRevision, selection.explicitName == nil else { return false }
@@ -292,17 +306,21 @@ enum ViewportStore {
private static let key = "mapViewportMeters"
static func save(_ meters: CLLocationDistance) {
UserDefaults.standard.set(meters, forKey: key)
RuntimeLogger.info("APP", "缩放", "存储缩放", details: ["zoom": String(meters)])
}
/// 取持久化缩放值;未存过返回 nil
static func load() -> CLLocationDistance? {
let v = UserDefaults.standard.double(forKey: key)
return v > 0 ? v : nil
let result = v > 0 ? v : nil
RuntimeLogger.info("APP", "缩放", "读取缩放", details: ["zoom": result.map { String($0) } ?? "nil"])
return result
}
/// 取持久化缩放值,取不到返回默认 1km 并立即存储
static func loadOrDefault() -> CLLocationDistance {
if let v = load() { return v }
let fallback: CLLocationDistance = 1_000
save(fallback)
RuntimeLogger.info("APP", "缩放", "使用默认缩放 1km")
return fallback
}
}
+14 -7
View File
@@ -48,6 +48,9 @@ struct MapViewRepresentable: UIViewRepresentable {
map.delegate = context.coordinator
map.showsUserLocation = true
let initialDistance = ViewportStore.loadOrDefault()
RuntimeLogger.info("APP", "地图", "makeUIView", details: [
"zoom": String(initialDistance)
])
map.setRegion(
MKCoordinateRegion(
center: selection.coordinate,
@@ -156,6 +159,11 @@ struct MapViewRepresentable: UIViewRepresentable {
private let pinSize: CGFloat = 38
// 蓝点实际大小从 MKUserLocationView 取,默认 20pt
private var userDotDiameter: CGFloat = 20
private var keyboardObserverTokens: [NSObjectProtocol] = []
deinit {
keyboardObserverTokens.forEach(NotificationCenter.default.removeObserver)
}
@objc func zoomInTapped() { parent.onZoomIn?() }
@objc func zoomOutTapped() { parent.onZoomOut?() }
@@ -175,15 +183,16 @@ struct MapViewRepresentable: UIViewRepresentable {
}
func setupKeyboardObservers() {
guard keyboardObserverTokens.isEmpty else { return }
let nc = NotificationCenter.default
nc.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { [weak self] _ in
keyboardObserverTokens.append(nc.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { [weak self] _ in
guard let self, let map = self.map else { return }
self.updatePinPosition(on: map)
}
nc.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { [weak self] _ in
})
keyboardObserverTokens.append(nc.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { [weak self] _ in
guard let self, let map = self.map else { return }
self.updatePinPosition(on: map)
}
})
}
func consume(_ command: MapCameraCommand?, on map: MKMapView) {
@@ -197,8 +206,7 @@ struct MapViewRepresentable: UIViewRepresentable {
let region: MKCoordinateRegion
switch command.kind {
case let .focus(coordinate, _):
// 保持当前 span 不变,只移动中心点,避免 latitudinalMeters
// 与 visibleVerticalDistance 之间因屏幕宽高比引入 2x 漂移
// 保持当前 span 不变,避免正方形 region 在竖屏 inflate
region = MKCoordinateRegion(center: coordinate, span: map.region.span)
case let .zoom(factor):
region = MKCoordinateRegion(
@@ -260,7 +268,6 @@ struct MapViewRepresentable: UIViewRepresentable {
parent.onViewportChanged(distance)
zoomLabel?.text = MapZoomMath.viewportScaleLabel(distanceMeters: distance)
updatePinPosition(on: mapView)
// 同步蓝点坐标
// 同步蓝点坐标(避免 delegate 更新不及时导致 mapState.realtimeLocation 为 nil)
if let ul = mapView.userLocation.location,
CLLocationCoordinate2DIsValid(ul.coordinate), ul.horizontalAccuracy >= 0 {
+4 -5
View File
@@ -25,9 +25,9 @@ final class ProxyManager: ObservableObject {
let lon = settings.flatMap { $0.enabled ? $0.longitude : nil } ?? 0
let enabled = (settings?.enabled ?? false) ? CInt(1) : CInt(0)
let accuracy = CInt(settings?.accuracy ?? 25)
RuntimeLogger.info("APP", "坐标转换", "启动代理: WGS-84", details: [
"lat": String(lat), "lon": String(lon)
])
if enabled != 0 {
RuntimeLogger.info("APP", "坐标转换", "启动代理: 恢复上次 WGS-84 定位")
}
let result: UInt = authority.certPEM.withCString { cp in
authority.keyPEM.withCString { kp in
UInt(wloccore_startproxy(UnsafeMutablePointer(mutating: cp), UnsafeMutablePointer(mutating: kp), CDouble(lat), CDouble(lon), enabled, accuracy))
@@ -62,8 +62,7 @@ final class ProxyManager: ObservableObject {
RuntimeLogger.info("APP", "Proxy.coords", "写入坐标", details: [
"revision": String(coordinateRevision),
"enabled": String(enabled),
"lat": String(lat),
"lon": String(lon)
"accuracy": String(accuracy)
])
return coordinateRevision
}
+2 -4
View File
@@ -280,11 +280,9 @@ final class RealtimeLocationManager: NSObject, ObservableObject, CLLocationManag
activeRequest = nil
isRequesting = false
if let coordinate {
if coordinate != nil {
RuntimeLogger.info("APP", "定位", "获取到实时定位", details: [
"requestID": String(requestID),
"lat": String(coordinate.latitude),
"lon": String(coordinate.longitude)
"requestID": String(requestID)
])
} else {
RuntimeLogger.warning("APP", "定位", "实时定位请求结束但没有坐标", details: ["requestID": String(requestID)])
+2 -6
View File
@@ -28,6 +28,7 @@ final class SetupCoordinator: ObservableObject {
trustState = .checking
message = "正在检测…"
testLog = ""
defer { needsSetup = !canModify }
do {
_ = try certificateStore.ensure()
if !proxy.isRunning { try await proxy.start() }
@@ -40,11 +41,7 @@ final class SetupCoordinator: ObservableObject {
kCFNetworkProxiesHTTPProxy as String: "127.0.0.1",
kCFNetworkProxiesHTTPPort as String: 8888,
]
// 用当前保存的虚拟定位坐标做测试;没有则用默认坐标
let saved = WlocSettingsStore.load()
let testLat = saved?.latitude ?? 22.543099
let testLon = saved?.longitude ?? 113.934576
let testAccuracy = saved?.accuracy ?? 25
// This probe verifies proxy reachability and TLS trust only; it does not validate a selected coordinate.
let req = makeWlocRequest()
let (_, resp) = try await URLSession(configuration: config).data(for: req)
let status = (resp as? HTTPURLResponse)?.statusCode ?? 0
@@ -69,7 +66,6 @@ final class SetupCoordinator: ObservableObject {
message = "检测失败 [\(ns.domain) \(ns.code)]: \(ns.localizedDescription)"
}
}
needsSetup = !canModify
}
func sceneDidBecomeActive() {}