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
+11 -1
View File
@@ -17,7 +17,7 @@ final class BackgroundKeepAlive {
guard isActive, let info = notification.userInfo,
let type = info[AVAudioSessionInterruptionTypeKey] as? UInt,
type == AVAudioSession.InterruptionType.ended.rawValue else { return }
start()
restartAfterInterruption()
RuntimeLogger.info("APP", "KeepAlive", "音频中断恢复")
}
@@ -50,6 +50,16 @@ final class BackgroundKeepAlive {
RuntimeLogger.info("APP", "KeepAlive", "后台保活已启动(静音音频)")
}
private func restartAfterInterruption() {
guard isActive else { return }
playerNode?.stop()
engine?.stop()
playerNode = nil
engine = nil
isActive = false
start()
}
func stop() {
guard isActive else { return }
isActive = false
+13 -2
View File
@@ -5,8 +5,9 @@ final class CertificateTrustVerifier {
/// Check whether a CA certificate (given as PEM data) is installed and fully trusted
/// by the system. Uses SecTrust evaluation against system anchors only no network needed.
static func isCACertificateTrusted(certPEM: String) -> Bool {
guard let certData = certPEM.data(using: .utf8),
let cert = SecCertificateCreateWithData(nil, certData as CFData) else {
guard let pemData = certPEM.data(using: .utf8),
let block = pemData.pemCertificateBlock,
let cert = SecCertificateCreateWithData(nil, block as CFData) else {
RuntimeLogger.error("APP", "Trust", "无法解析 CA 证书 PEM")
return false
}
@@ -38,3 +39,13 @@ final class CertificateTrustVerifier {
return result
}
}
private extension Data {
var pemCertificateBlock: Data? {
guard let text = String(data: self, encoding: .utf8),
let begin = text.range(of: "-----BEGIN CERTIFICATE-----"),
let end = text.range(of: "-----END CERTIFICATE-----") else { return nil }
let body = text[begin.upperBound..<end.lowerBound].components(separatedBy: .whitespacesAndNewlines).joined()
return Data(base64Encoded: body)
}
}
+195 -48
View File
@@ -1,5 +1,6 @@
import Foundation
import CoreLocation
import MapKit
/// GCJ-02 () WGS-84
///
@@ -18,33 +19,111 @@ enum CoordinateConverter {
// MARK: -
///
struct TileTypeChange: Equatable {
let previous: CoordType
let current: CoordType
}
/// 使 GCJ-02
@MainActor static var currentTileType = CoordType.gcj02
///
@MainActor private static var lastTileCheck: Date?
@MainActor private static var tileCheckPending = false
/// ""=GCJ-02=WGS-8430s
/// 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
static func detectTileByFixedGeocode() {
guard !tileCheckPending else { return }
if let last = lastTileCheck, -last.timeIntervalSinceNow < 30 { return }
tileCheckPending = true
let loc = CLLocation(latitude: 22.283819, longitude: 114.158439)
CLGeocoder().reverseGeocodeLocation(loc) { placemarks, _ in
Task { @MainActor in
defer { tileCheckPending = false }
let name = placemarks?.first?.name ?? ""
let newType: CoordType = (name == "林士街") ? .gcj02 : .wgs84
RuntimeLogger.info("APP", "坐标转换", "固定坐标反查 → \(newType.rawValue)", details: [
"名称": name
])
if newType != currentTileType {
currentTileType = newType
}
lastTileCheck = Date()
}
@discardableResult
static func detectTileByFixedGeocode(force: Bool = false) async -> TileTypeChange? {
guard !tileCheckPending else {
RuntimeLogger.info("APP", "坐标转换", "瓦片检测: 跳过(进行中)")
return nil
}
if !force, let last = lastTileCheck, -last.timeIntervalSinceNow < 30 {
RuntimeLogger.info("APP", "坐标转换", "瓦片检测: 跳过(缓存\(Int(-last.timeIntervalSinceNow))s)")
return nil
}
tileCheckPending = true
defer { tileCheckPending = false }
RuntimeLogger.info("APP", "坐标转换", "瓦片检测: 发起查询", details: [
"force": String(force),
"当前瓦片": currentTileType.rawValue
])
let probeResult = await fixedGeocodeProbe()
guard !Task.isCancelled else { return nil }
lastTileCheck = Date()
let nextType: CoordType
switch probeResult {
case let .response(name, count):
nextType = name == "林士街" ? .gcj02 : .wgs84
RuntimeLogger.info("APP", "坐标转换", "瓦片检测完成 → \(nextType.rawValue)", details: [
"结果数": String(count),
"命中锚点": String(nextType == .gcj02)
])
case .unavailable:
nextType = .gcj02
RuntimeLogger.warning("APP", "坐标转换", "瓦片检测无结果,回退 GCJ-02")
case .timedOut:
nextType = .gcj02
RuntimeLogger.warning("APP", "坐标转换", "瓦片检测超时,回退 GCJ-02")
case .cancelled:
return nil
}
guard nextType != currentTileType else { return nil }
let change = TileTypeChange(previous: currentTileType, current: nextType)
currentTileType = nextType
return change
}
/// Reprojects a map-display coordinate after the tile heuristic changes.
/// The physical coordinate remains WGS-84 in between the two display modes.
static func reprojectDisplayCoordinate(
_ coordinate: CLLocationCoordinate2D,
from previous: CoordType,
to current: CoordType
) -> CLLocationCoordinate2D {
guard previous != current else { return coordinate }
let stored = storedCoordinate(lat: coordinate.latitude, lon: coordinate.longitude, tileType: previous)
let display = displayCoordinate(lat: stored.lat, lon: stored.lon, tileType: current)
return CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon)
}
private static func fixedGeocodeProbe() async -> TileProbeResult {
let request = MKLocalSearch.Request()
request.naturalLanguageQuery = "22.283819, 114.158439"
let search = MKLocalSearch(request: request)
let resolver = TileProbeResolver()
return await withTaskCancellationHandler(operation: {
await withCheckedContinuation { continuation in
let timeout = DispatchWorkItem {
search.cancel()
resolver.resolve(.timedOut)
}
resolver.install(continuation, timeout: timeout)
guard !resolver.isResolved else { return }
search.start { response, error in
guard error == nil else {
resolver.resolve(.unavailable)
return
}
resolver.resolve(.response(
name: response?.mapItems.first?.name ?? "",
count: response?.mapItems.count ?? 0
))
}
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: timeout)
}
}, onCancel: {
search.cancel()
resolver.resolve(.cancelled)
})
}
// MARK: -
@@ -52,41 +131,43 @@ enum CoordinateConverter {
/// WGS-84
@MainActor
static func toStored(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
detectTileByFixedGeocode()
guard currentTileType == .gcj02 else {
RuntimeLogger.info("APP", "坐标转换", "toStored: 不转 瓦片=\(currentTileType.rawValue)", details: [
"lat": String(lat), "lon": String(lon)
])
return (lat, lon)
}
let wgs = gcj02ToWgs84(lat: lat, lon: lon)
let d = distance(lat1: lat, lon1: lon, lat2: wgs.lat, lon2: wgs.lon)
RuntimeLogger.info("APP", "坐标转换", "toStored: GCJ-02 → WGS-84 瓦片=\(currentTileType.rawValue)", details: [
"原始": "\(lat), \(lon)",
"结果": "\(wgs.lat), \(wgs.lon)",
"偏移": String(format: "%.0fm", d)
let stored = storedCoordinate(lat: lat, lon: lon, tileType: currentTileType)
RuntimeLogger.info("APP", "坐标转换", "地图坐标已规范为 WGS-84", details: [
"转换": String(currentTileType == .gcj02 && usesGCJ02ServiceArea(lat: lat, lon: lon))
])
return wgs
return stored
}
/// WGS-84
@MainActor
static func toDisplay(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
detectTileByFixedGeocode()
guard currentTileType == .gcj02 else {
RuntimeLogger.info("APP", "坐标转换", "toDisplay: WGS-84 → 不转 瓦片=\(currentTileType.rawValue)", details: [
"lat": String(lat), "lon": String(lon)
])
let display = displayCoordinate(lat: lat, lon: lon, tileType: currentTileType)
RuntimeLogger.info("APP", "坐标转换", "WGS-84 坐标已适配地图显示", details: [
"转换": String(currentTileType == .gcj02 && usesGCJ02ServiceArea(lat: lat, lon: lon))
])
return display
}
private static func storedCoordinate(
lat: Double,
lon: Double,
tileType: CoordType
) -> (lat: Double, lon: Double) {
guard tileType == .gcj02, usesGCJ02ServiceArea(lat: lat, lon: lon) else {
return (lat, lon)
}
let gcj = wgs84ToGcj02(lat: lat, lon: lon)
let d = distance(lat1: lat, lon1: lon, lat2: gcj.lat, lon2: gcj.lon)
RuntimeLogger.info("APP", "坐标转换", "toDisplay: WGS-84 → GCJ-02 瓦片=\(currentTileType.rawValue)", details: [
"原始": "\(lat), \(lon)",
"结果": "\(gcj.lat), \(gcj.lon)",
"偏移": String(format: "%.0fm", d)
])
return gcj
return gcj02ToWgs84(lat: lat, lon: lon)
}
private static func displayCoordinate(
lat: Double,
lon: Double,
tileType: CoordType
) -> (lat: Double, lon: Double) {
guard tileType == .gcj02, usesGCJ02ServiceArea(lat: lat, lon: lon) else {
return (lat, lon)
}
return wgs84ToGcj02(lat: lat, lon: lon)
}
// MARK: -
@@ -106,6 +187,7 @@ enum CoordinateConverter {
/// GCJ-02 WGS-84 0.5
static func gcj02ToWgs84(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
guard usesGCJ02ServiceArea(lat: lat, lon: lon) else { return (lat, lon) }
var wgsLat = lat
var wgsLon = lon
for _ in 0..<2 {
@@ -118,10 +200,22 @@ enum CoordinateConverter {
/// WGS-84 GCJ-02
static func wgs84ToGcj02(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
guard usesGCJ02ServiceArea(lat: lat, lon: lon) else { return (lat, lon) }
let d = delta(lat: lat, lon: lon)
return (lat + d.lat, lon + d.lon)
}
/// AMap documents GCJ-02 for mainland China, Hong Kong, Macao and Taiwan;
/// its overseas world map uses WGS-84. Keep the bounds explicit so the
/// domestic fallback tile type never shifts an overseas coordinate.
static func usesGCJ02ServiceArea(lat: Double, lon: Double) -> Bool {
let mainland = lat >= 0.8293 && lat <= 55.8271 && lon >= 72.004 && lon <= 137.8347
let hongKong = lat >= 22.13 && lat <= 22.57 && lon >= 113.82 && lon <= 114.45
let macao = lat >= 22.05 && lat <= 22.25 && lon >= 113.52 && lon <= 113.65
let taiwan = lat >= 21.75 && lat <= 25.35 && lon >= 119.30 && lon <= 122.10
return mainland || hongKong || macao || taiwan
}
// MARK: -
/// (WGS-84 GCJ-02 )
@@ -154,3 +248,56 @@ enum CoordinateConverter {
return ret
}
}
private enum TileProbeResult {
case response(name: String, count: Int)
case unavailable
case timedOut
case cancelled
}
private final class TileProbeResolver: @unchecked Sendable {
private let lock = NSLock()
private var result: TileProbeResult?
private var continuation: CheckedContinuation<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)
}
}
+22 -4
View File
@@ -10,8 +10,8 @@ final class NetworkMonitor: ObservableObject {
@Published private(set) var isWiFiEnabled = true
@Published private(set) var currentSSID: String?
/// WiFi SSID 使
var onWiFiChanged: (() -> Void)?
/// WiFi SSID
private var wifiChangeHandlers: [UUID: @MainActor () -> Void] = [:]
private let monitor = NWPathMonitor()
private var ssidTimer: Timer?
@@ -29,7 +29,7 @@ final class NetworkMonitor: ObservableObject {
self.isSatisfied = satisfied
self.isWiFiEnabled = wifi
if reconnected {
self.onWiFiChanged?()
self.notifyWiFiChanged()
}
}
}
@@ -39,6 +39,24 @@ final class NetworkMonitor: ObservableObject {
var isAirplaneMode: Bool { !isSatisfied }
/// Registers a Wi-Fi-change observer and returns a token that must be removed.
@discardableResult
func observeWiFiChanges(_ handler: @escaping @MainActor () -> Void) -> UUID {
let token = UUID()
wifiChangeHandlers[token] = handler
return token
}
func removeWiFiChangeObserver(_ token: UUID) {
wifiChangeHandlers.removeValue(forKey: token)
}
private func notifyWiFiChanged() {
for handler in wifiChangeHandlers.values {
handler()
}
}
private func startSSIDPolling() {
ssidTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
Task { @MainActor in
@@ -46,7 +64,7 @@ final class NetworkMonitor: ObservableObject {
let ssid = Self.fetchSSID()
if ssid != self.currentSSID, ssid != nil {
self.currentSSID = ssid
self.onWiFiChanged?()
self.notifyWiFiChanged()
}
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ enum VerificationResult: Equatable, Identifiable {
switch self {
case .success: return nil
case .proxyNotRunning, .verificationInProgress, .verificationSuperseded: return nil
case .certNotTrusted: return nil // tip
case .certNotTrusted: return .certificate
case .wifiProxyNotConfigured: return .proxySetup
case .coordinateWriteFailed, .patchFailed: return .rewriteFailed
}