mirror of
https://github.com/xweiba/location-spoofer.git
synced 2026-09-24 15:41:56 +08:00
release: PaopaoLocationSpoofer v1.0.0
- iOS 虚拟定位工具,基于本地 HTTP 代理 MITM 方案 - MapKit 原生地图体验,支持搜索、收藏、实时定位 - 完整的设置引导流程(证书安装、WiFi 代理配置、环境验证) - 支持 iOS 15+,SwiftUI 构建
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
|
||||
enum AppGroup {
|
||||
static let identifier = "group.com.paopaolabs.location-spoofer"
|
||||
static let defaults = UserDefaults(suiteName: identifier) ?? .standard
|
||||
|
||||
static var sharedContainerURL: URL? {
|
||||
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier)
|
||||
}
|
||||
|
||||
static var isSharedContainerAvailable: Bool { sharedContainerURL != nil }
|
||||
|
||||
static var containerURL: URL {
|
||||
if let url = sharedContainerURL { return url }
|
||||
return FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("LocationSpoofer", isDirectory: true)
|
||||
}
|
||||
}
|
||||
|
||||
enum WlocKeys {
|
||||
static let coords = "wloc_settings"
|
||||
}
|
||||
|
||||
struct WlocSettings: Codable {
|
||||
var longitude: Double
|
||||
var latitude: Double
|
||||
var accuracy: Int
|
||||
var enabled: Bool
|
||||
}
|
||||
|
||||
enum WlocSettingsStore {
|
||||
static func load() -> WlocSettings? {
|
||||
guard let data = AppGroup.defaults.data(forKey: WlocKeys.coords) else { return nil }
|
||||
return try? JSONDecoder().decode(WlocSettings.self, from: data)
|
||||
}
|
||||
|
||||
static func save(_ settings: WlocSettings) {
|
||||
guard let data = try? JSONEncoder().encode(settings) else { return }
|
||||
AppGroup.defaults.set(data, forKey: WlocKeys.coords)
|
||||
}
|
||||
|
||||
static func clear() {
|
||||
save(WlocSettings(longitude: 0, latitude: 0, accuracy: 25, enabled: false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
|
||||
final class BackgroundKeepAlive {
|
||||
static let shared = BackgroundKeepAlive()
|
||||
private var engine: AVAudioEngine?
|
||||
private var playerNode: AVAudioPlayerNode?
|
||||
private var isActive = false
|
||||
|
||||
private init() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(handleInterruption),
|
||||
name: AVAudioSession.interruptionNotification, object: nil)
|
||||
}
|
||||
|
||||
@objc private func handleInterruption(_ notification: Notification) {
|
||||
guard isActive, let info = notification.userInfo,
|
||||
let type = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
type == AVAudioSession.InterruptionType.ended.rawValue else { return }
|
||||
start()
|
||||
RuntimeLogger.info("APP", "KeepAlive", "音频中断恢复")
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !isActive else { return }
|
||||
isActive = true
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .mixWithOthers)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
RuntimeLogger.error("APP", "KeepAlive", "音频会话失败", error: error)
|
||||
}
|
||||
|
||||
let eng = AVAudioEngine()
|
||||
let player = AVAudioPlayerNode()
|
||||
eng.attach(player)
|
||||
let fmt = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 1)!
|
||||
let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: 44100 * 3)!
|
||||
buf.frameLength = 44100 * 3
|
||||
eng.connect(player, to: eng.mainMixerNode, format: fmt)
|
||||
eng.prepare()
|
||||
do { try eng.start() } catch {
|
||||
RuntimeLogger.error("APP", "KeepAlive", "引擎启动失败", error: error)
|
||||
isActive = false; return
|
||||
}
|
||||
player.scheduleBuffer(buf, at: nil, options: .loops)
|
||||
player.play()
|
||||
engine = eng; playerNode = player
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
RuntimeLogger.info("APP", "KeepAlive", "后台保活已启动(静音音频)")
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard isActive else { return }
|
||||
isActive = false
|
||||
playerNode?.stop(); engine?.stop()
|
||||
playerNode = nil; engine = nil
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
RuntimeLogger.info("APP", "KeepAlive", "后台保活已停止")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
final class CertificateAuthorityStore {
|
||||
private let directory: URL
|
||||
private let generator: () throws -> CertificateAuthority
|
||||
private let certificateURL: URL
|
||||
private let keyURL: URL
|
||||
|
||||
init(directory: URL = AppGroup.containerURL.appendingPathComponent("CertificateAuthority", isDirectory: true), generator: @escaping () throws -> CertificateAuthority = CoreBridge.generateCertificateAuthority) {
|
||||
self.directory = directory
|
||||
self.generator = generator
|
||||
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
|
||||
}
|
||||
RuntimeLogger.info("SHARED", "Certificate.store", "未找到 CA 文件,开始生成", details: ["directory": directory.path])
|
||||
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])
|
||||
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))
|
||||
}
|
||||
|
||||
private func applyCompleteProtection(to url: URL) {
|
||||
#if os(iOS)
|
||||
try? FileManager.default.setAttributes([.protectionKey: FileProtectionType.complete], ofItemAtPath: url.path)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
enum CertificateTrustState: Equatable {
|
||||
case checking
|
||||
case trusted
|
||||
case unavailable
|
||||
|
||||
var canModify: Bool { self == .trusted }
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .checking: return "checking..."
|
||||
case .trusted: return "trusted"
|
||||
case .unavailable: return "not configured"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum LocationActionState: Equatable {
|
||||
case idle
|
||||
case applyingLocation
|
||||
case failed(String)
|
||||
|
||||
var isBusy: Bool {
|
||||
if case .applyingLocation = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var isFailure: Bool {
|
||||
if case .failed = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var statusTitle: String {
|
||||
switch self {
|
||||
case .idle: return ""
|
||||
case .applyingLocation: return "applying..."
|
||||
case .failed: return "failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
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 {
|
||||
RuntimeLogger.error("APP", "Trust", "无法解析 CA 证书 PEM")
|
||||
return false
|
||||
}
|
||||
|
||||
// Create a basic trust with the CA cert, using system anchor certificates only
|
||||
var trust: SecTrust?
|
||||
let createStatus = SecTrustCreateWithCertificates(
|
||||
[cert] as CFArray,
|
||||
SecPolicyCreateBasicX509(),
|
||||
&trust
|
||||
)
|
||||
guard createStatus == errSecSuccess, let trust = trust else {
|
||||
RuntimeLogger.error("APP", "Trust", "无法创建 SecTrust")
|
||||
return false
|
||||
}
|
||||
|
||||
// Use system anchors only — if our CA is installed & trusted, evaluation passes
|
||||
SecTrustSetAnchorCertificatesOnly(trust, false)
|
||||
|
||||
var error: CFError?
|
||||
let result = SecTrustEvaluateWithError(trust, &error)
|
||||
if let error {
|
||||
RuntimeLogger.warning("APP", "Trust", "SecTrust 评估返回错误", details: [
|
||||
"error": (error as Error).localizedDescription
|
||||
])
|
||||
}
|
||||
|
||||
RuntimeLogger.info("APP", "Trust", result ? "CA 证书已被系统信任" : "CA 证书未被系统信任")
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
|
||||
/// GCJ-02 (火星坐标) ↔ WGS-84 坐标转换。
|
||||
///
|
||||
/// 在中国地区,MKMapView 使用高德 (AutoNavi) 瓦片数据(GCJ-02 坐标系),
|
||||
/// 因此从 MKMapView 的 `centerCoordinate`、`convert(point:toCoordinateFrom:)`
|
||||
/// 等方法返回的坐标也是 GCJ-02。但 CoreLocation / CLLocationManager 返回的
|
||||
/// 以及 Apple wloc 定位服务使用的都是 WGS-84。
|
||||
///
|
||||
/// 虚拟定位的坐标流中:
|
||||
/// - 地图 UI 层(显示、选点):GCJ-02(与瓦片一致)
|
||||
/// - 代理写出层(wloc 响应改写):WGS-84
|
||||
///
|
||||
/// 因此需要在坐标从地图 UI 进入代理之前做 GCJ-02 → WGS-84 转换。
|
||||
enum CoordinateConverter {
|
||||
// 椭球参数 (Krasovsky 1940)
|
||||
private static let a = 6378245.0
|
||||
private static let ee = 0.00669342162296594323
|
||||
|
||||
/// GCJ-02 → WGS-84(迭代法,精度优于 0.5 米)
|
||||
static func gcj02ToWgs84(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
||||
var wgsLat = lat
|
||||
var wgsLon = lon
|
||||
// 两次迭代足以收敛到亚米级精度
|
||||
for _ in 0..<2 {
|
||||
let d = delta(lat: wgsLat, lon: wgsLon)
|
||||
wgsLat = lat - d.lat
|
||||
wgsLon = lon - d.lon
|
||||
}
|
||||
return (wgsLat, wgsLon)
|
||||
}
|
||||
|
||||
/// 计算偏移量 (WGS-84 → GCJ-02 的增量)
|
||||
private static func delta(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
|
||||
let dLat = transformLat(x: lon - 105.0, y: lat - 35.0)
|
||||
let dLon = transformLon(x: lon - 105.0, y: lat - 35.0)
|
||||
let radLat = lat / 180.0 * .pi
|
||||
var magic = sin(radLat)
|
||||
magic = 1 - ee * magic * magic
|
||||
let sqrtMagic = sqrt(magic)
|
||||
return (
|
||||
lat: (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * .pi),
|
||||
lon: (dLon * 180.0) / (a / sqrtMagic * cos(radLat) * .pi)
|
||||
)
|
||||
}
|
||||
|
||||
private static func transformLat(x: Double, y: Double) -> Double {
|
||||
var ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * sqrt(abs(x))
|
||||
ret += (20.0 * sin(6.0 * x * .pi) + 20.0 * sin(2.0 * x * .pi)) * 2.0 / 3.0
|
||||
ret += (20.0 * sin(y * .pi) + 40.0 * sin(y / 3.0 * .pi)) * 2.0 / 3.0
|
||||
ret += (160.0 * sin(y / 12.0 * .pi) + 320.0 * sin(y * .pi / 30.0)) * 2.0 / 3.0
|
||||
return ret
|
||||
}
|
||||
|
||||
private static func transformLon(x: Double, y: Double) -> Double {
|
||||
var ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * sqrt(abs(x))
|
||||
ret += (20.0 * sin(6.0 * x * .pi) + 20.0 * sin(2.0 * x * .pi)) * 2.0 / 3.0
|
||||
ret += (20.0 * sin(x * .pi) + 40.0 * sin(x / 3.0 * .pi)) * 2.0 / 3.0
|
||||
ret += (150.0 * sin(x / 12.0 * .pi) + 300.0 * sin(x / 30.0 * .pi)) * 2.0 / 3.0
|
||||
return ret
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
struct CertificateAuthority: Equatable {
|
||||
let certPEM: String
|
||||
let keyPEM: String
|
||||
}
|
||||
|
||||
enum CoreBridgeError: LocalizedError {
|
||||
case generationFailed
|
||||
case serverStartFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .generationFailed: return "无法生成本地证书"
|
||||
case .serverStartFailed: return "无法启动本地证书服务"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CoreBridge {
|
||||
static func generateCertificateAuthority() throws -> CertificateAuthority {
|
||||
RuntimeLogger.info("APP", "Core.CA", "调用 Go Core 生成 CA")
|
||||
let result = wloccore_generateca()
|
||||
guard let certPointer = result.r0, let keyPointer = result.r1 else {
|
||||
flushLogs(category: "CA")
|
||||
throw CoreBridgeError.generationFailed
|
||||
}
|
||||
defer { free(certPointer); free(keyPointer) }
|
||||
RuntimeLogger.info("APP", "Core.CA", "Go Core CA 生成成功")
|
||||
flushLogs(category: "CA")
|
||||
return CertificateAuthority(certPEM: String(cString: certPointer), keyPEM: String(cString: keyPointer))
|
||||
}
|
||||
|
||||
/// 调用 Go Core 做一次模拟 wloc 响应改写测试,验证定位数据是否会被改成目标坐标。
|
||||
static func testWlocPatch(lat: Double, lon: Double, accuracy: Int) -> String {
|
||||
guard let ptr = wloccore_testpatch(CDouble(lat), CDouble(lon), CInt(accuracy)) else {
|
||||
return "error: null result"
|
||||
}
|
||||
defer { free(ptr) }
|
||||
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) }
|
||||
String(cString: pointer).split(separator: "\n").forEach {
|
||||
RuntimeLogger.info("CORE", category, String($0))
|
||||
}
|
||||
}
|
||||
|
||||
static func refreshVerifyToken() -> String {
|
||||
guard let ptr = wloccore_refreshverifytoken() else { return "" }
|
||||
defer { free(ptr) }
|
||||
return String(cString: ptr)
|
||||
}
|
||||
}
|
||||
|
||||
final class LocalCertificateServer {
|
||||
private var handle: UInt = 0
|
||||
private(set) var downloadURL: URL?
|
||||
private(set) var probeURL: URL?
|
||||
private(set) var leafHash = ""
|
||||
|
||||
deinit { stop() }
|
||||
|
||||
func start(authority: CertificateAuthority) throws {
|
||||
if handle != 0 {
|
||||
RuntimeLogger.debug("APP", "Certificate.server", "本地证书服务已在运行")
|
||||
return
|
||||
}
|
||||
RuntimeLogger.info("APP", "Certificate.server", "调用 Go Core 启动本地证书服务")
|
||||
let newHandle: UInt = authority.certPEM.withCString { certPointer in
|
||||
authority.keyPEM.withCString { keyPointer in
|
||||
UInt(wloccore_startcertserver(UnsafeMutablePointer(mutating: certPointer), UnsafeMutablePointer(mutating: keyPointer)))
|
||||
}
|
||||
}
|
||||
guard newHandle != 0 else {
|
||||
CoreBridge.flushLogs(category: "CertificateServer")
|
||||
throw CoreBridgeError.serverStartFailed
|
||||
}
|
||||
let httpPort = Int(wloccore_certserver_httpport(newHandle))
|
||||
let httpsPort = Int(wloccore_certserver_httpsport(newHandle))
|
||||
guard httpPort > 0, httpsPort > 0, let hashPointer = wloccore_certserver_leafsha256(newHandle) else {
|
||||
_ = wloccore_stopcertserver(newHandle)
|
||||
throw CoreBridgeError.serverStartFailed
|
||||
}
|
||||
defer { free(hashPointer) }
|
||||
handle = newHandle
|
||||
downloadURL = URL(string: "http://127.0.0.1:\(httpPort)/ca.cer")
|
||||
probeURL = URL(string: "https://127.0.0.1:\(httpsPort)/health")
|
||||
leafHash = String(cString: hashPointer)
|
||||
RuntimeLogger.info("APP", "Certificate.server", "本地证书服务启动成功", details: [
|
||||
"httpPort": String(httpPort),
|
||||
"httpsPort": String(httpsPort),
|
||||
"leafHash": leafHash
|
||||
])
|
||||
CoreBridge.flushLogs(category: "CertificateServer")
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard handle != 0 else { return }
|
||||
let result = wloccore_stopcertserver(handle)
|
||||
RuntimeLogger.info("APP", "Certificate.server", "停止本地证书服务", details: ["result": String(result)])
|
||||
CoreBridge.flushLogs(category: "CertificateServer")
|
||||
handle = 0
|
||||
downloadURL = nil
|
||||
probeURL = nil
|
||||
leafHash = ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
|
||||
struct FavoriteLocation: Codable, Identifiable, Equatable {
|
||||
let id: UUID
|
||||
var name: String
|
||||
var latitude: Double
|
||||
var longitude: Double
|
||||
var accuracy: Int
|
||||
var createdAt: Date
|
||||
|
||||
init(id: UUID = UUID(), name: String, latitude: Double, longitude: Double, accuracy: Int, createdAt: Date = Date()) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.accuracy = accuracy
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
struct MapConfiguration: Equatable {
|
||||
let showsUserLocation: Bool
|
||||
let allowsCurrentLocationRequest: Bool
|
||||
|
||||
static let `default` = MapConfiguration(showsUserLocation: false, allowsCurrentLocationRequest: false)
|
||||
}
|
||||
|
||||
final class FavoriteLocationStore: ObservableObject {
|
||||
private enum Keys {
|
||||
static let favorites = "favorite_locations"
|
||||
static let selectedID = "favorite_locations_selected_id"
|
||||
}
|
||||
|
||||
@Published private(set) var favorites: [FavoriteLocation]
|
||||
@Published private(set) var selectedFavoriteID: UUID?
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
self.defaults = defaults
|
||||
if let data = defaults.data(forKey: Keys.favorites),
|
||||
let decoded = try? JSONDecoder().decode([FavoriteLocation].self, from: data) {
|
||||
self.favorites = decoded
|
||||
} else {
|
||||
self.favorites = []
|
||||
}
|
||||
self.selectedFavoriteID = defaults.string(forKey: Keys.selectedID).flatMap(UUID.init(uuidString:))
|
||||
}
|
||||
|
||||
var selectedFavorite: FavoriteLocation? {
|
||||
guard let selectedFavoriteID else { return nil }
|
||||
return favorites.first(where: { $0.id == selectedFavoriteID })
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func save(name: String, latitude: Double, longitude: Double, accuracy: Int) -> FavoriteLocation {
|
||||
let favorite = FavoriteLocation(name: name, latitude: latitude, longitude: longitude, accuracy: accuracy)
|
||||
// 去重:相同坐标删除旧数据,新数据插入顶部
|
||||
favorites.removeAll {
|
||||
abs($0.latitude - favorite.latitude) < 0.000001 && abs($0.longitude - favorite.longitude) < 0.000001
|
||||
}
|
||||
favorites.insert(favorite, at: 0)
|
||||
select(favorite.id)
|
||||
persist()
|
||||
return favorite
|
||||
}
|
||||
|
||||
func select(_ id: UUID?) {
|
||||
selectedFavoriteID = id
|
||||
defaults.set(id?.uuidString, forKey: Keys.selectedID)
|
||||
}
|
||||
|
||||
func rename(_ id: UUID, to name: String) {
|
||||
guard let idx = favorites.firstIndex(where: { $0.id == id }) else { return }
|
||||
favorites[idx].name = name
|
||||
persist()
|
||||
}
|
||||
|
||||
func delete(_ favorite: FavoriteLocation) {
|
||||
favorites.removeAll { $0.id == favorite.id }
|
||||
if selectedFavoriteID == favorite.id {
|
||||
select(favorites.first?.id)
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
guard let data = try? JSONEncoder().encode(favorites) else { return }
|
||||
defaults.set(data, forKey: Keys.favorites)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Network
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class NetworkMonitor: ObservableObject {
|
||||
static let shared = NetworkMonitor()
|
||||
|
||||
@Published private(set) var isSatisfied = true
|
||||
@Published private(set) var isWiFiEnabled = true
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
|
||||
private init() {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
let satisfied = path.status == .satisfied
|
||||
let wifi = path.usesInterfaceType(.wifi)
|
||||
Task { @MainActor in
|
||||
self?.isSatisfied = satisfied
|
||||
self?.isWiFiEnabled = wifi
|
||||
}
|
||||
}
|
||||
monitor.start(queue: .main)
|
||||
}
|
||||
|
||||
var isAirplaneMode: Bool { !isSatisfied }
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import Foundation
|
||||
|
||||
struct RuntimeLogEntry: Codable, Identifiable, Equatable {
|
||||
enum Level: String, Codable {
|
||||
case debug
|
||||
case info
|
||||
case warning
|
||||
case error
|
||||
}
|
||||
|
||||
let id: UUID
|
||||
let timestamp: Date
|
||||
let source: String
|
||||
let level: Level
|
||||
let category: String
|
||||
let message: String
|
||||
let details: [String: String]
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
timestamp: Date = Date(),
|
||||
source: String,
|
||||
level: Level,
|
||||
category: String,
|
||||
message: String,
|
||||
details: [String: String] = [:]
|
||||
) {
|
||||
self.id = id
|
||||
self.timestamp = timestamp
|
||||
self.source = source
|
||||
self.level = level
|
||||
self.category = category
|
||||
self.message = message
|
||||
self.details = details
|
||||
}
|
||||
|
||||
var renderedText: String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
let suffix = details.isEmpty
|
||||
? ""
|
||||
: " " + details.sorted(by: { $0.key < $1.key }).map { "\($0.key)=\($0.value)" }.joined(separator: " ")
|
||||
return "\(formatter.string(from: timestamp)) [\(source)] [\(level.rawValue.uppercased())] [\(category)] \(message)\(suffix)"
|
||||
}
|
||||
}
|
||||
|
||||
enum RuntimeLogStore {
|
||||
private static let lock = NSLock()
|
||||
private static let decoder = JSONDecoder()
|
||||
private static let encoder = JSONEncoder()
|
||||
private static let maximumBytes: UInt64 = 1_500_000
|
||||
|
||||
static func append(_ entry: RuntimeLogEntry) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
do {
|
||||
let url = try logURL(for: entry.source)
|
||||
try rotateIfNeeded(url)
|
||||
var data = try encoder.encode(entry)
|
||||
data.append(0x0A)
|
||||
if !FileManager.default.fileExists(atPath: url.path) {
|
||||
try data.write(to: url, options: .atomic)
|
||||
return
|
||||
}
|
||||
let handle = try FileHandle(forWritingTo: url)
|
||||
defer { try? handle.close() }
|
||||
handle.seekToEndOfFile()
|
||||
handle.write(data)
|
||||
handle.synchronizeFile()
|
||||
} catch {
|
||||
NSLog("RuntimeLogStore append failed: %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
static func loadAll(limit: Int = 800) -> [RuntimeLogEntry] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
|
||||
guard let urls = try? FileManager.default.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: nil
|
||||
) else { return [] }
|
||||
let entries = urls
|
||||
.filter { $0.pathExtension == "jsonl" }
|
||||
.flatMap(readEntries)
|
||||
.sorted { $0.timestamp < $1.timestamp }
|
||||
return Array(entries.suffix(limit))
|
||||
}
|
||||
|
||||
static func clearAll() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
|
||||
private static func logURL(for source: String) throws -> URL {
|
||||
let directory = AppGroup.containerURL.appendingPathComponent("RuntimeLogs", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let process = (Bundle.main.bundleIdentifier ?? "unknown-process")
|
||||
.replacingOccurrences(of: "/", with: "-")
|
||||
let safeSource = source.replacingOccurrences(of: "/", with: "-")
|
||||
return directory.appendingPathComponent("\(process)-\(safeSource).jsonl")
|
||||
}
|
||||
|
||||
private static func rotateIfNeeded(_ url: URL) throws {
|
||||
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
let size = attributes[.size] as? NSNumber,
|
||||
size.uint64Value >= maximumBytes else { return }
|
||||
let backup = url.deletingPathExtension().appendingPathExtension("previous.jsonl")
|
||||
try? FileManager.default.removeItem(at: backup)
|
||||
try FileManager.default.moveItem(at: url, to: backup)
|
||||
}
|
||||
|
||||
private static func readEntries(_ url: URL) -> [RuntimeLogEntry] {
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let text = String(data: data, encoding: .utf8) else { return [] }
|
||||
return text.split(separator: "\n").compactMap { line in
|
||||
guard let data = String(line).data(using: .utf8) else { return nil }
|
||||
return try? decoder.decode(RuntimeLogEntry.self, from: data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum RuntimeLogger {
|
||||
static func debug(_ source: String, _ category: String, _ message: String, details: [String: String] = [:]) {
|
||||
write(.debug, source: source, category: category, message: message, details: details)
|
||||
}
|
||||
|
||||
static func info(_ source: String, _ category: String, _ message: String, details: [String: String] = [:]) {
|
||||
write(.info, source: source, category: category, message: message, details: details)
|
||||
}
|
||||
|
||||
static func warning(_ source: String, _ category: String, _ message: String, details: [String: String] = [:]) {
|
||||
write(.warning, source: source, category: category, message: message, details: details)
|
||||
}
|
||||
|
||||
static func error(_ source: String, _ category: String, _ message: String, error: Error? = nil, details: [String: String] = [:]) {
|
||||
var values = details
|
||||
if let error {
|
||||
let nsError = error as NSError
|
||||
values["error.domain"] = nsError.domain
|
||||
values["error.code"] = String(nsError.code)
|
||||
values["error.description"] = nsError.localizedDescription
|
||||
if !nsError.userInfo.isEmpty {
|
||||
values["error.userInfo"] = nsError.userInfo
|
||||
.map { "\($0.key)=\(String(describing: $0.value))" }
|
||||
.sorted()
|
||||
.joined(separator: "; ")
|
||||
}
|
||||
}
|
||||
write(.error, source: source, category: category, message: message, details: values)
|
||||
}
|
||||
|
||||
private static func write(_ level: RuntimeLogEntry.Level, source: String, category: String, message: String, details: [String: String]) {
|
||||
RuntimeLogStore.append(RuntimeLogEntry(
|
||||
source: source,
|
||||
level: level,
|
||||
category: category,
|
||||
message: message,
|
||||
details: details
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
|
||||
/// 环境验证结果,UI 层根据此值弹出对应的引导面板。
|
||||
enum VerificationResult: Equatable, Identifiable {
|
||||
case success
|
||||
case proxyNotRunning
|
||||
case verificationInProgress
|
||||
case verificationSuperseded
|
||||
case certNotTrusted
|
||||
case wifiProxyNotConfigured
|
||||
case coordinateWriteFailed(String)
|
||||
case patchFailed(String)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .success: return "成功"
|
||||
case .proxyNotRunning: return "代理未运行"
|
||||
case .verificationInProgress: return "已有验证正在进行"
|
||||
case .verificationSuperseded: return "验证已被新位置取代"
|
||||
case .certNotTrusted: return "证书未信任"
|
||||
case .wifiProxyNotConfigured: return "WiFi代理未配置"
|
||||
case .coordinateWriteFailed: return "坐标写入失败"
|
||||
case .patchFailed: return "改写验证失败"
|
||||
}
|
||||
}
|
||||
|
||||
var isSuccess: Bool { self == .success }
|
||||
|
||||
/// 对应的引导页类型
|
||||
var tipKind: TipKind? {
|
||||
switch self {
|
||||
case .success: return nil
|
||||
case .proxyNotRunning, .verificationInProgress, .verificationSuperseded: return nil
|
||||
case .certNotTrusted: return nil // 走完整引导页,不弹 tip
|
||||
case .wifiProxyNotConfigured: return .proxySetup
|
||||
case .coordinateWriteFailed, .patchFailed: return .rewriteFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user