mirror of
https://github.com/xweiba/location-spoofer.git
synced 2026-09-21 22:30:46 +08:00
feat: drop script version detection; split motion vs perturbation by mode
Third-party mode no longer probes /wloc-settings/version or capability metadata since the upstream scripts are unmaintained. Settings now show: - APP mode: motion-state simulation toggle only - third-party mode: random-perturbation (randomRadius) toggle only save() sends randomRadius so upstream wloc.js applies the offset.
This commit is contained in:
@@ -52,7 +52,6 @@ struct FirstSetupView: View {
|
|||||||
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
|
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
|
||||||
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
|
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
|
||||||
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
|
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
|
||||||
@ObservedObject private var motionSimulation = MotionSimulationStore.shared
|
|
||||||
@State private var copiedSubscriptionURL = false
|
@State private var copiedSubscriptionURL = false
|
||||||
@State private var copiedMITMHostname = false
|
@State private var copiedMITMHostname = false
|
||||||
@State private var screenshotPreview: SetupScreenshotPreview?
|
@State private var screenshotPreview: SetupScreenshotPreview?
|
||||||
@@ -848,17 +847,12 @@ struct FirstSetupView: View {
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
defer { isVerifying = false }
|
defer { isVerifying = false }
|
||||||
do {
|
do {
|
||||||
let response = try await thirdPartyProxy.validateConnection()
|
let response = try await thirdPartyProxy.query()
|
||||||
let advancedFeaturesAvailable = await thirdPartyProxy.refreshAdvancedFeatureAvailability()
|
|
||||||
if !advancedFeaturesAvailable {
|
|
||||||
motionSimulation.setEnabled(false)
|
|
||||||
}
|
|
||||||
let elapsedMilliseconds = Int(Date().timeIntervalSince(startedAt) * 1_000)
|
let elapsedMilliseconds = Int(Date().timeIntervalSince(startedAt) * 1_000)
|
||||||
RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方代理连接检测通过", details: [
|
RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方代理连接检测通过", details: [
|
||||||
"当前客户端": client.name,
|
"当前客户端": client.name,
|
||||||
"请求动作": "WLOC query",
|
"请求动作": "WLOC query",
|
||||||
"连接状态": response.latitude == nil || response.longitude == nil ? "已连接,无保存坐标" : "已连接,有保存坐标",
|
"连接状态": response.latitude == nil || response.longitude == nil ? "已连接,无保存坐标" : "已连接,有保存坐标",
|
||||||
"运动状态模拟": advancedFeaturesAvailable ? "支持" : "不支持,已关闭",
|
|
||||||
"耗时毫秒": String(elapsedMilliseconds)
|
"耗时毫秒": String(elapsedMilliseconds)
|
||||||
])
|
])
|
||||||
onComplete()
|
onComplete()
|
||||||
|
|||||||
+28
-89
@@ -25,6 +25,7 @@ struct SettingsView: View {
|
|||||||
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
|
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
|
||||||
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
|
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
|
||||||
@ObservedObject private var motionSimulation = MotionSimulationStore.shared
|
@ObservedObject private var motionSimulation = MotionSimulationStore.shared
|
||||||
|
@ObservedObject private var randomRadius = RandomRadiusStore.shared
|
||||||
@ObservedObject private var moduleSource = ThirdPartyModuleSourceStore.shared
|
@ObservedObject private var moduleSource = ThirdPartyModuleSourceStore.shared
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var activeTip: TipKind?
|
@State private var activeTip: TipKind?
|
||||||
@@ -85,15 +86,23 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section("定位模拟") {
|
Section("定位模拟") {
|
||||||
Toggle("运动状态模拟", isOn: motionSimulationBinding)
|
if runtimeMode.mode == .localWiFi {
|
||||||
.disabled(
|
Toggle("运动状态模拟", isOn: motionSimulationBinding)
|
||||||
modeOperationRunning ||
|
.disabled(
|
||||||
actions.state.isBusy ||
|
modeOperationRunning ||
|
||||||
thirdPartyProxy.isRequesting
|
actions.state.isBusy ||
|
||||||
)
|
thirdPartyProxy.isRequesting
|
||||||
Text("实验性功能,默认关闭。开启后会同时模拟定位响应中的运动状态。")
|
)
|
||||||
.font(.footnote)
|
Text("实验性功能,默认关闭。开启后会同时模拟定位响应中的运动状态。")
|
||||||
.foregroundStyle(.secondary)
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else if runtimeMode.mode == .thirdParty {
|
||||||
|
Toggle("随机扰动", isOn: randomRadiusBinding)
|
||||||
|
.disabled(modeOperationRunning || actions.state.isBusy)
|
||||||
|
Text("开启后,下次同步坐标时会给目标点添加随机偏移(默认半径 50 米),避免位置固定在同一点。")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if runtimeMode.mode == .thirdParty {
|
if runtimeMode.mode == .thirdParty {
|
||||||
@@ -257,16 +266,6 @@ struct SettingsView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("当前虚拟定位和本地代理将停止。App 会删除钥匙串中的设备 CA、立即生成新证书,并打开安装与信任引导。你还需要前往 iOS「设置 → 通用 → VPN 与设备管理」手动删除旧证书,然后重新下载安装并完全信任新证书。")
|
Text("当前虚拟定位和本地代理将停止。App 会删除钥匙串中的设备 CA、立即生成新证书,并打开安装与信任引导。你还需要前往 iOS「设置 → 通用 → VPN 与设备管理」手动删除旧证书,然后重新下载安装并完全信任新证书。")
|
||||||
}
|
}
|
||||||
.task(id: runtimeMode.mode) {
|
|
||||||
guard runtimeMode.mode == .thirdParty, !modeOperationRunning else { return }
|
|
||||||
if !(await thirdPartyProxy.refreshAdvancedFeatureAvailability()) {
|
|
||||||
disableUnsupportedThirdPartyMotionSimulation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: thirdPartyProxy.moduleUpdateRecommended) { updateRecommended in
|
|
||||||
guard updateRecommended else { return }
|
|
||||||
disableUnsupportedThirdPartyMotionSimulation()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func valueRow(_ title: String, value: String) -> some View {
|
private func valueRow(_ title: String, value: String) -> some View {
|
||||||
@@ -377,51 +376,18 @@ struct SettingsView: View {
|
|||||||
Binding(
|
Binding(
|
||||||
get: { motionSimulation.isEnabled },
|
get: { motionSimulation.isEnabled },
|
||||||
set: { enabled in
|
set: { enabled in
|
||||||
if runtimeMode.mode == .localWiFi {
|
proxy.applyMotionSimulation(enabled)
|
||||||
proxy.applyMotionSimulation(enabled)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard thirdPartyProxy.activeSettings?.success == true else {
|
|
||||||
guard enabled else {
|
|
||||||
motionSimulation.setEnabled(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
modeOperationRunning = true
|
|
||||||
Task { @MainActor in
|
|
||||||
if await thirdPartyProxy.refreshAdvancedFeatureAvailability() {
|
|
||||||
motionSimulation.setEnabled(true)
|
|
||||||
} else {
|
|
||||||
presentMotionSimulationModuleUpdateAlert()
|
|
||||||
}
|
|
||||||
modeOperationRunning = false
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
modeOperationRunning = true
|
|
||||||
Task { @MainActor in
|
|
||||||
do {
|
|
||||||
_ = try await thirdPartyProxy.updateMotionSimulation(enabled)
|
|
||||||
motionSimulation.setEnabled(enabled)
|
|
||||||
} catch {
|
|
||||||
RuntimeLogger.error(
|
|
||||||
"APP",
|
|
||||||
"ThirdPartyProxy",
|
|
||||||
"同步运动状态设置失败",
|
|
||||||
error: error,
|
|
||||||
details: ["当前客户端": thirdPartyClient.selectedClient.name]
|
|
||||||
)
|
|
||||||
if error as? ThirdPartyProxyError == .moduleOutdated {
|
|
||||||
presentMotionSimulationModuleUpdateAlert()
|
|
||||||
} else {
|
|
||||||
setup.requestThirdPartySetup(message: error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
modeOperationRunning = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var randomRadiusBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { randomRadius.isEnabled },
|
||||||
|
set: { randomRadius.setEnabled($0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var thirdPartyConfigurationSection: some View {
|
private var thirdPartyConfigurationSection: some View {
|
||||||
Section("第三方代理配置") {
|
Section("第三方代理配置") {
|
||||||
@@ -442,12 +408,6 @@ struct SettingsView: View {
|
|||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
if thirdPartyProxy.moduleUpdateRecommended {
|
|
||||||
Text("当前模块版本较旧,基础坐标功能仍可继续使用。重新导入最新模块后可使用版本检测和运动状态模拟。")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.orange)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let verificationText = thirdPartyClient.selectedClient.verificationText {
|
if let verificationText = thirdPartyClient.selectedClient.verificationText {
|
||||||
HStack {
|
HStack {
|
||||||
Text("验证状态")
|
Text("验证状态")
|
||||||
@@ -548,8 +508,7 @@ struct SettingsView: View {
|
|||||||
runtimeMode.setMode(.thirdParty)
|
runtimeMode.setMode(.thirdParty)
|
||||||
if runtimeMode.isInitialized(.thirdParty) {
|
if runtimeMode.isInitialized(.thirdParty) {
|
||||||
do {
|
do {
|
||||||
_ = try await thirdPartyProxy.validateConnection()
|
_ = try await thirdPartyProxy.query()
|
||||||
refreshThirdPartyAdvancedFeatures()
|
|
||||||
proxyOperationAlertTitle = "模式已切换"
|
proxyOperationAlertTitle = "模式已切换"
|
||||||
proxyOperationError = "第三方代理模式检测通过。请关闭 Wi-Fi 中的 127.0.0.1:8888 手动代理,避免双重拦截。"
|
proxyOperationError = "第三方代理模式检测通过。请关闭 Wi-Fi 中的 127.0.0.1:8888 手动代理,避免双重拦截。"
|
||||||
} catch {
|
} catch {
|
||||||
@@ -591,8 +550,7 @@ struct SettingsView: View {
|
|||||||
let startedAt = Date()
|
let startedAt = Date()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
_ = try await thirdPartyProxy.validateConnection()
|
_ = try await thirdPartyProxy.query()
|
||||||
refreshThirdPartyAdvancedFeatures()
|
|
||||||
RuntimeLogger.info("APP", "ThirdPartyProxy", "设置页第三方连接检测通过", details: [
|
RuntimeLogger.info("APP", "ThirdPartyProxy", "设置页第三方连接检测通过", details: [
|
||||||
"当前客户端": client.name,
|
"当前客户端": client.name,
|
||||||
"请求动作": "WLOC query",
|
"请求动作": "WLOC query",
|
||||||
@@ -618,25 +576,6 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshThirdPartyAdvancedFeatures() {
|
|
||||||
Task { @MainActor in
|
|
||||||
if !(await thirdPartyProxy.refreshAdvancedFeatureAvailability()) {
|
|
||||||
disableUnsupportedThirdPartyMotionSimulation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func presentMotionSimulationModuleUpdateAlert() {
|
|
||||||
disableUnsupportedThirdPartyMotionSimulation()
|
|
||||||
proxyOperationAlertTitle = "无法开启运动状态模拟"
|
|
||||||
proxyOperationError = "当前模块脚本不支持运动状态模拟,请重新导入最新模块脚本后再开启。基础坐标功能仍可继续使用。"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func disableUnsupportedThirdPartyMotionSimulation() {
|
|
||||||
guard runtimeMode.mode == .thirdParty else { return }
|
|
||||||
motionSimulation.setEnabled(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openThirdPartySetup(for error: Error) {
|
private func openThirdPartySetup(for error: Error) {
|
||||||
setup.requestThirdPartySetup(message: error.localizedDescription)
|
setup.requestThirdPartySetup(message: error.localizedDescription)
|
||||||
dismiss()
|
dismiss()
|
||||||
|
|||||||
@@ -66,6 +66,36 @@ final class MotionSimulationStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class RandomRadiusStore: ObservableObject {
|
||||||
|
static let shared = RandomRadiusStore()
|
||||||
|
|
||||||
|
private enum Key {
|
||||||
|
static let isEnabled = "randomRadius.isEnabled"
|
||||||
|
static let radius = "randomRadius.radius"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published private(set) var isEnabled: Bool
|
||||||
|
@Published private(set) var radius: Double
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = AppGroup.defaults) {
|
||||||
|
self.defaults = defaults
|
||||||
|
isEnabled = defaults.bool(forKey: Key.isEnabled)
|
||||||
|
radius = defaults.object(forKey: Key.radius) as? Double ?? 50
|
||||||
|
}
|
||||||
|
|
||||||
|
func setEnabled(_ enabled: Bool) {
|
||||||
|
isEnabled = enabled
|
||||||
|
defaults.set(enabled, forKey: Key.isEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRadius(_ radius: Double) {
|
||||||
|
self.radius = radius
|
||||||
|
defaults.set(radius, forKey: Key.radius)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ThirdPartyModuleSourceStore: ObservableObject {
|
final class ThirdPartyModuleSourceStore: ObservableObject {
|
||||||
static let shared = ThirdPartyModuleSourceStore()
|
static let shared = ThirdPartyModuleSourceStore()
|
||||||
|
|||||||
@@ -9,21 +9,6 @@ struct ThirdPartyProxySettingsResponse: Decodable, Equatable {
|
|||||||
let motionSimulationEnabled: Bool?
|
let motionSimulationEnabled: Bool?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ThirdPartyProxyVersionResponse: Decodable, Equatable {
|
|
||||||
let success: Bool
|
|
||||||
let moduleVersion: String
|
|
||||||
let protocolVersion: Int
|
|
||||||
let capabilities: Set<String>
|
|
||||||
|
|
||||||
static let requiredCapabilities: Set<String> = [
|
|
||||||
"wifi", "cellTower", "arpc", "marker", "synthetic", "bare", "motionSimulation"
|
|
||||||
]
|
|
||||||
|
|
||||||
var isCompatible: Bool {
|
|
||||||
success && protocolVersion >= 1 && capabilities.isSuperset(of: Self.requiredCapabilities)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ThirdPartyProxyConnectionState: Equatable {
|
enum ThirdPartyProxyConnectionState: Equatable {
|
||||||
case unknown
|
case unknown
|
||||||
case connected(active: Bool)
|
case connected(active: Bool)
|
||||||
@@ -36,7 +21,6 @@ enum ThirdPartyProxyError: LocalizedError, Equatable {
|
|||||||
case rejected(String)
|
case rejected(String)
|
||||||
case coordinateMismatch
|
case coordinateMismatch
|
||||||
case network(String)
|
case network(String)
|
||||||
case moduleOutdated
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -50,18 +34,11 @@ enum ThirdPartyProxyError: LocalizedError, Equatable {
|
|||||||
return "第三方代理保存的坐标与当前选点不一致"
|
return "第三方代理保存的坐标与当前选点不一致"
|
||||||
case .network(let message):
|
case .network(let message):
|
||||||
return "第三方代理请求失败:\(message)"
|
return "第三方代理请求失败:\(message)"
|
||||||
case .moduleOutdated:
|
|
||||||
return "模块版本过低,请重新导入模块"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var recoverySuggestion: String {
|
var recoverySuggestion: String {
|
||||||
switch self {
|
return "检查模块、MITM、证书和代理/VPN连接"
|
||||||
case .moduleOutdated:
|
|
||||||
return "删除旧模块后,重新复制并导入最新模块"
|
|
||||||
default:
|
|
||||||
return "检查模块、MITM、证书和代理/VPN连接"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func recoverySuggestion(for error: Error) -> String {
|
static func recoverySuggestion(for error: Error) -> String {
|
||||||
@@ -88,11 +65,9 @@ final class ThirdPartyProxyManager: ObservableObject {
|
|||||||
]
|
]
|
||||||
static let interceptionHostnamesText = interceptionHostnames.joined(separator: ", ")
|
static let interceptionHostnamesText = interceptionHostnames.joined(separator: ", ")
|
||||||
static let configurationEndpoint = URL(string: "https://gs-loc.apple.com/wloc-settings/save")!
|
static let configurationEndpoint = URL(string: "https://gs-loc.apple.com/wloc-settings/save")!
|
||||||
static let versionEndpoint = URL(string: "https://gs-loc.apple.com/wloc-settings/version")!
|
|
||||||
|
|
||||||
@Published private(set) var connectionState: ThirdPartyProxyConnectionState = .unknown
|
@Published private(set) var connectionState: ThirdPartyProxyConnectionState = .unknown
|
||||||
@Published private(set) var activeSettings: ThirdPartyProxySettingsResponse?
|
@Published private(set) var activeSettings: ThirdPartyProxySettingsResponse?
|
||||||
@Published private(set) var moduleUpdateRecommended = false
|
|
||||||
@Published private(set) var isRequesting = false
|
@Published private(set) var isRequesting = false
|
||||||
private let requester: any ThirdPartyProxyRequesting
|
private let requester: any ThirdPartyProxyRequesting
|
||||||
|
|
||||||
@@ -127,7 +102,8 @@ final class ThirdPartyProxyManager: ObservableObject {
|
|||||||
let response = try await perform(action: .save(
|
let response = try await perform(action: .save(
|
||||||
latitude: wgs84.latitude,
|
latitude: wgs84.latitude,
|
||||||
longitude: wgs84.longitude,
|
longitude: wgs84.longitude,
|
||||||
accuracy: favorite.accuracy
|
accuracy: favorite.accuracy,
|
||||||
|
randomRadius: RandomRadiusStore.shared.isEnabled ? RandomRadiusStore.shared.radius : 0
|
||||||
))
|
))
|
||||||
guard response.success else {
|
guard response.success else {
|
||||||
throw ThirdPartyProxyError.rejected(response.error ?? "第三方代理拒绝保存坐标")
|
throw ThirdPartyProxyError.rejected(response.error ?? "第三方代理拒绝保存坐标")
|
||||||
@@ -148,72 +124,6 @@ final class ThirdPartyProxyManager: ObservableObject {
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateMotionSimulation(_ enabled: Bool) async throws -> ThirdPartyProxySettingsResponse {
|
|
||||||
// Upstream Yu9191/wloc scripts do not implement motion-state simulation,
|
|
||||||
// so this can never succeed in third-party mode.
|
|
||||||
RuntimeLogger.warning("APP", "ThirdPartyProxy", "第三方模式不支持运动状态模拟", details: [
|
|
||||||
"请求动作": "motion simulation",
|
|
||||||
"处理建议": "运动状态模拟仅在 APP模式(内置代理)下可用"
|
|
||||||
])
|
|
||||||
throw ThirdPartyProxyError.moduleOutdated
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateConnection() async throws -> ThirdPartyProxySettingsResponse {
|
|
||||||
try await query()
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateVersion() async throws -> ThirdPartyProxyVersionResponse {
|
|
||||||
guard !isRequesting else {
|
|
||||||
throw ThirdPartyProxyError.rejected("已有第三方代理请求正在执行")
|
|
||||||
}
|
|
||||||
isRequesting = true
|
|
||||||
defer { isRequesting = false }
|
|
||||||
|
|
||||||
var request = URLRequest(url: Self.versionEndpoint)
|
|
||||||
request.httpMethod = "GET"
|
|
||||||
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
|
||||||
request.timeoutInterval = 8
|
|
||||||
do {
|
|
||||||
let (data, response) = try await requester.data(for: request)
|
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200,
|
|
||||||
let version = try? JSONDecoder().decode(ThirdPartyProxyVersionResponse.self, from: data),
|
|
||||||
version.isCompatible else {
|
|
||||||
throw ThirdPartyProxyError.moduleOutdated
|
|
||||||
}
|
|
||||||
RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方模块版本检测通过", details: [
|
|
||||||
"模块版本": version.moduleVersion,
|
|
||||||
"协议版本": String(version.protocolVersion),
|
|
||||||
"能力": version.capabilities.sorted().joined(separator: ",")
|
|
||||||
])
|
|
||||||
return version
|
|
||||||
} catch let error as ThirdPartyProxyError {
|
|
||||||
throw error
|
|
||||||
} catch {
|
|
||||||
throw ThirdPartyProxyError.network(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func refreshAdvancedFeatureAvailability() async -> Bool {
|
|
||||||
do {
|
|
||||||
_ = try await validateVersion()
|
|
||||||
moduleUpdateRecommended = false
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
moduleUpdateRecommended = true
|
|
||||||
RuntimeLogger.warning(
|
|
||||||
"APP",
|
|
||||||
"ThirdPartyProxy",
|
|
||||||
"第三方模块不支持高级功能",
|
|
||||||
details: [
|
|
||||||
"版本检测": error.localizedDescription,
|
|
||||||
"处理建议": "基础坐标功能可继续使用;更新模块后可使用运动状态模拟"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func clear() async throws {
|
func clear() async throws {
|
||||||
let response = try await perform(action: .clear)
|
let response = try await perform(action: .clear)
|
||||||
guard response.success else {
|
guard response.success else {
|
||||||
@@ -238,7 +148,7 @@ final class ThirdPartyProxyManager: ObservableObject {
|
|||||||
|
|
||||||
private enum Action {
|
private enum Action {
|
||||||
case query
|
case query
|
||||||
case save(latitude: Double, longitude: Double, accuracy: Int)
|
case save(latitude: Double, longitude: Double, accuracy: Int, randomRadius: Double)
|
||||||
case clear
|
case clear
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,11 +165,12 @@ final class ThirdPartyProxyManager: ObservableObject {
|
|||||||
components.queryItems = [URLQueryItem(name: "action", value: "query")]
|
components.queryItems = [URLQueryItem(name: "action", value: "query")]
|
||||||
case .clear:
|
case .clear:
|
||||||
components.queryItems = [URLQueryItem(name: "action", value: "clear")]
|
components.queryItems = [URLQueryItem(name: "action", value: "clear")]
|
||||||
case .save(let latitude, let longitude, let accuracy):
|
case .save(let latitude, let longitude, let accuracy, let randomRadius):
|
||||||
components.queryItems = [
|
components.queryItems = [
|
||||||
URLQueryItem(name: "lon", value: String(format: "%.8f", locale: Locale(identifier: "en_US_POSIX"), longitude)),
|
URLQueryItem(name: "lon", value: String(format: "%.8f", locale: Locale(identifier: "en_US_POSIX"), longitude)),
|
||||||
URLQueryItem(name: "lat", value: String(format: "%.8f", locale: Locale(identifier: "en_US_POSIX"), latitude)),
|
URLQueryItem(name: "lat", value: String(format: "%.8f", locale: Locale(identifier: "en_US_POSIX"), latitude)),
|
||||||
URLQueryItem(name: "acc", value: String(accuracy))
|
URLQueryItem(name: "acc", value: String(accuracy)),
|
||||||
|
URLQueryItem(name: "randomRadius", value: String(randomRadius))
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
guard let url = components.url else { throw ThirdPartyProxyError.invalidResponse }
|
guard let url = components.url else { throw ThirdPartyProxyError.invalidResponse }
|
||||||
|
|||||||
@@ -40,44 +40,17 @@ final class ThirdPartyProxyManagerTests: XCTestCase {
|
|||||||
XCTAssertEqual(manager.connectionState, .connected(active: true))
|
XCTAssertEqual(manager.connectionState, .connected(active: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testVersionEndpointRequiresProtocolAndCapabilities() async throws {
|
|
||||||
let requester = FakeThirdPartyRequester(body: #"{"success":false,"error":"无已保存的坐标"}"#)
|
|
||||||
let manager = ThirdPartyProxyManager(requester: requester)
|
|
||||||
|
|
||||||
let version = try await manager.validateVersion()
|
|
||||||
|
|
||||||
XCTAssertEqual(requester.lastURL?.path, "/wloc-settings/version")
|
|
||||||
XCTAssertEqual(version.moduleVersion, "1.0.0")
|
|
||||||
XCTAssertTrue(version.isCompatible)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testConnectionUsesLegacySaveQueryEndpoint() async throws {
|
func testConnectionUsesLegacySaveQueryEndpoint() async throws {
|
||||||
let requester = FakeThirdPartyRequester(body: #"{"success":false,"error":"无已保存的坐标"}"#)
|
let requester = FakeThirdPartyRequester(body: #"{"success":false,"error":"无已保存的坐标"}"#)
|
||||||
let manager = ThirdPartyProxyManager(requester: requester)
|
let manager = ThirdPartyProxyManager(requester: requester)
|
||||||
|
|
||||||
let response = try await manager.validateConnection()
|
let response = try await manager.query()
|
||||||
|
|
||||||
XCTAssertFalse(response.success)
|
XCTAssertFalse(response.success)
|
||||||
XCTAssertFalse(manager.moduleUpdateRecommended)
|
|
||||||
XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/save"])
|
XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/save"])
|
||||||
XCTAssertEqual(requester.requestedURLs.first?.query, "action=query")
|
XCTAssertEqual(requester.requestedURLs.first?.query, "action=query")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMissingVersionDisablesOnlyAdvancedFeatures() async {
|
|
||||||
let requester = FakeThirdPartyRequester(
|
|
||||||
body: #"{"success":false,"error":"无已保存的坐标"}"#,
|
|
||||||
versionBody: "not-json"
|
|
||||||
)
|
|
||||||
let manager = ThirdPartyProxyManager(requester: requester)
|
|
||||||
|
|
||||||
let isAvailable = await manager.refreshAdvancedFeatureAvailability()
|
|
||||||
|
|
||||||
XCTAssertFalse(isAvailable)
|
|
||||||
XCTAssertTrue(manager.moduleUpdateRecommended)
|
|
||||||
XCTAssertEqual(manager.connectionState, .unknown)
|
|
||||||
XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/version"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func testLegacyModuleCanStillSaveBasicCoordinates() async throws {
|
func testLegacyModuleCanStillSaveBasicCoordinates() async throws {
|
||||||
let favorite = FavoriteLocation(
|
let favorite = FavoriteLocation(
|
||||||
name: "深圳湾",
|
name: "深圳湾",
|
||||||
@@ -93,16 +66,25 @@ final class ThirdPartyProxyManagerTests: XCTestCase {
|
|||||||
wgs84.longitude,
|
wgs84.longitude,
|
||||||
wgs84.latitude
|
wgs84.latitude
|
||||||
)
|
)
|
||||||
let requester = FakeThirdPartyRequester(body: body, versionBody: "not-json")
|
let requester = FakeThirdPartyRequester(body: body)
|
||||||
let manager = ThirdPartyProxyManager(requester: requester)
|
let manager = ThirdPartyProxyManager(requester: requester)
|
||||||
|
|
||||||
let response = try await manager.save(favorite)
|
let response = try await manager.save(favorite)
|
||||||
|
|
||||||
XCTAssertTrue(response.success)
|
XCTAssertTrue(response.success)
|
||||||
XCTAssertFalse(manager.moduleUpdateRecommended)
|
|
||||||
XCTAssertEqual(manager.connectionState, .connected(active: true))
|
XCTAssertEqual(manager.connectionState, .connected(active: true))
|
||||||
XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/save"])
|
XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/save"])
|
||||||
XCTAssertNil(requester.requestedURLs.first?.query)
|
let queryComponents = URLComponents(
|
||||||
|
url: try XCTUnwrap(requester.requestedURLs.first),
|
||||||
|
resolvingAgainstBaseURL: false
|
||||||
|
)
|
||||||
|
let values = Dictionary(
|
||||||
|
uniqueKeysWithValues: (queryComponents?.queryItems ?? []).map { ($0.name, $0.value ?? "") }
|
||||||
|
)
|
||||||
|
XCTAssertEqual(values["lon"], String(format: "%.8f", locale: Locale(identifier: "en_US_POSIX"), wgs84.longitude))
|
||||||
|
XCTAssertEqual(values["lat"], String(format: "%.8f", locale: Locale(identifier: "en_US_POSIX"), wgs84.latitude))
|
||||||
|
XCTAssertEqual(values["acc"], "20")
|
||||||
|
XCTAssertEqual(values["randomRadius"], "0")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBrokenSaveQueryFailsWithoutCheckingVersion() async {
|
func testBrokenSaveQueryFailsWithoutCheckingVersion() async {
|
||||||
@@ -110,7 +92,7 @@ final class ThirdPartyProxyManagerTests: XCTestCase {
|
|||||||
let manager = ThirdPartyProxyManager(requester: requester)
|
let manager = ThirdPartyProxyManager(requester: requester)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try await manager.validateConnection()
|
_ = try await manager.query()
|
||||||
XCTFail("expected interception failure")
|
XCTFail("expected interception failure")
|
||||||
} catch {
|
} catch {
|
||||||
XCTAssertEqual(error as? ThirdPartyProxyError, .moduleNotIntercepted)
|
XCTAssertEqual(error as? ThirdPartyProxyError, .moduleNotIntercepted)
|
||||||
@@ -189,16 +171,11 @@ final class ThirdPartyProxyManagerTests: XCTestCase {
|
|||||||
|
|
||||||
private final class FakeThirdPartyRequester: ThirdPartyProxyRequesting {
|
private final class FakeThirdPartyRequester: ThirdPartyProxyRequesting {
|
||||||
private let data: Data
|
private let data: Data
|
||||||
private let versionData: Data
|
|
||||||
private(set) var lastURL: URL?
|
private(set) var lastURL: URL?
|
||||||
private(set) var requestedURLs: [URL] = []
|
private(set) var requestedURLs: [URL] = []
|
||||||
|
|
||||||
init(
|
init(body: String) {
|
||||||
body: String,
|
|
||||||
versionBody: String = #"{"success":true,"moduleVersion":"1.0.0","protocolVersion":1,"capabilities":["wifi","cellTower","arpc","marker","synthetic","bare","motionSimulation"]}"#
|
|
||||||
) {
|
|
||||||
data = Data(body.utf8)
|
data = Data(body.utf8)
|
||||||
versionData = Data(versionBody.utf8)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||||
@@ -210,6 +187,6 @@ private final class FakeThirdPartyRequester: ThirdPartyProxyRequesting {
|
|||||||
httpVersion: "HTTP/1.1",
|
httpVersion: "HTTP/1.1",
|
||||||
headerFields: ["Content-Type": "application/json"]
|
headerFields: ["Content-Type": "application/json"]
|
||||||
)!
|
)!
|
||||||
return (request.url?.path == "/wloc-settings/version" ? versionData : data, response)
|
return (data, response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,43 +67,19 @@ grep -q 'Label("查看诊断日志"' "$SETUP" \
|
|||||||
|| fail "third-party connection failure must expose the diagnostics action"
|
|| fail "third-party connection failure must expose the diagnostics action"
|
||||||
! grep -q 'setupActionError = error.localizedDescription' "$SETUP" \
|
! grep -q 'setupActionError = error.localizedDescription' "$SETUP" \
|
||||||
|| fail "third-party connection failure must not use the generic failure alert"
|
|| fail "third-party connection failure must not use the generic failure alert"
|
||||||
grep -q 'let response = try await thirdPartyProxy.validateConnection()' "$SETUP" \
|
grep -q 'let response = try await thirdPartyProxy.query()' "$SETUP" \
|
||||||
|| fail "the import page must validate the legacy save/query endpoint"
|
|| fail "the import page must validate the save/query endpoint"
|
||||||
grep -A20 'let response = try await thirdPartyProxy.validateConnection()' "$SETUP" \
|
grep -A20 'let response = try await thirdPartyProxy.query()' "$SETUP" | grep -q 'onComplete()' \
|
||||||
| grep -q 'refreshAdvancedFeatureAvailability()' \
|
|
||||||
|| fail "the import page must also probe advanced module capabilities"
|
|
||||||
grep -A20 'let response = try await thirdPartyProxy.validateConnection()' "$SETUP" \
|
|
||||||
| grep -q 'motionSimulation.setEnabled(false)' \
|
|
||||||
|| fail "an incompatible module must turn motion simulation off without failing basic setup"
|
|
||||||
grep -A20 'let response = try await thirdPartyProxy.validateConnection()' "$SETUP" | grep -q 'onComplete()' \
|
|
||||||
|| fail "a successful third-party connection test must close setup immediately"
|
|| fail "a successful third-party connection test must close setup immediately"
|
||||||
grep -q 'components.queryItems = \[URLQueryItem(name: "action", value: "query")\]' "$MANAGER" \
|
grep -q 'components.queryItems = \[URLQueryItem(name: "action", value: "query")\]' "$MANAGER" \
|
||||||
|| fail "the connection test must preserve the established save?action=query contract"
|
|| fail "the connection test must preserve the established save?action=query contract"
|
||||||
grep -q 'thirdPartyProxy.moduleUpdateRecommended' "$SETTINGS" \
|
grep -B4 'Toggle("运动状态模拟"' "$SETTINGS" | grep -q 'runtimeMode.mode == .localWiFi' \
|
||||||
|| fail "Settings must react to legacy module compatibility mode"
|
|| fail "the motion-state toggle must only appear in APP mode"
|
||||||
grep -q '当前模块版本较旧,基础坐标功能仍可继续使用' "$SETTINGS" \
|
grep -B4 'Toggle("随机扰动"' "$SETTINGS" | grep -q 'runtimeMode.mode == .thirdParty' \
|
||||||
|| fail "Settings must explain that legacy modules retain basic coordinate support"
|
|| fail "the random-perturbation toggle must only appear in third-party mode"
|
||||||
! grep -A8 'Toggle("运动状态模拟"' "$SETTINGS" | grep -q 'thirdPartyProxy.moduleUpdateRecommended' \
|
! grep -q 'validateVersion\|refreshAdvancedFeatureAvailability\|moduleUpdateRecommended' \
|
||||||
|| fail "legacy module compatibility must turn motion simulation off without disabling its toggle"
|
"$SETUP" "$SETTINGS" "$MANAGER" \
|
||||||
grep -q 'refreshAdvancedFeatureAvailability' "$SETTINGS" \
|
|| fail "script version detection must be removed from the app"
|
||||||
|| fail "Settings must probe advanced module availability independently"
|
|
||||||
grep -A18 'guard thirdPartyProxy.activeSettings?.success == true else' "$SETTINGS" \
|
|
||||||
| grep -q 'refreshAdvancedFeatureAvailability()' \
|
|
||||||
|| fail "enabling inactive third-party motion simulation must validate the version endpoint first"
|
|
||||||
grep -A18 'guard thirdPartyProxy.activeSettings?.success == true else' "$SETTINGS" \
|
|
||||||
| grep -q 'motionSimulation.setEnabled(true)' \
|
|
||||||
|| fail "inactive third-party motion simulation may enable only after version validation succeeds"
|
|
||||||
grep -q 'proxyOperationAlertTitle = "无法开启运动状态模拟"' "$SETTINGS" \
|
|
||||||
|| fail "motion simulation must show a dedicated failure alert when version validation fails"
|
|
||||||
grep -q '请重新导入最新模块脚本后再开启' "$SETTINGS" \
|
|
||||||
|| fail "motion simulation failure must tell the user to update the module script"
|
|
||||||
test "$(grep -c 'presentMotionSimulationModuleUpdateAlert()' "$SETTINGS")" -ge 3 \
|
|
||||||
|| fail "inactive and active motion simulation paths must share the module-update alert"
|
|
||||||
grep -q 'onChange(of: thirdPartyProxy.moduleUpdateRecommended)' "$SETTINGS" \
|
|
||||||
|| fail "Settings must react when an installed module becomes incompatible"
|
|
||||||
grep -A5 'private func disableUnsupportedThirdPartyMotionSimulation()' "$SETTINGS" \
|
|
||||||
| grep -q 'motionSimulation.setEnabled(false)' \
|
|
||||||
|| fail "unsupported third-party modules must force motion simulation off before disabling the toggle"
|
|
||||||
! grep -q 'thirdPartyTestResult?.success' "$SETUP" \
|
! grep -q 'thirdPartyTestResult?.success' "$SETUP" \
|
||||||
|| fail "a successful third-party test must not leave a separate completion state"
|
|| fail "a successful third-party test must not leave a separate completion state"
|
||||||
grep -q 'setupStep = \.thirdPartyImport' "$ROOT/App/SetupCoordinator.swift" \
|
grep -q 'setupStep = \.thirdPartyImport' "$ROOT/App/SetupCoordinator.swift" \
|
||||||
|
|||||||
Reference in New Issue
Block a user