mirror of
https://github.com/xweiba/location-spoofer.git
synced 2026-09-21 22:30:46 +08:00
fix: 优化启动引导、坐标处理和日志管理
This commit is contained in:
@@ -43,3 +43,76 @@ enum WlocSettingsStore {
|
||||
save(WlocSettings(longitude: 0, latitude: 0, accuracy: 25, enabled: false))
|
||||
}
|
||||
}
|
||||
|
||||
enum VirtualLocationTipKind: Equatable {
|
||||
case activation
|
||||
case deactivation
|
||||
}
|
||||
|
||||
/// Owns the persistent counters and suppression flags for automatic operation tips.
|
||||
/// Manual help sheets do not consult or mutate this store.
|
||||
struct VirtualLocationTipPreferences {
|
||||
static let minimumCountForSuppression = 3
|
||||
|
||||
private enum Key {
|
||||
static let activationCount = "virtualLocationTip.activationCount"
|
||||
static let deactivationCount = "virtualLocationTip.deactivationCount"
|
||||
static let activationSuppressed = "virtualLocationTip.activationSuppressed"
|
||||
static let deactivationSuppressed = "virtualLocationTip.deactivationSuppressed"
|
||||
static let legacyActivationSuppressed = "activationTipDisabled"
|
||||
}
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let legacyDefaults: UserDefaults
|
||||
|
||||
init(
|
||||
defaults: UserDefaults = AppGroup.defaults,
|
||||
legacyDefaults: UserDefaults = .standard
|
||||
) {
|
||||
self.defaults = defaults
|
||||
self.legacyDefaults = legacyDefaults
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func recordSuccessfulOperation(_ kind: VirtualLocationTipKind) -> Int {
|
||||
let key = countKey(for: kind)
|
||||
let next = defaults.integer(forKey: key) + 1
|
||||
defaults.set(next, forKey: key)
|
||||
return next
|
||||
}
|
||||
|
||||
func shouldPresentAutomaticTip(_ kind: VirtualLocationTipKind) -> Bool {
|
||||
!isSuppressed(kind)
|
||||
}
|
||||
|
||||
func canSuppress(_ kind: VirtualLocationTipKind) -> Bool {
|
||||
defaults.integer(forKey: countKey(for: kind)) >= Self.minimumCountForSuppression
|
||||
}
|
||||
|
||||
func suppress(_ kind: VirtualLocationTipKind) {
|
||||
guard canSuppress(kind) else { return }
|
||||
defaults.set(true, forKey: suppressionKey(for: kind))
|
||||
}
|
||||
|
||||
private func isSuppressed(_ kind: VirtualLocationTipKind) -> Bool {
|
||||
if kind == .activation,
|
||||
legacyDefaults.bool(forKey: Key.legacyActivationSuppressed) {
|
||||
return true
|
||||
}
|
||||
return defaults.bool(forKey: suppressionKey(for: kind))
|
||||
}
|
||||
|
||||
private func countKey(for kind: VirtualLocationTipKind) -> String {
|
||||
switch kind {
|
||||
case .activation: return Key.activationCount
|
||||
case .deactivation: return Key.deactivationCount
|
||||
}
|
||||
}
|
||||
|
||||
private func suppressionKey(for kind: VirtualLocationTipKind) -> String {
|
||||
switch kind {
|
||||
case .activation: return Key.activationSuppressed
|
||||
case .deactivation: return Key.deactivationSuppressed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,178 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
protocol CertificateAuthorityKeychain {
|
||||
func load() throws -> CertificateAuthority?
|
||||
func save(_ authority: CertificateAuthority) throws
|
||||
func remove() throws
|
||||
}
|
||||
|
||||
enum CertificateAuthorityStoreError: LocalizedError {
|
||||
case invalidAuthority
|
||||
case keychain(OSStatus)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidAuthority: return "本地 CA 证书或私钥无效"
|
||||
case let .keychain(status): return "无法写入设备钥匙串(\(status))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class DeviceCertificateAuthorityKeychain: CertificateAuthorityKeychain {
|
||||
private enum Item {
|
||||
static let service = "com.paopaolabs.location-spoofer.certificate-authority"
|
||||
static let certificateAccount = "root-ca-certificate"
|
||||
static let keyAccount = "root-ca-private-key"
|
||||
}
|
||||
|
||||
func load() throws -> CertificateAuthority? {
|
||||
guard let certPEM = try load(account: Item.certificateAccount),
|
||||
let keyPEM = try load(account: Item.keyAccount) else {
|
||||
return nil
|
||||
}
|
||||
return CertificateAuthority(certPEM: certPEM, keyPEM: keyPEM)
|
||||
}
|
||||
|
||||
func save(_ authority: CertificateAuthority) throws {
|
||||
try remove()
|
||||
do {
|
||||
try save(authority.certPEM, account: Item.certificateAccount)
|
||||
try save(authority.keyPEM, account: Item.keyAccount)
|
||||
} catch {
|
||||
try? remove()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func remove() throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: Item.service,
|
||||
kSecAttrSynchronizable as String: kCFBooleanFalse as Any,
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw CertificateAuthorityStoreError.keychain(status)
|
||||
}
|
||||
}
|
||||
|
||||
private func load(account: String) throws -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: Item.service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrSynchronizable as String: kCFBooleanFalse as Any,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return nil }
|
||||
guard status == errSecSuccess, let data = result as? Data,
|
||||
let value = String(data: data, encoding: .utf8) else {
|
||||
throw CertificateAuthorityStoreError.keychain(status)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private func save(_ value: String, account: String) throws {
|
||||
guard let data = value.data(using: .utf8) else {
|
||||
throw CocoaError(.fileWriteInapplicableStringEncoding)
|
||||
}
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: Item.service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
kSecAttrSynchronizable as String: kCFBooleanFalse as Any,
|
||||
kSecValueData as String: data,
|
||||
]
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
guard status == errSecSuccess else { throw CertificateAuthorityStoreError.keychain(status) }
|
||||
}
|
||||
}
|
||||
|
||||
final class CertificateAuthorityStore {
|
||||
private let directory: URL
|
||||
private let generator: () throws -> CertificateAuthority
|
||||
private let validator: (CertificateAuthority) -> Bool
|
||||
private let keychain: CertificateAuthorityKeychain
|
||||
private let certificateURL: URL
|
||||
private let keyURL: URL
|
||||
|
||||
init(directory: URL = AppGroup.containerURL.appendingPathComponent("CertificateAuthority", isDirectory: true), generator: @escaping () throws -> CertificateAuthority = CoreBridge.generateCertificateAuthority) {
|
||||
init(
|
||||
directory: URL = AppGroup.containerURL.appendingPathComponent("CertificateAuthority", isDirectory: true),
|
||||
keychain: CertificateAuthorityKeychain = DeviceCertificateAuthorityKeychain(),
|
||||
generator: @escaping () throws -> CertificateAuthority = CoreBridge.generateCertificateAuthority,
|
||||
validator: @escaping (CertificateAuthority) -> Bool = CoreBridge.isValidCertificateAuthority
|
||||
) {
|
||||
self.directory = directory
|
||||
self.keychain = keychain
|
||||
self.generator = generator
|
||||
self.validator = validator
|
||||
self.certificateURL = directory.appendingPathComponent("ca-cert.pem")
|
||||
self.keyURL = directory.appendingPathComponent("ca-key.pem")
|
||||
}
|
||||
|
||||
func ensure() throws -> CertificateAuthority {
|
||||
if let current = try load() {
|
||||
RuntimeLogger.debug("SHARED", "Certificate.store", "读取已有 CA 文件", details: ["directory": directory.path])
|
||||
return current
|
||||
if let authority = try loadValidKeychainAuthority() {
|
||||
removeLegacyFilesBestEffort()
|
||||
RuntimeLogger.debug("SHARED", "Certificate.store", "复用设备钥匙串中的 CA")
|
||||
return authority
|
||||
}
|
||||
RuntimeLogger.info("SHARED", "Certificate.store", "未找到 CA 文件,开始生成", details: ["directory": directory.path])
|
||||
|
||||
if let legacy = try loadLegacyAuthority(), validator(legacy) {
|
||||
try keychain.save(legacy)
|
||||
removeLegacyFilesBestEffort()
|
||||
RuntimeLogger.info("SHARED", "Certificate.store", "旧 CA 已迁移到设备钥匙串")
|
||||
return legacy
|
||||
}
|
||||
|
||||
let authority = try generator()
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
guard let certificateData = authority.certPEM.data(using: .utf8),
|
||||
let keyData = authority.keyPEM.data(using: .utf8) else {
|
||||
throw CocoaError(.fileWriteInapplicableStringEncoding)
|
||||
}
|
||||
try certificateData.write(to: certificateURL, options: .atomic)
|
||||
try keyData.write(to: keyURL, options: .atomic)
|
||||
applyCompleteProtection(to: certificateURL)
|
||||
applyCompleteProtection(to: keyURL)
|
||||
RuntimeLogger.info("SHARED", "Certificate.store", "CA 文件写入完成", details: ["directory": directory.path])
|
||||
guard validator(authority) else { throw CertificateAuthorityStoreError.invalidAuthority }
|
||||
try keychain.save(authority)
|
||||
removeLegacyFilesBestEffort()
|
||||
RuntimeLogger.info("SHARED", "Certificate.store", "已生成并保存设备专属 CA")
|
||||
return authority
|
||||
}
|
||||
|
||||
func load() throws -> CertificateAuthority? {
|
||||
guard FileManager.default.fileExists(atPath: certificateURL.path), FileManager.default.fileExists(atPath: keyURL.path) else { return nil }
|
||||
return CertificateAuthority(certPEM: try String(contentsOf: certificateURL, encoding: .utf8), keyPEM: try String(contentsOf: keyURL, encoding: .utf8))
|
||||
try loadValidKeychainAuthority()
|
||||
}
|
||||
|
||||
private func applyCompleteProtection(to url: URL) {
|
||||
#if os(iOS)
|
||||
try? FileManager.default.setAttributes([.protectionKey: FileProtectionType.complete], ofItemAtPath: url.path)
|
||||
#endif
|
||||
private func loadValidKeychainAuthority() throws -> CertificateAuthority? {
|
||||
guard let authority = try keychain.load() else { return nil }
|
||||
guard validator(authority) else {
|
||||
RuntimeLogger.warning("SHARED", "Certificate.store", "钥匙串中的 CA 无效,准备回退")
|
||||
try? keychain.remove()
|
||||
return nil
|
||||
}
|
||||
return authority
|
||||
}
|
||||
|
||||
private func loadLegacyAuthority() throws -> CertificateAuthority? {
|
||||
guard FileManager.default.fileExists(atPath: certificateURL.path),
|
||||
FileManager.default.fileExists(atPath: keyURL.path) else {
|
||||
return nil
|
||||
}
|
||||
return CertificateAuthority(
|
||||
certPEM: try String(contentsOf: certificateURL, encoding: .utf8),
|
||||
keyPEM: try String(contentsOf: keyURL, encoding: .utf8)
|
||||
)
|
||||
}
|
||||
|
||||
private func removeLegacyFilesBestEffort() {
|
||||
let fileManager = FileManager.default
|
||||
// Remove private material first. Each item is retried on later launches
|
||||
// when a valid Keychain authority is available.
|
||||
for url in [keyURL, certificateURL] where fileManager.fileExists(atPath: url.path) {
|
||||
do {
|
||||
try fileManager.removeItem(at: url)
|
||||
} catch {
|
||||
RuntimeLogger.error("SHARED", "Certificate.store", "删除旧 CA 文件失败,将在下次启动重试", error: error)
|
||||
}
|
||||
}
|
||||
try? fileManager.removeItem(at: directory)
|
||||
}
|
||||
}
|
||||
|
||||
+180
-121
@@ -2,121 +2,199 @@ import Foundation
|
||||
import CoreLocation
|
||||
import MapKit
|
||||
|
||||
struct CoordinatePair: Codable, Equatable {
|
||||
static let currentConversionVersion = 1
|
||||
|
||||
struct Value: Codable, Equatable {
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
|
||||
var coordinate: CLLocationCoordinate2D {
|
||||
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
||||
}
|
||||
}
|
||||
|
||||
let wgs84: Value
|
||||
let gcj02: Value
|
||||
let conversionVersion: Int
|
||||
|
||||
init(wgs84: Value, gcj02: Value, conversionVersion: Int = CoordinatePair.currentConversionVersion) {
|
||||
self.wgs84 = wgs84
|
||||
self.gcj02 = gcj02
|
||||
self.conversionVersion = conversionVersion
|
||||
}
|
||||
|
||||
init(mapCoordinate: CLLocationCoordinate2D, mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem) {
|
||||
self = CoordinateConverter.coordinatePair(
|
||||
lat: mapCoordinate.latitude,
|
||||
lon: mapCoordinate.longitude,
|
||||
mapCoordinateSystem: mapCoordinateSystem
|
||||
)
|
||||
}
|
||||
|
||||
func coordinate(for mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem) -> CLLocationCoordinate2D {
|
||||
switch mapCoordinateSystem {
|
||||
case .wgs84: return wgs84.coordinate
|
||||
case .gcj02: return gcj02.coordinate
|
||||
}
|
||||
}
|
||||
|
||||
func matchesWGS84(latitude: Double, longitude: Double, tolerance: Double = 0.0001) -> Bool {
|
||||
abs(wgs84.latitude - latitude) <= tolerance
|
||||
&& abs(wgs84.longitude - longitude) <= tolerance
|
||||
}
|
||||
}
|
||||
|
||||
/// GCJ-02 (火星坐标) ↔ WGS-84 坐标转换。
|
||||
///
|
||||
/// MKMapView 根据实时定位动态切换瓦片源:中国境内用高德 GCJ-02,境外用 Apple WGS-84。
|
||||
/// App 内部统一以 WGS-84 存储,仅在地图交互时按当前瓦片类型双向转换。
|
||||
/// MapKit does not expose a public API for its active coordinate reference system.
|
||||
/// The app resolves it with a bounded heuristic, then stores both WGS-84 and
|
||||
/// GCJ-02 representations at each write boundary so replay does not convert again.
|
||||
enum CoordinateConverter {
|
||||
// 椭球参数 (Krasovsky 1940)
|
||||
private static let a = 6378245.0
|
||||
private static let ee = 0.00669342162296594323
|
||||
|
||||
/// 坐标类型
|
||||
enum CoordType: String {
|
||||
enum MapCoordinateSystem: String {
|
||||
case gcj02 = "GCJ-02"
|
||||
case wgs84 = "WGS-84"
|
||||
}
|
||||
|
||||
// MARK: - 全局瓦片类型
|
||||
// MARK: - 地图坐标标准
|
||||
|
||||
struct TileTypeChange: Equatable {
|
||||
let previous: CoordType
|
||||
let current: CoordType
|
||||
struct MapCoordinateSystemChange: Equatable {
|
||||
let previous: MapCoordinateSystem
|
||||
let current: MapCoordinateSystem
|
||||
}
|
||||
|
||||
/// 当前地图瓦片坐标系。探测不可用时使用国内 GCJ-02 作为兜底。
|
||||
@MainActor static var currentTileType = CoordType.gcj02
|
||||
@MainActor private static var lastTileCheck: Date?
|
||||
@MainActor private static var tileCheckPending = false
|
||||
/// 当前 Apple 地图坐标标准。检测不可用时使用国内 GCJ-02 作为兜底。
|
||||
@MainActor static var currentMapCoordinateSystem = MapCoordinateSystem.gcj02
|
||||
@MainActor private static var mapCoordinateSystemCheckPending = false
|
||||
@MainActor private(set) static var initialMapCoordinateSystemUsedFallback = true
|
||||
|
||||
/// 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.
|
||||
/// Startup gate: only request realtime location if the public MapKit probe
|
||||
/// cannot resolve a coordinate type. This guarantees a finite answer before
|
||||
/// persisted map positions are replayed.
|
||||
@MainActor
|
||||
@discardableResult
|
||||
static func detectTileByFixedGeocode(force: Bool = false) async -> TileTypeChange? {
|
||||
guard !tileCheckPending else {
|
||||
RuntimeLogger.info("APP", "坐标转换", "瓦片检测: 跳过(进行中)")
|
||||
return nil
|
||||
static func resolveInitialMapCoordinateSystem() async -> MapCoordinateSystem {
|
||||
guard !mapCoordinateSystemCheckPending else {
|
||||
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测已有请求进行中")
|
||||
return currentMapCoordinateSystem
|
||||
}
|
||||
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
|
||||
mapCoordinateSystemCheckPending = true
|
||||
defer { mapCoordinateSystemCheckPending = false }
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准检测开始", details: [
|
||||
"锚点": "22.283819,114.158439",
|
||||
"判定规则": "首条名称=林士街→GCJ-02,否则→WGS-84",
|
||||
"缓存": "false"
|
||||
])
|
||||
|
||||
let probeResult = await fixedGeocodeProbe()
|
||||
guard !Task.isCancelled else { return nil }
|
||||
lastTileCheck = Date()
|
||||
|
||||
let nextType: CoordType
|
||||
switch probeResult {
|
||||
let nextType: MapCoordinateSystem
|
||||
switch await fixedAnchorCoordinateSystemProbe() {
|
||||
case let .response(name, count):
|
||||
nextType = name == "林士街" ? .gcj02 : .wgs84
|
||||
RuntimeLogger.info("APP", "坐标转换", "瓦片检测完成 → \(nextType.rawValue)", details: [
|
||||
initialMapCoordinateSystemUsedFallback = false
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准检测获得明确结果", details: [
|
||||
"首条名称": name,
|
||||
"结果数": String(count),
|
||||
"命中锚点": String(nextType == .gcj02)
|
||||
"命中林士街": String(name == "林士街"),
|
||||
"最终标准": nextType.rawValue,
|
||||
"结果来源": "固定锚点"
|
||||
])
|
||||
case .unavailable(let reason), .timedOut(let reason):
|
||||
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测不可用,开始实时定位兜底", details: [
|
||||
"原因": reason
|
||||
])
|
||||
let realtime = await RealtimeLocationManager.shared.requestLocation()
|
||||
if let realtime,
|
||||
CLLocationCoordinate2DIsValid(realtime),
|
||||
!usesGCJ02ServiceArea(lat: realtime.latitude, lon: realtime.longitude) {
|
||||
nextType = .wgs84
|
||||
} else {
|
||||
nextType = .gcj02
|
||||
}
|
||||
initialMapCoordinateSystemUsedFallback = true
|
||||
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测使用兜底结果", details: [
|
||||
"探测失败原因": reason,
|
||||
"实时定位存在": String(realtime != nil),
|
||||
"最终标准": nextType.rawValue,
|
||||
"结果来源": realtime == nil ? "默认国内标准" : "实时定位服务区域"
|
||||
])
|
||||
case .unavailable:
|
||||
nextType = .gcj02
|
||||
RuntimeLogger.warning("APP", "坐标转换", "瓦片检测无结果,回退 GCJ-02")
|
||||
case .timedOut:
|
||||
nextType = .gcj02
|
||||
RuntimeLogger.warning("APP", "坐标转换", "瓦片检测超时,回退 GCJ-02")
|
||||
case .cancelled:
|
||||
return nil
|
||||
initialMapCoordinateSystemUsedFallback = true
|
||||
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测被取消,保留默认国内标准", details: [
|
||||
"最终标准": currentMapCoordinateSystem.rawValue
|
||||
])
|
||||
return currentMapCoordinateSystem
|
||||
}
|
||||
|
||||
guard nextType != currentTileType else { return nil }
|
||||
let change = TileTypeChange(previous: currentTileType, current: nextType)
|
||||
currentTileType = nextType
|
||||
currentMapCoordinateSystem = nextType
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准已确定,允许创建地图", details: [
|
||||
"最终标准": nextType.rawValue,
|
||||
"使用兜底": String(initialMapCoordinateSystemUsedFallback),
|
||||
"缓存": "false"
|
||||
])
|
||||
return nextType
|
||||
}
|
||||
|
||||
/// A user-requested realtime sample is WGS-84 and can correct a provisional
|
||||
/// startup map coordinate system without altering persisted coordinate pairs.
|
||||
@MainActor
|
||||
static func correctMapCoordinateSystemUsingRealtime(_ coordinate: CLLocationCoordinate2D) -> MapCoordinateSystemChange? {
|
||||
guard CLLocationCoordinate2DIsValid(coordinate) else { return nil }
|
||||
guard initialMapCoordinateSystemUsedFallback else {
|
||||
RuntimeLogger.info("APP", "坐标转换", "实时定位不覆盖固定锚点的明确检测结果", details: [
|
||||
"当前标准": currentMapCoordinateSystem.rawValue
|
||||
])
|
||||
return nil
|
||||
}
|
||||
let next: MapCoordinateSystem = usesGCJ02ServiceArea(lat: coordinate.latitude, lon: coordinate.longitude) ? .gcj02 : .wgs84
|
||||
guard next != currentMapCoordinateSystem else {
|
||||
RuntimeLogger.info("APP", "坐标转换", "实时定位确认兜底地图坐标标准无需修正", details: [
|
||||
"当前标准": currentMapCoordinateSystem.rawValue
|
||||
])
|
||||
return nil
|
||||
}
|
||||
let change = MapCoordinateSystemChange(previous: currentMapCoordinateSystem, current: next)
|
||||
currentMapCoordinateSystem = next
|
||||
RuntimeLogger.warning("APP", "坐标转换", "实时定位修正启动兜底地图坐标标准", details: [
|
||||
"from": change.previous.rawValue,
|
||||
"to": change.current.rawValue
|
||||
])
|
||||
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 {
|
||||
private static func fixedAnchorCoordinateSystemProbe() async -> MapCoordinateSystemProbeResult {
|
||||
let request = MKLocalSearch.Request()
|
||||
request.naturalLanguageQuery = "22.283819, 114.158439"
|
||||
let search = MKLocalSearch(request: request)
|
||||
let resolver = TileProbeResolver()
|
||||
let resolver = MapCoordinateSystemProbeResolver()
|
||||
|
||||
return await withTaskCancellationHandler(operation: {
|
||||
await withCheckedContinuation { continuation in
|
||||
let timeout = DispatchWorkItem {
|
||||
search.cancel()
|
||||
resolver.resolve(.timedOut)
|
||||
resolver.resolve(.timedOut(reason: "固定锚点查询超过5秒"))
|
||||
}
|
||||
resolver.install(continuation, timeout: timeout)
|
||||
guard !resolver.isResolved else { return }
|
||||
search.start { response, error in
|
||||
guard error == nil else {
|
||||
resolver.resolve(.unavailable)
|
||||
if let error {
|
||||
let nsError = error as NSError
|
||||
resolver.resolve(.unavailable(
|
||||
reason: "\(nsError.domain)(\(nsError.code)): \(nsError.localizedDescription)"
|
||||
))
|
||||
return
|
||||
}
|
||||
resolver.resolve(.response(
|
||||
name: response?.mapItems.first?.name ?? "",
|
||||
count: response?.mapItems.count ?? 0
|
||||
))
|
||||
let items = response?.mapItems ?? []
|
||||
guard let first = items.first,
|
||||
let name = first.name?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!name.isEmpty else {
|
||||
resolver.resolve(.unavailable(reason: "固定锚点查询返回空结果"))
|
||||
return
|
||||
}
|
||||
resolver.resolve(.response(name: name, count: items.count))
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: timeout)
|
||||
}
|
||||
@@ -126,48 +204,30 @@ enum CoordinateConverter {
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - 存取转换
|
||||
|
||||
/// 地图坐标 → WGS-84 存储
|
||||
@MainActor
|
||||
static func toStored(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
||||
let stored = storedCoordinate(lat: lat, lon: lon, tileType: currentTileType)
|
||||
RuntimeLogger.info("APP", "坐标转换", "地图坐标已规范为 WGS-84", details: [
|
||||
"转换": String(currentTileType == .gcj02 && usesGCJ02ServiceArea(lat: lat, lon: lon))
|
||||
])
|
||||
return stored
|
||||
}
|
||||
|
||||
/// WGS-84 存储 → 当前地图瓦片坐标系(显示用)
|
||||
@MainActor
|
||||
static func toDisplay(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
||||
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)
|
||||
/// Creates the complete persisted pair once at the map input boundary.
|
||||
static func coordinatePair(lat: Double, lon: Double, mapCoordinateSystem: MapCoordinateSystem) -> CoordinatePair {
|
||||
let raw = CoordinatePair.Value(latitude: lat, longitude: lon)
|
||||
switch mapCoordinateSystem {
|
||||
case .wgs84:
|
||||
let gcj = wgs84ToGcj02(lat: lat, lon: lon)
|
||||
return CoordinatePair(
|
||||
wgs84: raw,
|
||||
gcj02: .init(latitude: gcj.lat, longitude: gcj.lon)
|
||||
)
|
||||
case .gcj02:
|
||||
let wgs = gcj02ToWgs84(lat: lat, lon: lon)
|
||||
return CoordinatePair(
|
||||
wgs84: .init(latitude: wgs.lat, longitude: wgs.lon),
|
||||
gcj02: raw
|
||||
)
|
||||
}
|
||||
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)
|
||||
/// Legacy raw domestic map values were historically displayed as GCJ-02;
|
||||
/// overseas values were WGS-84 and stay identity coordinates.
|
||||
static func legacyCoordinatePair(lat: Double, lon: Double) -> CoordinatePair {
|
||||
let type: MapCoordinateSystem = usesGCJ02ServiceArea(lat: lat, lon: lon) ? .gcj02 : .wgs84
|
||||
return coordinatePair(lat: lat, lon: lon, mapCoordinateSystem: type)
|
||||
}
|
||||
|
||||
// MARK: - 工具
|
||||
@@ -205,9 +265,8 @@ enum CoordinateConverter {
|
||||
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.
|
||||
/// Keep the GCJ-02 service region explicit. Outside this region, conversion
|
||||
/// is identity so the domestic fallback can never shift an overseas value.
|
||||
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
|
||||
@@ -249,17 +308,17 @@ enum CoordinateConverter {
|
||||
}
|
||||
}
|
||||
|
||||
private enum TileProbeResult {
|
||||
private enum MapCoordinateSystemProbeResult {
|
||||
case response(name: String, count: Int)
|
||||
case unavailable
|
||||
case timedOut
|
||||
case unavailable(reason: String)
|
||||
case timedOut(reason: String)
|
||||
case cancelled
|
||||
}
|
||||
|
||||
private final class TileProbeResolver: @unchecked Sendable {
|
||||
private final class MapCoordinateSystemProbeResolver: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var result: TileProbeResult?
|
||||
private var continuation: CheckedContinuation<TileProbeResult, Never>?
|
||||
private var result: MapCoordinateSystemProbeResult?
|
||||
private var continuation: CheckedContinuation<MapCoordinateSystemProbeResult, Never>?
|
||||
private var timeout: DispatchWorkItem?
|
||||
|
||||
var isResolved: Bool {
|
||||
@@ -269,7 +328,7 @@ private final class TileProbeResolver: @unchecked Sendable {
|
||||
}
|
||||
|
||||
func install(
|
||||
_ continuation: CheckedContinuation<TileProbeResult, Never>,
|
||||
_ continuation: CheckedContinuation<MapCoordinateSystemProbeResult, Never>,
|
||||
timeout: DispatchWorkItem
|
||||
) {
|
||||
lock.lock()
|
||||
@@ -284,7 +343,7 @@ private final class TileProbeResolver: @unchecked Sendable {
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func resolve(_ nextResult: TileProbeResult) {
|
||||
func resolve(_ nextResult: MapCoordinateSystemProbeResult) {
|
||||
lock.lock()
|
||||
guard result == nil else {
|
||||
lock.unlock()
|
||||
|
||||
+8
-15
@@ -19,6 +19,14 @@ enum CoreBridgeError: LocalizedError {
|
||||
}
|
||||
|
||||
enum CoreBridge {
|
||||
static func isValidCertificateAuthority(_ authority: CertificateAuthority) -> Bool {
|
||||
authority.certPEM.withCString { certificate in
|
||||
authority.keyPEM.withCString { key in
|
||||
wloccore_validateca(UnsafeMutablePointer(mutating: certificate), UnsafeMutablePointer(mutating: key)) != 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func generateCertificateAuthority() throws -> CertificateAuthority {
|
||||
RuntimeLogger.info("APP", "Core.CA", "调用 Go Core 生成 CA")
|
||||
let result = wloccore_generateca()
|
||||
@@ -41,21 +49,6 @@ enum CoreBridge {
|
||||
return String(cString: ptr)
|
||||
}
|
||||
|
||||
/// 构造接近真实设备格式的 wloc 请求体(多个 WiFi AP + 蜂窝基站)。
|
||||
static func testWlocRequestData() -> Data {
|
||||
guard let ptr = wloccore_testrequesthex() else { return Data([0x0a, 0x02, 0x08, 0x01]) }
|
||||
defer { free(ptr) }
|
||||
let hex = String(cString: ptr)
|
||||
var data = Data()
|
||||
var idx = hex.startIndex
|
||||
while idx < hex.endIndex {
|
||||
let end = hex.index(idx, offsetBy: 2, limitedBy: hex.endIndex) ?? hex.endIndex
|
||||
if let b = UInt8(hex[idx..<end], radix: 16) { data.append(b) }
|
||||
idx = end
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
static func flushLogs(category: String) {
|
||||
guard let pointer = wloccore_drainlogs() else { return }
|
||||
defer { free(pointer) }
|
||||
|
||||
@@ -1,21 +1,79 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
struct FavoriteLocation: Codable, Identifiable, Equatable {
|
||||
let id: UUID
|
||||
var name: String
|
||||
var latitude: Double
|
||||
var longitude: Double
|
||||
var coordinatePair: CoordinatePair
|
||||
var accuracy: Int
|
||||
var createdAt: Date
|
||||
private var wasDecodedFromLegacyCoordinates = false
|
||||
|
||||
init(id: UUID = UUID(), name: String, latitude: Double, longitude: Double, accuracy: Int, createdAt: Date = Date()) {
|
||||
/// WGS-84 compatibility accessor. WLOC consumers must use this value.
|
||||
var latitude: Double { coordinatePair.wgs84.latitude }
|
||||
var longitude: Double { coordinatePair.wgs84.longitude }
|
||||
var isLegacyCoordinateRecord: Bool { wasDecodedFromLegacyCoordinates }
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
coordinatePair: CoordinatePair,
|
||||
accuracy: Int,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.coordinatePair = coordinatePair
|
||||
self.accuracy = accuracy
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
accuracy: Int,
|
||||
createdAt: Date = Date(),
|
||||
mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem = .gcj02
|
||||
) {
|
||||
self.init(
|
||||
id: id,
|
||||
name: name,
|
||||
coordinatePair: CoordinateConverter.coordinatePair(lat: latitude, lon: longitude, mapCoordinateSystem: mapCoordinateSystem),
|
||||
accuracy: accuracy,
|
||||
createdAt: createdAt
|
||||
)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, coordinatePair, accuracy, createdAt, latitude, longitude
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
accuracy = try container.decode(Int.self, forKey: .accuracy)
|
||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
if let pair = try container.decodeIfPresent(CoordinatePair.self, forKey: .coordinatePair) {
|
||||
coordinatePair = pair
|
||||
} else {
|
||||
let latitude = try container.decode(Double.self, forKey: .latitude)
|
||||
let longitude = try container.decode(Double.self, forKey: .longitude)
|
||||
coordinatePair = CoordinateConverter.legacyCoordinatePair(lat: latitude, lon: longitude)
|
||||
wasDecodedFromLegacyCoordinates = true
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(name, forKey: .name)
|
||||
try container.encode(coordinatePair, forKey: .coordinatePair)
|
||||
try container.encode(accuracy, forKey: .accuracy)
|
||||
try container.encode(createdAt, forKey: .createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
struct MapConfiguration: Equatable {
|
||||
@@ -52,18 +110,38 @@ final class FavoriteLocationStore: ObservableObject {
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func save(name: String, latitude: Double, longitude: Double, accuracy: Int) -> FavoriteLocation {
|
||||
let favorite = FavoriteLocation(name: name, latitude: latitude, longitude: longitude, accuracy: accuracy)
|
||||
// 去重:相同坐标删除旧数据,新数据插入顶部
|
||||
func save(
|
||||
name: String,
|
||||
mapCoordinate: CLLocationCoordinate2D,
|
||||
mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem,
|
||||
accuracy: Int
|
||||
) -> FavoriteLocation {
|
||||
let favorite = FavoriteLocation(
|
||||
name: name,
|
||||
coordinatePair: .init(mapCoordinate: mapCoordinate, mapCoordinateSystem: mapCoordinateSystem),
|
||||
accuracy: accuracy
|
||||
)
|
||||
favorites.removeAll {
|
||||
abs($0.latitude - favorite.latitude) < 0.000001 && abs($0.longitude - favorite.longitude) < 0.000001
|
||||
abs($0.coordinatePair.wgs84.latitude - favorite.coordinatePair.wgs84.latitude) < 0.000001
|
||||
&& abs($0.coordinatePair.wgs84.longitude - favorite.coordinatePair.wgs84.longitude) < 0.000001
|
||||
}
|
||||
favorites.insert(favorite, at: 0)
|
||||
select(favorite.id)
|
||||
persist()
|
||||
persistIgnoringFailure()
|
||||
return favorite
|
||||
}
|
||||
|
||||
/// Compatibility entry point for callers that already own raw map values.
|
||||
@discardableResult
|
||||
func save(name: String, latitude: Double, longitude: Double, accuracy: Int, mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem = .gcj02) -> FavoriteLocation {
|
||||
save(
|
||||
name: name,
|
||||
mapCoordinate: .init(latitude: latitude, longitude: longitude),
|
||||
mapCoordinateSystem: mapCoordinateSystem,
|
||||
accuracy: accuracy
|
||||
)
|
||||
}
|
||||
|
||||
func select(_ id: UUID?) {
|
||||
selectedFavoriteID = id
|
||||
defaults.set(id?.uuidString, forKey: Keys.selectedID)
|
||||
@@ -72,7 +150,7 @@ final class FavoriteLocationStore: ObservableObject {
|
||||
func rename(_ id: UUID, to name: String) {
|
||||
guard let idx = favorites.firstIndex(where: { $0.id == id }) else { return }
|
||||
favorites[idx].name = name
|
||||
persist()
|
||||
persistIgnoringFailure()
|
||||
}
|
||||
|
||||
func delete(_ favorite: FavoriteLocation) {
|
||||
@@ -80,11 +158,23 @@ final class FavoriteLocationStore: ObservableObject {
|
||||
if selectedFavoriteID == favorite.id {
|
||||
select(favorites.first?.id)
|
||||
}
|
||||
persist()
|
||||
persistIgnoringFailure()
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
guard let data = try? JSONEncoder().encode(favorites) else { return }
|
||||
defaults.set(data, forKey: Keys.favorites)
|
||||
func migrateLegacyCoordinates() throws {
|
||||
guard favorites.contains(where: \.isLegacyCoordinateRecord) else { return }
|
||||
try persist()
|
||||
}
|
||||
|
||||
private func persistIgnoringFailure() {
|
||||
do {
|
||||
try persist()
|
||||
} catch {
|
||||
RuntimeLogger.error("APP", "收藏", "保存收藏失败", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func persist() throws {
|
||||
defaults.set(try JSONEncoder().encode(favorites), forKey: Keys.favorites)
|
||||
}
|
||||
}
|
||||
|
||||
+43
-14
@@ -2,6 +2,12 @@ import Network
|
||||
import Foundation
|
||||
import SystemConfiguration.CaptiveNetwork
|
||||
|
||||
enum WiFiChangeReason: String {
|
||||
case reconnected = "Wi-Fi 恢复连接"
|
||||
case interfaceChanged = "网络接口切换到 Wi-Fi"
|
||||
case ssidChanged = "SSID 发生变化"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class NetworkMonitor: ObservableObject {
|
||||
static let shared = NetworkMonitor()
|
||||
@@ -10,26 +16,44 @@ final class NetworkMonitor: ObservableObject {
|
||||
@Published private(set) var isWiFiEnabled = true
|
||||
@Published private(set) var currentSSID: String?
|
||||
|
||||
/// WiFi 重连或 SSID 变化时触发的订阅。调用方必须在离开页面时移除订阅。
|
||||
private var wifiChangeHandlers: [UUID: @MainActor () -> Void] = [:]
|
||||
/// Wi-Fi 重连、接口切换或 SSID 变化时触发。调用方必须在离开页面时移除订阅。
|
||||
private var wifiChangeHandlers: [UUID: @MainActor (WiFiChangeReason) -> Void] = [:]
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
private var ssidTimer: Timer?
|
||||
private var wasSatisfied = true
|
||||
private var wasWiFiEnabled = true
|
||||
private var hasReceivedInitialPath = false
|
||||
private var lastKnownSSID: String?
|
||||
|
||||
private init() {
|
||||
let initialSSID = Self.fetchSSID()
|
||||
currentSSID = initialSSID
|
||||
lastKnownSSID = initialSSID
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
let satisfied = path.status == .satisfied
|
||||
let wifi = path.usesInterfaceType(.wifi)
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
// 网络恢复连接 → 触发检测
|
||||
let reconnected = satisfied && !self.wasSatisfied && wifi
|
||||
let reason: WiFiChangeReason?
|
||||
if !self.hasReceivedInitialPath {
|
||||
// NWPathMonitor 的首次回调只是状态基线,不是网络切换。
|
||||
self.hasReceivedInitialPath = true
|
||||
reason = nil
|
||||
} else if satisfied && wifi && !self.wasSatisfied {
|
||||
reason = .reconnected
|
||||
} else if satisfied && wifi && !self.wasWiFiEnabled {
|
||||
// 蜂窝网络和 Wi-Fi 都可能是 satisfied,不能只比较 status。
|
||||
reason = .interfaceChanged
|
||||
} else {
|
||||
reason = nil
|
||||
}
|
||||
self.wasSatisfied = satisfied
|
||||
self.wasWiFiEnabled = wifi
|
||||
self.isSatisfied = satisfied
|
||||
self.isWiFiEnabled = wifi
|
||||
if reconnected {
|
||||
self.notifyWiFiChanged()
|
||||
if let reason {
|
||||
self.notifyWiFiChanged(reason: reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,11 +61,9 @@ final class NetworkMonitor: ObservableObject {
|
||||
startSSIDPolling()
|
||||
}
|
||||
|
||||
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 {
|
||||
func observeWiFiChanges(_ handler: @escaping @MainActor (WiFiChangeReason) -> Void) -> UUID {
|
||||
let token = UUID()
|
||||
wifiChangeHandlers[token] = handler
|
||||
return token
|
||||
@@ -51,9 +73,9 @@ final class NetworkMonitor: ObservableObject {
|
||||
wifiChangeHandlers.removeValue(forKey: token)
|
||||
}
|
||||
|
||||
private func notifyWiFiChanged() {
|
||||
private func notifyWiFiChanged(reason: WiFiChangeReason) {
|
||||
for handler in wifiChangeHandlers.values {
|
||||
handler()
|
||||
handler(reason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,9 +84,16 @@ final class NetworkMonitor: ObservableObject {
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
let ssid = Self.fetchSSID()
|
||||
if ssid != self.currentSSID, ssid != nil {
|
||||
self.currentSSID = ssid
|
||||
self.notifyWiFiChanged()
|
||||
self.currentSSID = ssid
|
||||
guard let ssid else { return }
|
||||
guard let previousSSID = self.lastKnownSSID else {
|
||||
// 首次取得 SSID 只是建立基线,不能当作用户切换了 Wi-Fi。
|
||||
self.lastKnownSSID = ssid
|
||||
return
|
||||
}
|
||||
if ssid != previousSSID {
|
||||
self.lastKnownSSID = ssid
|
||||
self.notifyWiFiChanged(reason: .ssidChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+126
-4
@@ -1,3 +1,4 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
struct RuntimeLogEntry: Codable, Identifiable, Equatable {
|
||||
@@ -49,11 +50,17 @@ enum RuntimeLogStore {
|
||||
private static let decoder = JSONDecoder()
|
||||
private static let encoder = JSONEncoder()
|
||||
private static let maximumBytes: UInt64 = 1_500_000
|
||||
static let retentionInterval: TimeInterval = 3 * 24 * 60 * 60
|
||||
private static let pruningInterval: TimeInterval = 60 * 60
|
||||
private static var lastPrunedAt: Date?
|
||||
|
||||
static func append(_ entry: RuntimeLogEntry) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
do {
|
||||
let now = Date()
|
||||
try pruneExpiredLogsIfNeeded(now: now)
|
||||
guard entry.timestamp >= retentionCutoff(now: now) else { return }
|
||||
let url = try logURL(for: entry.source)
|
||||
try rotateIfNeeded(url)
|
||||
var data = try encoder.encode(entry)
|
||||
@@ -75,7 +82,8 @@ enum RuntimeLogStore {
|
||||
static func loadAll(limit: Int = 800) -> [RuntimeLogEntry] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
|
||||
let directory = logDirectory
|
||||
try? pruneExpiredLogs(in: directory, now: Date())
|
||||
guard let urls = try? FileManager.default.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: nil
|
||||
@@ -90,12 +98,12 @@ enum RuntimeLogStore {
|
||||
static func clearAll() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
try? FileManager.default.removeItem(at: logDirectory)
|
||||
lastPrunedAt = nil
|
||||
}
|
||||
|
||||
private static func logURL(for source: String) throws -> URL {
|
||||
let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
|
||||
let directory = logDirectory
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let process = (Bundle.main.bundleIdentifier ?? "unknown-process")
|
||||
.replacingOccurrences(of: "/", with: "-")
|
||||
@@ -103,6 +111,56 @@ enum RuntimeLogStore {
|
||||
return directory.appendingPathComponent("\(process)-\(safeSource).jsonl")
|
||||
}
|
||||
|
||||
private static var logDirectory: URL {
|
||||
AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
|
||||
}
|
||||
|
||||
static func retentionCutoff(now: Date) -> Date {
|
||||
now.addingTimeInterval(-retentionInterval)
|
||||
}
|
||||
|
||||
private static func pruneExpiredLogsIfNeeded(now: Date) throws {
|
||||
if let lastPrunedAt,
|
||||
now >= lastPrunedAt,
|
||||
now.timeIntervalSince(lastPrunedAt) < pruningInterval {
|
||||
return
|
||||
}
|
||||
try pruneExpiredLogs(in: logDirectory, now: now)
|
||||
}
|
||||
|
||||
private static func pruneExpiredLogs(in directory: URL, now: Date) throws {
|
||||
guard FileManager.default.fileExists(atPath: directory.path) else {
|
||||
lastPrunedAt = now
|
||||
return
|
||||
}
|
||||
let urls = try FileManager.default.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: nil
|
||||
).filter { $0.pathExtension == "jsonl" }
|
||||
|
||||
for url in urls {
|
||||
let entries = readEntries(url)
|
||||
let retained = retainedEntries(entries, now: now)
|
||||
guard retained.count != entries.count else { continue }
|
||||
guard !retained.isEmpty else {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
continue
|
||||
}
|
||||
var data = Data()
|
||||
for entry in retained {
|
||||
data.append(try encoder.encode(entry))
|
||||
data.append(0x0A)
|
||||
}
|
||||
try data.write(to: url, options: .atomic)
|
||||
}
|
||||
lastPrunedAt = now
|
||||
}
|
||||
|
||||
static func retainedEntries(_ entries: [RuntimeLogEntry], now: Date) -> [RuntimeLogEntry] {
|
||||
let cutoff = retentionCutoff(now: now)
|
||||
return entries.filter { $0.timestamp >= cutoff }
|
||||
}
|
||||
|
||||
private static func rotateIfNeeded(_ url: URL) throws {
|
||||
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
let size = attributes[.size] as? NSNumber,
|
||||
@@ -162,3 +220,67 @@ enum RuntimeLogger {
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Realtime-location diagnostics keep precise coordinates out of the persisted,
|
||||
/// exportable log. Exact values are printed only by DEBUG builds for local Xcode
|
||||
/// debugging.
|
||||
enum RealtimeLocationTrace {
|
||||
static func log(
|
||||
_ message: String,
|
||||
location: CLLocation,
|
||||
details: [String: String] = [:],
|
||||
level: RuntimeLogEntry.Level = .info
|
||||
) {
|
||||
var metadata = details
|
||||
metadata["样本时间"] = ISO8601DateFormatter().string(from: location.timestamp)
|
||||
metadata["样本年龄秒"] = format(Date().timeIntervalSince(location.timestamp))
|
||||
metadata["水平精度米"] = format(location.horizontalAccuracy)
|
||||
metadata["垂直精度米"] = format(location.verticalAccuracy)
|
||||
metadata["海拔米"] = format(location.altitude)
|
||||
metadata["坐标有效"] = String(CLLocationCoordinate2DIsValid(location.coordinate))
|
||||
persist(level, message: message, details: metadata)
|
||||
debugCoordinate(message, location: location, details: metadata)
|
||||
}
|
||||
|
||||
static func coordinate(
|
||||
_ message: String,
|
||||
coordinate: CLLocationCoordinate2D,
|
||||
details: [String: String] = [:]
|
||||
) {
|
||||
#if DEBUG
|
||||
let suffix = details.sorted { $0.key < $1.key }
|
||||
.map { "\($0.key)=\($0.value)" }
|
||||
.joined(separator: " ")
|
||||
let latitude = String(format: "%.8f", coordinate.latitude)
|
||||
let longitude = String(format: "%.8f", coordinate.longitude)
|
||||
let metadata = suffix.isEmpty ? "" : " \(suffix)"
|
||||
print("[RealtimeLocation] \(message) latitude=\(latitude) longitude=\(longitude)\(metadata)")
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func persist(
|
||||
_ level: RuntimeLogEntry.Level,
|
||||
message: String,
|
||||
details: [String: String]
|
||||
) {
|
||||
switch level {
|
||||
case .debug: RuntimeLogger.debug("APP", "实时定位", message, details: details)
|
||||
case .info: RuntimeLogger.info("APP", "实时定位", message, details: details)
|
||||
case .warning: RuntimeLogger.warning("APP", "实时定位", message, details: details)
|
||||
case .error: RuntimeLogger.error("APP", "实时定位", message, details: details)
|
||||
}
|
||||
}
|
||||
|
||||
private static func debugCoordinate(
|
||||
_ message: String,
|
||||
location: CLLocation,
|
||||
details: [String: String]
|
||||
) {
|
||||
coordinate(message, coordinate: location.coordinate, details: details)
|
||||
}
|
||||
|
||||
private static func format(_ value: Double) -> String {
|
||||
guard value.isFinite else { return String(value) }
|
||||
return String(format: "%.3f", value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,22 @@ enum VerificationResult: Equatable, Identifiable {
|
||||
switch self {
|
||||
case .success: return nil
|
||||
case .proxyNotRunning, .verificationInProgress, .verificationSuperseded: return nil
|
||||
case .certNotTrusted: return .certificate
|
||||
case .certNotTrusted: return nil
|
||||
case .wifiProxyNotConfigured: return .proxySetup
|
||||
case .coordinateWriteFailed, .patchFailed: return .rewriteFailed
|
||||
}
|
||||
}
|
||||
|
||||
/// Wi-Fi 变化后仍留在地图页显示的提醒。
|
||||
/// 证书失败必须进入完整证书安装引导,因此不在这里返回通用提示页。
|
||||
var wifiChangeReminderTipKind: TipKind? {
|
||||
switch self {
|
||||
case .proxyNotRunning:
|
||||
return .proxySetup
|
||||
case .certNotTrusted, .verificationInProgress, .verificationSuperseded:
|
||||
return nil
|
||||
default:
|
||||
return tipKind
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user