mirror of
https://github.com/xweiba/location-spoofer.git
synced 2026-09-25 16:11:54 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eadeca636d | ||
|
|
b530d52add | ||
|
|
135e26ea7a | ||
|
|
d35d5c788a | ||
|
|
cce2aef58f | ||
|
|
9cea68392e | ||
|
|
fb0ca961a4 | ||
|
|
a666f177e6 | ||
|
|
a6b9e8056b | ||
|
|
9510d9aa42 | ||
|
|
41dfe7a8ed |
@@ -2,6 +2,9 @@
|
|||||||
.superpowers/
|
.superpowers/
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
!ThirdParty/WlocScripts/dist/
|
||||||
|
!ThirdParty/WlocScripts/dist/**
|
||||||
|
node_modules/
|
||||||
DerivedData/
|
DerivedData/
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
*.xcuserstate
|
*.xcuserstate
|
||||||
|
|||||||
+47
-17
@@ -393,8 +393,22 @@ struct FirstSetupView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var thirdPartyMITMWarning: some View {
|
||||||
|
Label {
|
||||||
|
Text("iOS 27 beta 6 起,系统已禁止对 gs-loc.apple.com 进行 MITM 拦截。该版本及之后的 beta 版本暂时无法使用本项目,等待后续适配方案。")
|
||||||
|
.font(.footnote)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
}
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(10)
|
||||||
|
.background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
|
||||||
private var thirdPartyClientStep: some View {
|
private var thirdPartyClientStep: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
thirdPartyMITMWarning
|
||||||
if !setup.message.isEmpty {
|
if !setup.message.isEmpty {
|
||||||
Label(setup.message, systemImage: "exclamationmark.triangle.fill")
|
Label(setup.message, systemImage: "exclamationmark.triangle.fill")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
@@ -463,7 +477,7 @@ struct FirstSetupView: View {
|
|||||||
|
|
||||||
Text("适配要求")
|
Text("适配要求")
|
||||||
.font(.subheadline.bold())
|
.font(.subheadline.bold())
|
||||||
Text("客户端需要支持请求脚本、持久化存储、HTTP 200 JSON 响应、Apple WLOC 响应脚本,以及 gs-loc.apple.com / gs-loc-cn.apple.com 的 HTTPS 解密。保存接口和 WLOC 响应脚本必须读取同一份持久化数据。")
|
Text("客户端需要支持请求脚本、持久化存储、HTTP 200 JSON 响应、Apple WLOC 响应脚本,以及 Apple 定位域名(gs-loc.apple.com、gsp-ssl.ls.apple.com、bluedot.is.autonavi.com 等)的 HTTPS 解密。保存接口和 WLOC 响应脚本必须读取同一份持久化数据。")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -475,6 +489,7 @@ struct FirstSetupView: View {
|
|||||||
private var thirdPartyImportStep: some View {
|
private var thirdPartyImportStep: some View {
|
||||||
let client = thirdPartyClient.selectedClient
|
let client = thirdPartyClient.selectedClient
|
||||||
return VStack(alignment: .leading, spacing: 16) {
|
return VStack(alignment: .leading, spacing: 16) {
|
||||||
|
thirdPartyMITMWarning
|
||||||
if showThirdPartyRepairReason {
|
if showThirdPartyRepairReason {
|
||||||
Label(
|
Label(
|
||||||
"检测到第三方代理连接异常,请检查模块、MITM 和代理连接后重新检测。",
|
"检测到第三方代理连接异常,请检查模块、MITM 和代理连接后重新检测。",
|
||||||
@@ -533,10 +548,17 @@ struct FirstSetupView: View {
|
|||||||
shadowrocketHTTPSDecryptionGuide
|
shadowrocketHTTPSDecryptionGuide
|
||||||
} else {
|
} else {
|
||||||
GroupBox(label: Label("第 2 步:完成 \(client.name) 配置", systemImage: "slider.horizontal.3")) {
|
GroupBox(label: Label("第 2 步:完成 \(client.name) 配置", systemImage: "slider.horizontal.3")) {
|
||||||
Text("请在 \(client.name) 中完成相应配置。")
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
.font(.caption)
|
Text("请在 \(client.name) 中完成相应配置。")
|
||||||
.foregroundStyle(.secondary)
|
.font(.caption)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Text("配置时请复制下方全部解密域名(含 gsp-ssl.ls.apple.com、bluedot.is.autonavi.com)。")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
mitmHostnameCopyButton
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,21 +578,14 @@ struct FirstSetupView: View {
|
|||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
instructionRow(1, "进入“配置 → 本地文件”,找到带黄点的配置,点击右侧 i 图标。")
|
instructionRow(1, "进入“配置 → 本地文件”,找到带黄点的配置,点击右侧 i 图标。")
|
||||||
instructionRow(2, "进入“HTTPS 解密”,开启解密开关。")
|
instructionRow(2, "进入“HTTPS 解密”,开启解密开关。")
|
||||||
instructionRow(3, "在域名列表中添加 gs-loc.apple.com。")
|
instructionRow(3, "在域名列表中添加下方复制的全部解密域名。")
|
||||||
setupScreenshot(
|
setupScreenshot(
|
||||||
assetName: "ShadowrocketHTTPSDecryption",
|
assetName: "ShadowrocketHTTPSDecryption",
|
||||||
title: "配置 HTTPS 解密",
|
title: "配置 HTTPS 解密",
|
||||||
caption: "1 开启 HTTPS 解密,2 添加 gs-loc.apple.com,3 打开证书设置。"
|
caption: "1 开启 HTTPS 解密,2 添加复制的全部解密域名,3 打开证书设置。"
|
||||||
)
|
)
|
||||||
|
|
||||||
Button {
|
mitmHostnameCopyButton
|
||||||
UIPasteboard.general.string = ThirdPartyProxyManager.interceptionHostname
|
|
||||||
copiedMITMHostname = true
|
|
||||||
} label: {
|
|
||||||
Label(copiedMITMHostname ? "已复制 gs-loc.apple.com" : "复制解密域名", systemImage: "doc.on.doc")
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
|
|
||||||
instructionRow(4, "按 Shadowrocket 提示生成并完成证书授权。")
|
instructionRow(4, "按 Shadowrocket 提示生成并完成证书授权。")
|
||||||
setupScreenshot(
|
setupScreenshot(
|
||||||
@@ -595,6 +610,20 @@ struct FirstSetupView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var mitmHostnameCopyButton: some View {
|
||||||
|
Button {
|
||||||
|
UIPasteboard.general.string = ThirdPartyProxyManager.interceptionHostnamesText
|
||||||
|
copiedMITMHostname = true
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
copiedMITMHostname ? "已复制解密域名" : "复制解密域名",
|
||||||
|
systemImage: "doc.on.doc"
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
|
||||||
private func instructionRow(_ number: Int, _ text: String) -> some View {
|
private func instructionRow(_ number: Int, _ text: String) -> some View {
|
||||||
HStack(alignment: .top, spacing: 8) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
Text("\(number)")
|
Text("\(number)")
|
||||||
@@ -842,13 +871,14 @@ struct FirstSetupView: View {
|
|||||||
"连接状态": connectionState,
|
"连接状态": connectionState,
|
||||||
"耗时毫秒": String(elapsedMilliseconds),
|
"耗时毫秒": String(elapsedMilliseconds),
|
||||||
"错误类型": errorType,
|
"错误类型": errorType,
|
||||||
"处理建议": "检查模块、MITM、证书和代理/VPN连接"
|
"处理建议": ThirdPartyProxyError.recoverySuggestion(for: error)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
thirdPartyTestFailure = ThirdPartyConnectionTestFailure(
|
thirdPartyTestFailure = ThirdPartyConnectionTestFailure(
|
||||||
message: """
|
message: """
|
||||||
======== 第三方代理连接检测 ========
|
======== 第三方代理连接检测 ========
|
||||||
当前客户端:\(client.name)
|
当前客户端:\(client.name)
|
||||||
|
配置接口:/wloc-settings/save
|
||||||
请求动作:WLOC query
|
请求动作:WLOC query
|
||||||
检查范围:模块拦截、MITM、证书、代理/VPN 连接
|
检查范围:模块拦截、MITM、证书、代理/VPN 连接
|
||||||
连接状态:\(connectionState)
|
连接状态:\(connectionState)
|
||||||
@@ -856,7 +886,7 @@ struct FirstSetupView: View {
|
|||||||
耗时:\(elapsedMilliseconds) ms
|
耗时:\(elapsedMilliseconds) ms
|
||||||
错误类型:\(errorType)
|
错误类型:\(errorType)
|
||||||
错误详情:\(error.localizedDescription)
|
错误详情:\(error.localizedDescription)
|
||||||
处理建议:确认模块已启用,并检查 MITM、证书和代理/VPN 连接后重试。
|
处理建议:\(ThirdPartyProxyError.recoverySuggestion(for: error))。
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
showsThirdPartyFailureLog = true
|
showsThirdPartyFailureLog = true
|
||||||
|
|||||||
@@ -662,7 +662,7 @@ struct MapHomeView: View {
|
|||||||
"当前客户端": thirdPartyClient.selectedClient.name,
|
"当前客户端": thirdPartyClient.selectedClient.name,
|
||||||
"请求动作": "WLOC save",
|
"请求动作": "WLOC save",
|
||||||
"恢复状态": wasActive ? "保留原第三方坐标" : "保持未启用",
|
"恢复状态": wasActive ? "保留原第三方坐标" : "保持未启用",
|
||||||
"处理建议": "检查模块、MITM、证书和代理/VPN连接"
|
"处理建议": ThirdPartyProxyError.recoverySuggestion(for: error)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
setup.requestThirdPartySetup(message: error.localizedDescription)
|
setup.requestThirdPartySetup(message: error.localizedDescription)
|
||||||
@@ -744,7 +744,7 @@ struct MapHomeView: View {
|
|||||||
"当前客户端": thirdPartyClient.selectedClient.name,
|
"当前客户端": thirdPartyClient.selectedClient.name,
|
||||||
"请求动作": "WLOC clear",
|
"请求动作": "WLOC clear",
|
||||||
"恢复状态": "保留已启用状态",
|
"恢复状态": "保留已启用状态",
|
||||||
"处理建议": "检查模块、MITM、证书和代理/VPN连接"
|
"处理建议": ThirdPartyProxyError.recoverySuggestion(for: error)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
setup.requestThirdPartySetup(message: error.localizedDescription)
|
setup.requestThirdPartySetup(message: error.localizedDescription)
|
||||||
|
|||||||
+33
-2
@@ -25,12 +25,21 @@ final class ProxyManager: ObservableObject {
|
|||||||
let lon = settings.flatMap { $0.enabled ? $0.longitude : nil } ?? 0
|
let lon = settings.flatMap { $0.enabled ? $0.longitude : nil } ?? 0
|
||||||
let enabled = (settings?.enabled ?? false) ? CInt(1) : CInt(0)
|
let enabled = (settings?.enabled ?? false) ? CInt(1) : CInt(0)
|
||||||
let accuracy = CInt(settings?.accuracy ?? 25)
|
let accuracy = CInt(settings?.accuracy ?? 25)
|
||||||
|
let motionEnabled = MotionSimulationStore.shared.isEnabled ? CInt(1) : CInt(0)
|
||||||
if enabled != 0 {
|
if enabled != 0 {
|
||||||
RuntimeLogger.info("APP", "坐标转换", "启动代理: 恢复上次 WGS-84 定位")
|
RuntimeLogger.info("APP", "坐标转换", "启动代理: 恢复上次 WGS-84 定位")
|
||||||
}
|
}
|
||||||
let result: UInt = authority.certPEM.withCString { cp in
|
let result: UInt = authority.certPEM.withCString { cp in
|
||||||
authority.keyPEM.withCString { kp in
|
authority.keyPEM.withCString { kp in
|
||||||
UInt(wloccore_startproxy(UnsafeMutablePointer(mutating: cp), UnsafeMutablePointer(mutating: kp), CDouble(lat), CDouble(lon), enabled, accuracy))
|
UInt(wloccore_startproxyv2(
|
||||||
|
UnsafeMutablePointer(mutating: cp),
|
||||||
|
UnsafeMutablePointer(mutating: kp),
|
||||||
|
CDouble(lat),
|
||||||
|
CDouble(lon),
|
||||||
|
enabled,
|
||||||
|
accuracy,
|
||||||
|
motionEnabled
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard result != 0 else { CoreBridge.flushLogs(category: "Proxy"); throw ProxyError.startFailed }
|
guard result != 0 else { CoreBridge.flushLogs(category: "Proxy"); throw ProxyError.startFailed }
|
||||||
@@ -58,7 +67,13 @@ final class ProxyManager: ObservableObject {
|
|||||||
@discardableResult
|
@discardableResult
|
||||||
func setCoords(lat: Double, lon: Double, enabled: Bool, accuracy: Int = 25) -> UInt64 {
|
func setCoords(lat: Double, lon: Double, enabled: Bool, accuracy: Int = 25) -> UInt64 {
|
||||||
coordinateRevision &+= 1
|
coordinateRevision &+= 1
|
||||||
wloccore_setcoords(CDouble(lat), CDouble(lon), enabled ? 1 : 0, CInt(accuracy))
|
wloccore_setpatchconfig(
|
||||||
|
CDouble(lat),
|
||||||
|
CDouble(lon),
|
||||||
|
enabled ? 1 : 0,
|
||||||
|
CInt(accuracy),
|
||||||
|
MotionSimulationStore.shared.isEnabled ? 1 : 0
|
||||||
|
)
|
||||||
RuntimeLogger.info("APP", "Proxy.coords", "写入坐标", details: [
|
RuntimeLogger.info("APP", "Proxy.coords", "写入坐标", details: [
|
||||||
"revision": String(coordinateRevision),
|
"revision": String(coordinateRevision),
|
||||||
"enabled": String(enabled),
|
"enabled": String(enabled),
|
||||||
@@ -119,6 +134,22 @@ final class ProxyManager: ObservableObject {
|
|||||||
return (Double(r.r0), Double(r.r1), r.r2 != 0)
|
return (Double(r.r0), Double(r.r1), r.r2 != 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyMotionSimulation(_ enabled: Bool) {
|
||||||
|
MotionSimulationStore.shared.setEnabled(enabled)
|
||||||
|
let settings = WlocSettingsStore.load()
|
||||||
|
wloccore_setpatchconfig(
|
||||||
|
CDouble(settings?.latitude ?? 0),
|
||||||
|
CDouble(settings?.longitude ?? 0),
|
||||||
|
settings?.enabled == true ? 1 : 0,
|
||||||
|
CInt(settings?.accuracy ?? 25),
|
||||||
|
enabled ? 1 : 0
|
||||||
|
)
|
||||||
|
RuntimeLogger.info("APP", "Proxy.motion", "运动状态模拟设置已更新", details: [
|
||||||
|
"enabled": String(enabled)
|
||||||
|
])
|
||||||
|
CoreBridge.flushLogs(category: "Proxy")
|
||||||
|
}
|
||||||
|
|
||||||
func prepareCertificateDownloadURL() async -> URL? {
|
func prepareCertificateDownloadURL() async -> URL? {
|
||||||
do {
|
do {
|
||||||
if !isRunning { try await start() }
|
if !isRunning { try await start() }
|
||||||
|
|||||||
+158
-3
@@ -1,5 +1,22 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
private enum UpdateCheckResult: Identifiable {
|
||||||
|
case current(currentVersion: String, latestVersion: String)
|
||||||
|
case available(AppUpdatePrompt)
|
||||||
|
case failed
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .current(let currentVersion, let latestVersion):
|
||||||
|
return "current-\(currentVersion)-\(latestVersion)"
|
||||||
|
case .available(let prompt):
|
||||||
|
return "available-\(prompt.id)"
|
||||||
|
case .failed:
|
||||||
|
return "failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@ObservedObject var setup: SetupCoordinator
|
@ObservedObject var setup: SetupCoordinator
|
||||||
@ObservedObject var actions: LocationActionCoordinator
|
@ObservedObject var actions: LocationActionCoordinator
|
||||||
@@ -7,14 +24,20 @@ struct SettingsView: 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
|
||||||
|
@ObservedObject private var randomRadius = RandomRadiusStore.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?
|
||||||
@State private var proxyOperationError = ""
|
@State private var proxyOperationError = ""
|
||||||
@State private var proxyOperationAlertTitle = "代理操作失败"
|
@State private var proxyOperationAlertTitle = "代理操作失败"
|
||||||
@State private var modeOperationRunning = false
|
@State private var modeOperationRunning = false
|
||||||
@State private var copiedClient: ThirdPartyProxyClient?
|
@State private var copiedClient: ThirdPartyProxyClient?
|
||||||
|
@State private var copiedMITMHostnames = false
|
||||||
@State private var showCertificateResetConfirmation = false
|
@State private var showCertificateResetConfirmation = false
|
||||||
@State private var githubDestination: SafariDestination?
|
@State private var githubDestination: SafariDestination?
|
||||||
|
@State private var isCheckingForUpdates = false
|
||||||
|
@State private var updateCheckResult: UpdateCheckResult?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
@@ -62,6 +85,26 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section("定位模拟") {
|
||||||
|
if runtimeMode.mode == .localWiFi {
|
||||||
|
Toggle("运动状态模拟", isOn: motionSimulationBinding)
|
||||||
|
.disabled(
|
||||||
|
modeOperationRunning ||
|
||||||
|
actions.state.isBusy ||
|
||||||
|
thirdPartyProxy.isRequesting
|
||||||
|
)
|
||||||
|
Text("实验性功能,默认关闭。开启后会同时模拟定位响应中的运动状态。")
|
||||||
|
.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 {
|
||||||
thirdPartyConfigurationSection
|
thirdPartyConfigurationSection
|
||||||
} else {
|
} else {
|
||||||
@@ -99,6 +142,19 @@ struct SettingsView: View {
|
|||||||
Label("进入引导页", systemImage: "arrow.clockwise.circle")
|
Label("进入引导页", systemImage: "arrow.clockwise.circle")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Button {
|
||||||
|
checkForUpdates()
|
||||||
|
} label: {
|
||||||
|
if isCheckingForUpdates {
|
||||||
|
HStack {
|
||||||
|
ProgressView()
|
||||||
|
Text("正在检查…")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Label("检查更新", systemImage: "arrow.triangle.2.circlepath")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isCheckingForUpdates)
|
||||||
valueRow("版本", value: versionText)
|
valueRow("版本", value: versionText)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +251,9 @@ struct SettingsView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(proxyOperationError)
|
Text(proxyOperationError)
|
||||||
}
|
}
|
||||||
|
.alert(item: $updateCheckResult) { result in
|
||||||
|
updateCheckAlert(for: result)
|
||||||
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
"重置证书?",
|
"重置证书?",
|
||||||
isPresented: $showCertificateResetConfirmation,
|
isPresented: $showCertificateResetConfirmation,
|
||||||
@@ -219,6 +278,71 @@ struct SettingsView: View {
|
|||||||
return "\(v) (\(b))"
|
return "\(v) (\(b))"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func checkForUpdates() {
|
||||||
|
guard !isCheckingForUpdates else { return }
|
||||||
|
isCheckingForUpdates = true
|
||||||
|
Task { @MainActor in
|
||||||
|
defer { isCheckingForUpdates = false }
|
||||||
|
guard let configuration = await AppRemoteConfigurationService.fetch() else {
|
||||||
|
updateCheckResult = .failed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
AppRemoteConfigurationStore.shared.apply(configuration)
|
||||||
|
let currentVersion = Bundle.main.object(
|
||||||
|
forInfoDictionaryKey: "CFBundleShortVersionString"
|
||||||
|
) as? String ?? AppRemoteConfiguration.fallback.latestVersion
|
||||||
|
guard let pendingPrompt = configuration.updatePrompt(currentVersion: currentVersion) else {
|
||||||
|
updateCheckResult = .current(
|
||||||
|
currentVersion: currentVersion,
|
||||||
|
latestVersion: configuration.latestVersion
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let releaseNotes = await AppRemoteConfigurationService.fetchReleaseNotes(
|
||||||
|
version: pendingPrompt.latestVersion
|
||||||
|
)
|
||||||
|
let prompt = configuration.updatePrompt(
|
||||||
|
currentVersion: currentVersion,
|
||||||
|
releaseNotes: releaseNotes
|
||||||
|
) ?? pendingPrompt
|
||||||
|
updateCheckResult = .available(prompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateCheckAlert(for result: UpdateCheckResult) -> Alert {
|
||||||
|
switch result {
|
||||||
|
case .current(let currentVersion, let latestVersion):
|
||||||
|
return Alert(
|
||||||
|
title: Text("已是最新版本"),
|
||||||
|
message: Text("当前版本 \(currentVersion),远程最新版本 \(latestVersion)。"),
|
||||||
|
dismissButton: .default(Text("知道了"))
|
||||||
|
)
|
||||||
|
case .available(let prompt):
|
||||||
|
let details = prompt.releaseNotes
|
||||||
|
?? "更新说明暂时无法加载,请前往最新 Release 页面查看。"
|
||||||
|
let message: String
|
||||||
|
if prompt.requirement == .required {
|
||||||
|
message = "当前版本 \(prompt.currentVersion) 已停止支持,请更新到 \(prompt.latestVersion) 后继续使用。\n\n\(details)"
|
||||||
|
} else {
|
||||||
|
message = "当前版本 \(prompt.currentVersion),最新版本 \(prompt.latestVersion)。\n\n\(details)"
|
||||||
|
}
|
||||||
|
return Alert(
|
||||||
|
title: Text(prompt.requirement == .required ? "需要更新" : "发现新版本"),
|
||||||
|
message: Text(message),
|
||||||
|
primaryButton: .default(Text("前往更新")) {
|
||||||
|
UIApplication.shared.open(AppRemoteConfigurationService.releasesURL)
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel(Text("稍后"))
|
||||||
|
)
|
||||||
|
case .failed:
|
||||||
|
return Alert(
|
||||||
|
title: Text("检查更新失败"),
|
||||||
|
message: Text("无法获取远程版本信息,请检查网络后重试。"),
|
||||||
|
dismissButton: .default(Text("知道了"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var proxyBinding: Binding<Bool> {
|
private var proxyBinding: Binding<Bool> {
|
||||||
Binding(get: { proxy.isRunning }, set: { on in
|
Binding(get: { proxy.isRunning }, set: { on in
|
||||||
Task {
|
Task {
|
||||||
@@ -248,6 +372,22 @@ struct SettingsView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var motionSimulationBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { motionSimulation.isEnabled },
|
||||||
|
set: { enabled in
|
||||||
|
proxy.applyMotionSimulation(enabled)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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("第三方代理配置") {
|
||||||
@@ -260,6 +400,14 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Toggle("使用国内镜像下载模块", isOn: Binding(
|
||||||
|
get: { moduleSource.useMirror },
|
||||||
|
set: { moduleSource.setUseMirror($0) }
|
||||||
|
))
|
||||||
|
Text("仅影响之后复制和重新导入的模块地址;已安装模块需要重新导入后切换来源。")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
if let verificationText = thirdPartyClient.selectedClient.verificationText {
|
if let verificationText = thirdPartyClient.selectedClient.verificationText {
|
||||||
HStack {
|
HStack {
|
||||||
Text("验证状态")
|
Text("验证状态")
|
||||||
@@ -277,6 +425,13 @@ struct SettingsView: View {
|
|||||||
Label(copiedClient == thirdPartyClient.selectedClient ? "已复制模块订阅地址" : "复制模块订阅地址", systemImage: "doc.on.doc")
|
Label(copiedClient == thirdPartyClient.selectedClient ? "已复制模块订阅地址" : "复制模块订阅地址", systemImage: "doc.on.doc")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
UIPasteboard.general.string = ThirdPartyProxyManager.interceptionHostnamesText
|
||||||
|
copiedMITMHostnames = true
|
||||||
|
} label: {
|
||||||
|
Label(copiedMITMHostnames ? "已复制解密域名" : "复制解密域名", systemImage: "doc.on.doc")
|
||||||
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
openThirdPartyClient(thirdPartyClient.selectedClient)
|
openThirdPartyClient(thirdPartyClient.selectedClient)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -298,7 +453,7 @@ struct SettingsView: View {
|
|||||||
.font(.footnote).foregroundStyle(.secondary)
|
.font(.footnote).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("复制模块订阅地址后,在对应代理客户端中添加模块/重写订阅,并启用 MITM。第三方客户端保存坐标后,即使关闭本 App,坐标仍由代理客户端持久化并继续生效。")
|
Text("复制模块订阅地址后,在对应代理客户端中添加模块/重写订阅,并为复制的全部域名(含 gsp-ssl.ls.apple.com、bluedot.is.autonavi.com)启用 MITM。第三方客户端保存坐标后,即使关闭本 App,坐标仍由代理客户端持久化并继续生效。")
|
||||||
.font(.footnote).foregroundStyle(.secondary)
|
.font(.footnote).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -336,7 +491,7 @@ struct SettingsView: View {
|
|||||||
return """
|
return """
|
||||||
App 在设备本地运行一个代理服务器(127.0.0.1:8888)。
|
App 在设备本地运行一个代理服务器(127.0.0.1:8888)。
|
||||||
|
|
||||||
通过 WiFi 手动代理配置,让系统的定位请求(gs-loc.apple.com/clls/wloc)经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。
|
通过 WiFi 手动代理配置,让系统发往 Apple 定位域名(gs-loc.apple.com、gsp-ssl.ls.apple.com、bluedot.is.autonavi.com 等)的定位请求经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +568,7 @@ struct SettingsView: View {
|
|||||||
"请求动作": "WLOC query",
|
"请求动作": "WLOC query",
|
||||||
"连接状态": String(describing: thirdPartyProxy.connectionState),
|
"连接状态": String(describing: thirdPartyProxy.connectionState),
|
||||||
"耗时毫秒": String(Int(Date().timeIntervalSince(startedAt) * 1_000)),
|
"耗时毫秒": String(Int(Date().timeIntervalSince(startedAt) * 1_000)),
|
||||||
"处理建议": "检查模块、MITM、证书和代理/VPN连接"
|
"处理建议": ThirdPartyProxyError.recoverySuggestion(for: error)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
openThirdPartySetup(for: error)
|
openThirdPartySetup(for: error)
|
||||||
|
|||||||
+15
-1
@@ -55,6 +55,11 @@ func wloccore_validateca(certData, keyData *C.char) C.int {
|
|||||||
|
|
||||||
//export wloccore_startproxy
|
//export wloccore_startproxy
|
||||||
func wloccore_startproxy(certData, keyData *C.char, lat, lon C.double, enabled C.int, accuracy C.int) C.uintptr_t {
|
func wloccore_startproxy(certData, keyData *C.char, lat, lon C.double, enabled C.int, accuracy C.int) C.uintptr_t {
|
||||||
|
return wloccore_startproxyv2(certData, keyData, lat, lon, enabled, accuracy, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
//export wloccore_startproxyv2
|
||||||
|
func wloccore_startproxyv2(certData, keyData *C.char, lat, lon C.double, enabled C.int, accuracy C.int, motionEnabled C.int) C.uintptr_t {
|
||||||
if certData == nil || keyData == nil {
|
if certData == nil || keyData == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -65,6 +70,7 @@ func wloccore_startproxy(certData, keyData *C.char, lat, lon C.double, enabled C
|
|||||||
float64(lon),
|
float64(lon),
|
||||||
enabled != 0,
|
enabled != 0,
|
||||||
int(accuracy),
|
int(accuracy),
|
||||||
|
motionEnabled != 0,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logEvent("startproxy failed: " + err.Error())
|
logEvent("startproxy failed: " + err.Error())
|
||||||
@@ -106,13 +112,21 @@ func proxyForHandle(h C.uintptr_t) (server *http.Server, handle cgo.Handle, ok b
|
|||||||
|
|
||||||
//export wloccore_setcoords
|
//export wloccore_setcoords
|
||||||
func wloccore_setcoords(lat, lon C.double, enabled C.int, accuracy C.int) {
|
func wloccore_setcoords(lat, lon C.double, enabled C.int, accuracy C.int) {
|
||||||
|
wloccore_setpatchconfig(lat, lon, enabled, accuracy, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
//export wloccore_setpatchconfig
|
||||||
|
func wloccore_setpatchconfig(lat, lon C.double, enabled C.int, accuracy C.int, motionEnabled C.int) {
|
||||||
stateMu.Lock()
|
stateMu.Lock()
|
||||||
currentLat = float64(lat)
|
currentLat = float64(lat)
|
||||||
currentLon = float64(lon)
|
currentLon = float64(lon)
|
||||||
currentEnabled = enabled != 0
|
currentEnabled = enabled != 0
|
||||||
currentAccuracy = int(accuracy)
|
currentAccuracy = int(accuracy)
|
||||||
|
currentMotionSimulationEnabled = motionEnabled != 0
|
||||||
stateMu.Unlock()
|
stateMu.Unlock()
|
||||||
logEvent("setcoords enabled=" + strconv.FormatBool(enabled != 0) + " accuracy=" + strconv.Itoa(int(accuracy)))
|
logEvent("setpatchconfig enabled=" + strconv.FormatBool(enabled != 0) +
|
||||||
|
" accuracy=" + strconv.Itoa(int(accuracy)) +
|
||||||
|
" motion=" + strconv.FormatBool(motionEnabled != 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
//export wloccore_getcoords
|
//export wloccore_getcoords
|
||||||
|
|||||||
+24
-13
@@ -22,13 +22,14 @@ import (
|
|||||||
const proxyPort = 8888
|
const proxyPort = 8888
|
||||||
|
|
||||||
var (
|
var (
|
||||||
stateMu sync.Mutex
|
stateMu sync.Mutex
|
||||||
currentLat float64
|
currentLat float64
|
||||||
currentLon float64
|
currentLon float64
|
||||||
currentEnabled bool
|
currentEnabled bool
|
||||||
currentAccuracy int
|
currentAccuracy int
|
||||||
globalCACert *tls.Certificate
|
currentMotionSimulationEnabled bool
|
||||||
verifyToken string
|
globalCACert *tls.Certificate
|
||||||
|
verifyToken string
|
||||||
|
|
||||||
logMu sync.Mutex
|
logMu sync.Mutex
|
||||||
logEntries []string
|
logEntries []string
|
||||||
@@ -63,7 +64,13 @@ func isWlocHost(host string) bool {
|
|||||||
host = h
|
host = h
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return host == "gs-loc.apple.com" || host == "gs-loc-cn.apple.com"
|
switch host {
|
||||||
|
case "gs-loc.apple.com", "gs-loc-cn.apple.com",
|
||||||
|
"gsp-ssl.ls.apple.com", "bluedot.is.autonavi.com",
|
||||||
|
"bluedot.is.autonavi.com.gds.alibabadns.com":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer {
|
func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer {
|
||||||
@@ -94,10 +101,10 @@ func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer {
|
|||||||
}
|
}
|
||||||
if r.URL.Path == "/coords" {
|
if r.URL.Path == "/coords" {
|
||||||
stateMu.Lock()
|
stateMu.Lock()
|
||||||
enabled, lat, lon, accuracy := currentEnabled, currentLat, currentLon, currentAccuracy
|
enabled, lat, lon, accuracy, motionEnabled := currentEnabled, currentLat, currentLon, currentAccuracy, currentMotionSimulationEnabled
|
||||||
stateMu.Unlock()
|
stateMu.Unlock()
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Write([]byte(fmt.Sprintf(`{"enabled":%t,"lat":%.6f,"lon":%.6f,"accuracy":%d}`, enabled, lat, lon, accuracy)))
|
w.Write([]byte(fmt.Sprintf(`{"enabled":%t,"lat":%.6f,"lon":%.6f,"accuracy":%d,"motionSimulationEnabled":%t}`, enabled, lat, lon, accuracy, motionEnabled)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.URL.Path == "/proxy.mobileconfig" || r.URL.Path == "/proxy.mobileconfig/" {
|
if r.URL.Path == "/proxy.mobileconfig" || r.URL.Path == "/proxy.mobileconfig/" {
|
||||||
@@ -221,7 +228,7 @@ func patchWlocResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Respons
|
|||||||
}
|
}
|
||||||
|
|
||||||
stateMu.Lock()
|
stateMu.Lock()
|
||||||
enabled, lat, lon, accuracy := currentEnabled, currentLat, currentLon, currentAccuracy
|
enabled, lat, lon, accuracy, motionEnabled := currentEnabled, currentLat, currentLon, currentAccuracy, currentMotionSimulationEnabled
|
||||||
stateMu.Unlock()
|
stateMu.Unlock()
|
||||||
|
|
||||||
const maxPatchBodyBytes int64 = 1 << 20
|
const maxPatchBodyBytes int64 = 1 << 20
|
||||||
@@ -249,7 +256,10 @@ func patchWlocResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Respons
|
|||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
patched, stats, err := patchResponseBody(body, wlocCoords{Latitude: lat, Longitude: lon, Accuracy: accuracy})
|
patched, stats, err := patchResponseBody(body, wlocCoords{
|
||||||
|
Latitude: lat, Longitude: lon, Accuracy: accuracy,
|
||||||
|
MotionSimulationEnabled: motionEnabled,
|
||||||
|
})
|
||||||
if err != nil || bytes.Equal(patched, body) {
|
if err != nil || bytes.Equal(patched, body) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logEvent("wloc patch skipped: " + err.Error())
|
logEvent("wloc patch skipped: " + err.Error())
|
||||||
@@ -326,7 +336,7 @@ func randomUint32() uint32 {
|
|||||||
return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
|
return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
|
||||||
}
|
}
|
||||||
|
|
||||||
func startProxy(certPEM, keyPEM []byte, lat, lon float64, enabled bool, accuracy int) (*http.Server, error) {
|
func startProxy(certPEM, keyPEM []byte, lat, lon float64, enabled bool, accuracy int, motionEnabled bool) (*http.Server, error) {
|
||||||
cert, err := parseCA(certPEM, keyPEM)
|
cert, err := parseCA(certPEM, keyPEM)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -335,6 +345,7 @@ func startProxy(certPEM, keyPEM []byte, lat, lon float64, enabled bool, accuracy
|
|||||||
stateMu.Lock()
|
stateMu.Lock()
|
||||||
globalCACert = cert
|
globalCACert = cert
|
||||||
currentLat, currentLon, currentEnabled, currentAccuracy = lat, lon, enabled, accuracy
|
currentLat, currentLon, currentEnabled, currentAccuracy = lat, lon, enabled, accuracy
|
||||||
|
currentMotionSimulationEnabled = motionEnabled
|
||||||
stateMu.Unlock()
|
stateMu.Unlock()
|
||||||
|
|
||||||
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", proxyPort))
|
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", proxyPort))
|
||||||
|
|||||||
+34
-3
@@ -20,11 +20,17 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type wlocCoords struct {
|
type wlocCoords struct {
|
||||||
Latitude float64
|
Latitude float64
|
||||||
Longitude float64
|
Longitude float64
|
||||||
Accuracy int
|
Accuracy int
|
||||||
|
MotionSimulationEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
motionActivityType = 63
|
||||||
|
motionActivityConfidence = 467
|
||||||
|
)
|
||||||
|
|
||||||
type patchStats struct {
|
type patchStats struct {
|
||||||
WiFi int
|
WiFi int
|
||||||
Cell int
|
Cell int
|
||||||
@@ -179,6 +185,7 @@ func patchLocation(loc []byte, c wlocCoords) ([]byte, bool, error) {
|
|||||||
lon := int64(math.Round(c.Longitude * 1e8))
|
lon := int64(math.Round(c.Longitude * 1e8))
|
||||||
var out []byte
|
var out []byte
|
||||||
changed := false
|
changed := false
|
||||||
|
hasMotionType, hasMotionConfidence := false, false
|
||||||
for _, f := range fields {
|
for _, f := range fields {
|
||||||
switch {
|
switch {
|
||||||
case f.num == 1 && f.wireType == wireVarint:
|
case f.num == 1 && f.wireType == wireVarint:
|
||||||
@@ -199,10 +206,34 @@ func patchLocation(loc []byte, c wlocCoords) ([]byte, bool, error) {
|
|||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
out = append(out, raw...)
|
out = append(out, raw...)
|
||||||
|
case c.MotionSimulationEnabled && f.num == 11 && f.wireType == wireVarint:
|
||||||
|
hasMotionType = true
|
||||||
|
raw := append(writeTag(11, wireVarint), writeVarint(motionActivityType)...)
|
||||||
|
if !bytes.Equal(raw, f.raw) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
out = append(out, raw...)
|
||||||
|
case c.MotionSimulationEnabled && f.num == 12 && f.wireType == wireVarint:
|
||||||
|
hasMotionConfidence = true
|
||||||
|
raw := append(writeTag(12, wireVarint), writeVarint(motionActivityConfidence)...)
|
||||||
|
if !bytes.Equal(raw, f.raw) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
out = append(out, raw...)
|
||||||
default:
|
default:
|
||||||
out = append(out, f.raw...)
|
out = append(out, f.raw...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if c.MotionSimulationEnabled && !hasMotionType {
|
||||||
|
out = append(out, writeTag(11, wireVarint)...)
|
||||||
|
out = append(out, writeVarint(motionActivityType)...)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if c.MotionSimulationEnabled && !hasMotionConfidence {
|
||||||
|
out = append(out, writeTag(12, wireVarint)...)
|
||||||
|
out = append(out, writeVarint(motionActivityConfidence)...)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
return out, changed, nil
|
return out, changed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ func testLocation(lat, lon int64, accuracy uint64) []byte {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testLocationWithMotion(lat, lon int64, accuracy, motionType, motionConfidence uint64) []byte {
|
||||||
|
out := testLocation(lat, lon, accuracy)
|
||||||
|
out = append(out, writeTag(11, wireVarint)...)
|
||||||
|
out = append(out, writeVarint(motionType)...)
|
||||||
|
out = append(out, writeTag(12, wireVarint)...)
|
||||||
|
out = append(out, writeVarint(motionConfidence)...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func testWifiDevice(loc []byte) []byte {
|
func testWifiDevice(loc []byte) []byte {
|
||||||
mac := []byte("aa:bb:cc:dd:ee:ff")
|
mac := []byte("aa:bb:cc:dd:ee:ff")
|
||||||
var out []byte
|
var out []byte
|
||||||
@@ -113,6 +122,66 @@ func TestPatchCellLocation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMotionSimulationDisabledPreservesFields(t *testing.T) {
|
||||||
|
original := testLocationWithMotion(100, 200, 25, 7, 88)
|
||||||
|
patched, changed, err := patchLocation(original, wlocCoords{
|
||||||
|
Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("coordinates were not patched")
|
||||||
|
}
|
||||||
|
fields, err := parseFields(patched)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, field := range fields {
|
||||||
|
if (field.num == 11 || field.num == 12) && !bytes.Contains(original, field.raw) {
|
||||||
|
t.Fatalf("motion field %d changed while disabled", field.num)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMotionSimulationEnabledReplacesFields(t *testing.T) {
|
||||||
|
original := testLocationWithMotion(100, 200, 25, 7, 88)
|
||||||
|
patched, _, err := patchLocation(original, wlocCoords{
|
||||||
|
Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50,
|
||||||
|
MotionSimulationEnabled: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(patched, append(writeTag(11, wireVarint), writeVarint(motionActivityType)...)) {
|
||||||
|
t.Fatal("motion activity type was not replaced")
|
||||||
|
}
|
||||||
|
if !bytes.Contains(patched, append(writeTag(12, wireVarint), writeVarint(motionActivityConfidence)...)) {
|
||||||
|
t.Fatal("motion activity confidence was not replaced")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMotionSimulationEnabledAddsMissingFields(t *testing.T) {
|
||||||
|
patched, _, err := patchLocation(testLocation(100, 200, 25), wlocCoords{
|
||||||
|
Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50,
|
||||||
|
MotionSimulationEnabled: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fields, err := parseFields(patched)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
counts := map[int]int{}
|
||||||
|
for _, field := range fields {
|
||||||
|
counts[field.num]++
|
||||||
|
}
|
||||||
|
if counts[11] != 1 || counts[12] != 1 {
|
||||||
|
t.Fatalf("expected one inserted motion field each, got %+v", counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPatchARPCFramePreservesEnvelopeAndSuffix(t *testing.T) {
|
func TestPatchARPCFramePreservesEnvelopeAndSuffix(t *testing.T) {
|
||||||
payload := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
|
payload := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
|
||||||
suffix := []byte{0xde, 0xad, 0xbe, 0xef}
|
suffix := []byte{0xde, 0xad, 0xbe, 0xef}
|
||||||
|
|||||||
+4
-1
@@ -9,10 +9,13 @@ An open-source project for **iOS location-service research, software development
|
|||||||
The project uses either an on-device proxy or a third-party proxy client to simulate selected Apple location-service
|
The project uses either an on-device proxy or a third-party proxy client to simulate selected Apple location-service
|
||||||
responses in a controlled test environment.
|
responses in a controlled test environment.
|
||||||
|
|
||||||
|
> ⚠️ **Starting with iOS 27 beta 6, the system blocks MITM interception of `gs-loc.apple.com`.** This project is
|
||||||
|
> temporarily unusable on that version and later betas until a workaround is found.
|
||||||
|
|
||||||
[](project.yml)
|
[](project.yml)
|
||||||
[](project.yml)
|
[](project.yml)
|
||||||
[](Core/go.mod)
|
[](Core/go.mod)
|
||||||
[](docs/CHANGELOG.md)
|
[](docs/CHANGELOG.md)
|
||||||
|
|
||||||
[Features](#feature-overview) ·
|
[Features](#feature-overview) ·
|
||||||
[How It Works](#how-it-works) ·
|
[How It Works](#how-it-works) ·
|
||||||
|
|||||||
@@ -9,10 +9,12 @@
|
|||||||
项目通过本机代理或第三方代理客户端,对 Apple 定位服务的指定响应进行测试环境模拟,帮助开发者验证应用在
|
项目通过本机代理或第三方代理客户端,对 Apple 定位服务的指定响应进行测试环境模拟,帮助开发者验证应用在
|
||||||
不同地理位置和定位场景下的行为。
|
不同地理位置和定位场景下的行为。
|
||||||
|
|
||||||
|
> ⚠️ **iOS 27 beta 6 起,系统已禁止对 `gs-loc.apple.com` 进行 MITM 拦截。** 目前该版本及之后的 beta 版本暂时无法使用本项目,等待后续适配方案。
|
||||||
|
|
||||||
[](project.yml)
|
[](project.yml)
|
||||||
[](project.yml)
|
[](project.yml)
|
||||||
[](Core/go.mod)
|
[](Core/go.mod)
|
||||||
[](docs/CHANGELOG.md)
|
[](docs/CHANGELOG.md)
|
||||||
|
|
||||||
[功能概览](#功能概览) ·
|
[功能概览](#功能概览) ·
|
||||||
[工作原理](#工作原理) ·
|
[工作原理](#工作原理) ·
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 67 KiB |
@@ -1,11 +0,0 @@
|
|||||||
#!name=Apple WLOC 定位修改
|
|
||||||
#!desc=修改 Apple 网络定位返回坐标 | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f | 选点页面: https://wloc-pages.pages.dev/
|
|
||||||
#!author=Yu9191 Rewrite
|
|
||||||
#!homepage=https://github.com/Yu9191/wloc
|
|
||||||
|
|
||||||
[rewrite_local]
|
|
||||||
^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc url script-response-body https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc.js
|
|
||||||
^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save url script-echo-response https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc-settings.js
|
|
||||||
|
|
||||||
[mitm]
|
|
||||||
hostname = gs-loc.apple.com, gs-loc-cn.apple.com
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
#!name=Apple WLOC 定位修改
|
|
||||||
#!desc=修改 Apple 网络定位返回坐标 | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f
|
|
||||||
#!icon=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/wloc.jpg
|
|
||||||
#!author=Yu9191 Rewrite
|
|
||||||
#!homepage=https://github.com/Yu9191/wloc
|
|
||||||
#!openUrl=https://wloc-pages.pages.dev/
|
|
||||||
|
|
||||||
[Argument]
|
|
||||||
longitude = input, "113.94114", tag=经度(在线选点优先)
|
|
||||||
latitude = input, "22.544577", tag=纬度(在线选点优先)
|
|
||||||
accuracy = input, "25", tag=精度(米)
|
|
||||||
logLevel = select, "info", "off", "error", "warn", "debug", "all", tag=日志级别
|
|
||||||
|
|
||||||
[Script]
|
|
||||||
http-response ^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc.js, requires-body=true, binary-body-mode=true, timeout=30, tag=Apple WLOC, argument=[{longitude},{latitude},{accuracy},{logLevel}]
|
|
||||||
http-request ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc-settings.js, timeout=10, tag=WLOC Settings
|
|
||||||
|
|
||||||
[MITM]
|
|
||||||
hostname = gs-loc.apple.com, gs-loc-cn.apple.com
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
#!name=Apple WLOC 定位修改
|
|
||||||
#!desc=修改 Apple 网络定位返回坐标 (Shadowrocket 小火箭) | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f | 选点页面: https://wloc-pages.pages.dev/
|
|
||||||
#!author=Yu9191 Rewrite
|
|
||||||
#!homepage=https://github.com/Yu9191/wloc
|
|
||||||
#!icon=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/wloc.jpg
|
|
||||||
#!category=Tools
|
|
||||||
|
|
||||||
[Script]
|
|
||||||
Apple WLOC = type=http-response,pattern=^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc,requires-body=1,binary-body-mode=1,max-size=0,timeout=30,script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc.js,argument=longitude=113.94114&latitude=22.544577&accuracy=25&logLevel=info
|
|
||||||
WLOC Settings = type=http-request,pattern=^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save,requires-body=0,max-size=0,timeout=10,script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc-settings.js
|
|
||||||
|
|
||||||
[MITM]
|
|
||||||
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
#!name=Apple WLOC 定位修改
|
|
||||||
#!desc=修改 Apple 网络定位返回坐标 | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f | 选点页面: https://wloc-pages.pages.dev/
|
|
||||||
#!author=Yu9191 Rewrite
|
|
||||||
#!homepage=https://github.com/Yu9191/wloc
|
|
||||||
#!category=Tools
|
|
||||||
#!arguments=经度:113.94114, 纬度:22.544577, 精度:25, 日志级别:info
|
|
||||||
#!arguments-desc=经度/纬度: 默认坐标(在线选点储存后优先)\n精度: GPS精度(米)\n日志级别: off/error/warn/info/debug/all\n\n使用方法: 打开选点页面 -> 选位置 -> 储存到设备
|
|
||||||
|
|
||||||
[Script]
|
|
||||||
Apple WLOC = type=http-response, pattern="^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc", requires-body=1, binary-body-mode=1, max-size=0, timeout=30, script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc.js, argument=longitude={{{经度}}}&latitude={{{纬度}}}&accuracy={{{精度}}}&logLevel={{{日志级别}}}
|
|
||||||
WLOC Settings = type=http-request, pattern="^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save", requires-body=0, max-size=0, timeout=10, script-path=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc-settings.js
|
|
||||||
|
|
||||||
[MITM]
|
|
||||||
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
name: Apple WLOC 定位修改
|
|
||||||
desc: "修改 Apple 网络定位返回坐标 | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f | 选点页面: https://wloc-pages.pages.dev/"
|
|
||||||
author: Yu9191 Rewrite
|
|
||||||
homepage: https://github.com/Yu9191/wloc
|
|
||||||
icon: https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/wloc.jpg
|
|
||||||
category: Tools
|
|
||||||
|
|
||||||
http:
|
|
||||||
mitm:
|
|
||||||
- "gs-loc.apple.com"
|
|
||||||
- "gs-loc-cn.apple.com"
|
|
||||||
script:
|
|
||||||
- match: ^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc
|
|
||||||
name: WLOC.Location
|
|
||||||
type: response
|
|
||||||
require-body: true
|
|
||||||
binary-mode: true
|
|
||||||
max-size: 0
|
|
||||||
timeout: 30
|
|
||||||
argument: longitude=113.94114&latitude=22.544577&accuracy=25&logLevel=info
|
|
||||||
- match: ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save
|
|
||||||
name: WLOC.Settings
|
|
||||||
type: request
|
|
||||||
require-body: false
|
|
||||||
timeout: 10
|
|
||||||
|
|
||||||
script-providers:
|
|
||||||
WLOC.Location:
|
|
||||||
url: https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc.js
|
|
||||||
interval: 86400
|
|
||||||
WLOC.Settings:
|
|
||||||
url: https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc-settings.js
|
|
||||||
interval: 86400
|
|
||||||
@@ -44,6 +44,80 @@ enum WlocSettingsStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class MotionSimulationStore: ObservableObject {
|
||||||
|
static let shared = MotionSimulationStore()
|
||||||
|
|
||||||
|
private enum Key {
|
||||||
|
static let enabled = "motionSimulation.enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published private(set) var isEnabled: Bool
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = AppGroup.defaults) {
|
||||||
|
self.defaults = defaults
|
||||||
|
isEnabled = defaults.bool(forKey: Key.enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setEnabled(_ enabled: Bool) {
|
||||||
|
isEnabled = enabled
|
||||||
|
defaults.set(enabled, forKey: Key.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
final class ThirdPartyModuleSourceStore: ObservableObject {
|
||||||
|
static let shared = ThirdPartyModuleSourceStore()
|
||||||
|
|
||||||
|
private enum Key {
|
||||||
|
static let useMirror = "thirdPartyModule.useMirror"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published private(set) var useMirror: Bool
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = AppGroup.defaults) {
|
||||||
|
self.defaults = defaults
|
||||||
|
useMirror = defaults.object(forKey: Key.useMirror) as? Bool ?? true
|
||||||
|
}
|
||||||
|
|
||||||
|
func setUseMirror(_ enabled: Bool) {
|
||||||
|
useMirror = enabled
|
||||||
|
defaults.set(enabled, forKey: Key.useMirror)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum VirtualLocationTipKind: Equatable {
|
enum VirtualLocationTipKind: Equatable {
|
||||||
case activation
|
case activation
|
||||||
case deactivation
|
case deactivation
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ struct AppRemoteConfiguration: Decodable, Equatable {
|
|||||||
let communityPromptClients: [String]
|
let communityPromptClients: [String]
|
||||||
|
|
||||||
static let fallback = AppRemoteConfiguration(
|
static let fallback = AppRemoteConfiguration(
|
||||||
latestVersion: "1.0.4",
|
latestVersion: "1.0.5",
|
||||||
minimumSupportedVersion: "1.0.0",
|
minimumSupportedVersion: "1.0.0",
|
||||||
communityPromptClients: [
|
communityPromptClients: [
|
||||||
ThirdPartyProxyClient.surge.rawValue,
|
ThirdPartyProxyClient.surge.rawValue,
|
||||||
@@ -107,87 +107,99 @@ final class AppRemoteConfigurationStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum AppRemoteConfigurationService {
|
enum AppRemoteConfigurationService {
|
||||||
static let configurationURL = URL(
|
static let configurationURLs = [
|
||||||
string: "https://raw.githubusercontent.com/xweiba/location-spoofer/main/version.txt"
|
URL(
|
||||||
)!
|
string: "https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/version.txt"
|
||||||
|
)!,
|
||||||
|
URL(
|
||||||
|
string: "https://raw.githubusercontent.com/xweiba/location-spoofer/main/version.txt"
|
||||||
|
)!
|
||||||
|
]
|
||||||
static let releasesURL = URL(
|
static let releasesURL = URL(
|
||||||
string: "https://github.com/xweiba/location-spoofer/releases/latest"
|
string: "https://github.com/xweiba/location-spoofer/releases/latest"
|
||||||
)!
|
)!
|
||||||
|
|
||||||
static func fetch() async -> AppRemoteConfiguration? {
|
static func fetch() async -> AppRemoteConfiguration? {
|
||||||
let sessionConfiguration = URLSessionConfiguration.ephemeral
|
let session = makeSession()
|
||||||
sessionConfiguration.timeoutIntervalForRequest = 1.5
|
|
||||||
sessionConfiguration.timeoutIntervalForResource = 2
|
|
||||||
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
|
||||||
sessionConfiguration.waitsForConnectivity = false
|
|
||||||
let session = URLSession(configuration: sessionConfiguration)
|
|
||||||
defer { session.finishTasksAndInvalidate() }
|
defer { session.finishTasksAndInvalidate() }
|
||||||
|
|
||||||
var request = URLRequest(url: configurationURL)
|
for url in configurationURLs {
|
||||||
request.timeoutInterval = 1.5
|
var request = URLRequest(url: url)
|
||||||
|
request.timeoutInterval = 1.5
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (data, response) = try await session.data(for: request)
|
let (data, response) = try await session.data(for: request)
|
||||||
guard let httpResponse = response as? HTTPURLResponse,
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
httpResponse.statusCode == 200 else {
|
httpResponse.statusCode == 200 else {
|
||||||
RuntimeLogger.warning("APP", "Update", "版本配置请求返回非 200,继续使用内置配置")
|
continue
|
||||||
return nil
|
}
|
||||||
|
let configuration = try AppRemoteConfiguration.decode(data)
|
||||||
|
RuntimeLogger.info("APP", "Update", "远程版本配置加载成功", details: [
|
||||||
|
"来源": url.host ?? "未知",
|
||||||
|
"最新版本": configuration.latestVersion,
|
||||||
|
"最低版本": configuration.minimumSupportedVersion,
|
||||||
|
"社区征集客户端数": String(configuration.communityPromptClients.count)
|
||||||
|
])
|
||||||
|
return configuration
|
||||||
|
} catch {
|
||||||
|
RuntimeLogger.info("APP", "Update", "版本配置源不可用,尝试下一地址", details: [
|
||||||
|
"来源": url.host ?? "未知",
|
||||||
|
"错误": error.localizedDescription
|
||||||
|
])
|
||||||
}
|
}
|
||||||
let configuration = try AppRemoteConfiguration.decode(data)
|
|
||||||
RuntimeLogger.info("APP", "Update", "远程版本配置加载成功", details: [
|
|
||||||
"最新版本": configuration.latestVersion,
|
|
||||||
"最低版本": configuration.minimumSupportedVersion,
|
|
||||||
"社区征集客户端数": String(configuration.communityPromptClients.count)
|
|
||||||
])
|
|
||||||
return configuration
|
|
||||||
} catch {
|
|
||||||
RuntimeLogger.warning("APP", "Update", "版本配置加载失败,继续使用内置配置", details: [
|
|
||||||
"错误": error.localizedDescription
|
|
||||||
])
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RuntimeLogger.warning("APP", "Update", "版本配置加载失败,继续使用内置配置")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
static func fetchReleaseNotes(version: String) async -> String? {
|
static func fetchReleaseNotes(version: String) async -> String? {
|
||||||
guard let url = URL(
|
let session = makeSession()
|
||||||
string: "https://raw.githubusercontent.com/xweiba/location-spoofer/main/docs/releases/v\(version).md"
|
defer { session.finishTasksAndInvalidate() }
|
||||||
) else {
|
|
||||||
return nil
|
for url in releaseNotesURLs(version: version) {
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.timeoutInterval = 1.5
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
|
httpResponse.statusCode == 200,
|
||||||
|
let markdown = String(data: data, encoding: .utf8),
|
||||||
|
let summary = releaseNotesSummary(markdown) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
} catch {
|
||||||
|
RuntimeLogger.info("APP", "Update", "版本说明源不可用,尝试下一地址", details: [
|
||||||
|
"来源": url.host ?? "未知",
|
||||||
|
"版本": version,
|
||||||
|
"错误": error.localizedDescription
|
||||||
|
])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RuntimeLogger.info("APP", "Update", "版本说明加载失败,将使用最新 Release 页面", details: [
|
||||||
|
"版本": version
|
||||||
|
])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func releaseNotesURLs(version: String) -> [URL] {
|
||||||
|
let path = "https://raw.githubusercontent.com/xweiba/location-spoofer/main/docs/releases/v\(version).md"
|
||||||
|
return [
|
||||||
|
URL(string: "https://gh-proxy.org/\(path)")!,
|
||||||
|
URL(string: path)!
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeSession() -> URLSession {
|
||||||
let sessionConfiguration = URLSessionConfiguration.ephemeral
|
let sessionConfiguration = URLSessionConfiguration.ephemeral
|
||||||
sessionConfiguration.timeoutIntervalForRequest = 1.5
|
sessionConfiguration.timeoutIntervalForRequest = 1.5
|
||||||
sessionConfiguration.timeoutIntervalForResource = 2
|
sessionConfiguration.timeoutIntervalForResource = 2
|
||||||
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||||
sessionConfiguration.waitsForConnectivity = false
|
sessionConfiguration.waitsForConnectivity = false
|
||||||
let session = URLSession(configuration: sessionConfiguration)
|
return URLSession(configuration: sessionConfiguration)
|
||||||
defer { session.finishTasksAndInvalidate() }
|
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.timeoutInterval = 1.5
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (data, response) = try await session.data(for: request)
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse,
|
|
||||||
httpResponse.statusCode == 200 else {
|
|
||||||
RuntimeLogger.info("APP", "Update", "版本说明不存在,将使用最新 Release 页面", details: [
|
|
||||||
"版本": version
|
|
||||||
])
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
guard let markdown = String(data: data, encoding: .utf8) else {
|
|
||||||
RuntimeLogger.info("APP", "Update", "版本说明编码无效,将使用最新 Release 页面", details: [
|
|
||||||
"版本": version
|
|
||||||
])
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return releaseNotesSummary(markdown)
|
|
||||||
} catch {
|
|
||||||
RuntimeLogger.info("APP", "Update", "版本说明加载失败,将使用最新 Release 页面", details: [
|
|
||||||
"版本": version,
|
|
||||||
"错误": error.localizedDescription
|
|
||||||
])
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func releaseNotesSummary(_ markdown: String) -> String? {
|
private static func releaseNotesSummary(_ markdown: String) -> String? {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ struct ThirdPartyProxySettingsResponse: Decodable, Equatable {
|
|||||||
let latitude: Double?
|
let latitude: Double?
|
||||||
let accuracy: Int?
|
let accuracy: Int?
|
||||||
let error: String?
|
let error: String?
|
||||||
|
let motionSimulationEnabled: Bool?
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ThirdPartyProxyConnectionState: Equatable {
|
enum ThirdPartyProxyConnectionState: Equatable {
|
||||||
@@ -35,6 +36,15 @@ enum ThirdPartyProxyError: LocalizedError, Equatable {
|
|||||||
return "第三方代理请求失败:\(message)"
|
return "第三方代理请求失败:\(message)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var recoverySuggestion: String {
|
||||||
|
return "检查模块、MITM、证书和代理/VPN连接"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func recoverySuggestion(for error: Error) -> String {
|
||||||
|
(error as? Self)?.recoverySuggestion
|
||||||
|
?? "检查模块、MITM、证书和代理/VPN连接"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol ThirdPartyProxyRequesting {
|
protocol ThirdPartyProxyRequesting {
|
||||||
@@ -46,7 +56,14 @@ extension URLSession: ThirdPartyProxyRequesting {}
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class ThirdPartyProxyManager: ObservableObject {
|
final class ThirdPartyProxyManager: ObservableObject {
|
||||||
static let shared = ThirdPartyProxyManager()
|
static let shared = ThirdPartyProxyManager()
|
||||||
static let interceptionHostname = "gs-loc.apple.com"
|
static let interceptionHostnames = [
|
||||||
|
"gs-loc.apple.com",
|
||||||
|
"gs-loc-cn.apple.com",
|
||||||
|
"gsp-ssl.ls.apple.com",
|
||||||
|
"bluedot.is.autonavi.com",
|
||||||
|
"bluedot.is.autonavi.com.gds.alibabadns.com"
|
||||||
|
]
|
||||||
|
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")!
|
||||||
|
|
||||||
@Published private(set) var connectionState: ThirdPartyProxyConnectionState = .unknown
|
@Published private(set) var connectionState: ThirdPartyProxyConnectionState = .unknown
|
||||||
@@ -69,18 +86,13 @@ final class ThirdPartyProxyManager: ObservableObject {
|
|||||||
|
|
||||||
func query() async throws -> ThirdPartyProxySettingsResponse {
|
func query() async throws -> ThirdPartyProxySettingsResponse {
|
||||||
let response = try await perform(action: .query)
|
let response = try await perform(action: .query)
|
||||||
if response.success,
|
let active = try validatedQueryState(response)
|
||||||
response.latitude != nil,
|
if active {
|
||||||
response.longitude != nil {
|
|
||||||
activeSettings = response
|
activeSettings = response
|
||||||
connectionState = .connected(active: true)
|
connectionState = .connected(active: true)
|
||||||
} else if response.error?.contains("无已保存") == true {
|
} else {
|
||||||
activeSettings = nil
|
activeSettings = nil
|
||||||
connectionState = .connected(active: false)
|
connectionState = .connected(active: false)
|
||||||
} else {
|
|
||||||
let error = ThirdPartyProxyError.rejected(response.error ?? "第三方代理查询失败")
|
|
||||||
connectionState = .failed(error.localizedDescription)
|
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
@@ -90,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 ?? "第三方代理拒绝保存坐标")
|
||||||
@@ -121,9 +134,21 @@ final class ThirdPartyProxyManager: ObservableObject {
|
|||||||
RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方代理坐标已清除")
|
RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方代理坐标已清除")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func validatedQueryState(_ response: ThirdPartyProxySettingsResponse) throws -> Bool {
|
||||||
|
if response.success,
|
||||||
|
response.latitude != nil,
|
||||||
|
response.longitude != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if response.error?.contains("无已保存") == true {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
throw ThirdPartyProxyError.rejected(response.error ?? "第三方代理查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,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 }
|
||||||
@@ -211,21 +237,23 @@ enum ThirdPartyProxyClient: String, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
var subscriptionURL: URL {
|
var subscriptionURL: URL {
|
||||||
let url: String
|
// Third-party modules are served directly from the upstream Yu9191/wloc
|
||||||
|
// repository (mirror via gh-proxy when enabled). We no longer maintain
|
||||||
|
// project-owned module copies, so the URL tracks upstream releases.
|
||||||
|
let fileName: String
|
||||||
switch self {
|
switch self {
|
||||||
case .surge, .egern:
|
case .surge, .egern: fileName = "wloc.sgmodule"
|
||||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.sgmodule"
|
case .quantumultX: fileName = "wloc.conf"
|
||||||
case .quantumultX:
|
case .loon: fileName = "wloc.lpx"
|
||||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.conf"
|
case .stash: fileName = "wloc.stoverride"
|
||||||
case .loon:
|
case .shadowrocket: fileName = "wloc.module"
|
||||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.lpx"
|
|
||||||
case .stash:
|
|
||||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.stoverride"
|
|
||||||
case .shadowrocket:
|
|
||||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.module"
|
|
||||||
}
|
}
|
||||||
return URL(string: url)!
|
let base = ThirdPartyModuleSourceStore.shared.useMirror
|
||||||
|
? "https://gh-proxy.org/https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/\(fileName)"
|
||||||
|
: "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/\(fileName)"
|
||||||
|
return URL(string: base)!
|
||||||
}
|
}
|
||||||
|
|
||||||
var launchURL: URL? {
|
var launchURL: URL? {
|
||||||
|
|||||||
@@ -62,11 +62,23 @@ final class AppRemoteConfigurationTests: XCTestCase {
|
|||||||
func testFallbackMatchesCurrentProjectPolicy() {
|
func testFallbackMatchesCurrentProjectPolicy() {
|
||||||
let configuration = AppRemoteConfiguration.fallback
|
let configuration = AppRemoteConfiguration.fallback
|
||||||
|
|
||||||
XCTAssertEqual(configuration.latestVersion, "1.0.4")
|
XCTAssertEqual(configuration.latestVersion, "1.0.5")
|
||||||
XCTAssertEqual(configuration.minimumSupportedVersion, "1.0.0")
|
XCTAssertEqual(configuration.minimumSupportedVersion, "1.0.0")
|
||||||
XCTAssertFalse(configuration.requestsCommunityPrompt(for: .shadowrocket))
|
XCTAssertFalse(configuration.requestsCommunityPrompt(for: .shadowrocket))
|
||||||
for client in ThirdPartyProxyClient.allCases where client != .shadowrocket {
|
for client in ThirdPartyProxyClient.allCases where client != .shadowrocket {
|
||||||
XCTAssertTrue(configuration.requestsCommunityPrompt(for: client))
|
XCTAssertTrue(configuration.requestsCommunityPrompt(for: client))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testUpdateResourcesPreferDomesticMirrorAndRetainOfficialFallback() {
|
||||||
|
XCTAssertEqual(AppRemoteConfigurationService.configurationURLs.first?.host, "gh-proxy.org")
|
||||||
|
XCTAssertEqual(
|
||||||
|
AppRemoteConfigurationService.configurationURLs.last?.host,
|
||||||
|
"raw.githubusercontent.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
let releaseNotesURLs = AppRemoteConfigurationService.releaseNotesURLs(version: "1.0.5")
|
||||||
|
XCTAssertEqual(releaseNotesURLs.first?.host, "gh-proxy.org")
|
||||||
|
XCTAssertEqual(releaseNotesURLs.last?.host, "raw.githubusercontent.com")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import PaopaoLocationSpoofer
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class MotionSimulationStoreTests: XCTestCase {
|
||||||
|
func testDefaultsToDisabledAndPersistsChanges() {
|
||||||
|
let suite = "MotionSimulationStoreTests.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
|
|
||||||
|
let store = MotionSimulationStore(defaults: defaults)
|
||||||
|
XCTAssertFalse(store.isEnabled)
|
||||||
|
|
||||||
|
store.setEnabled(true)
|
||||||
|
XCTAssertTrue(MotionSimulationStore(defaults: defaults).isEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import PaopaoLocationSpoofer
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ThirdPartyModuleSourceStoreTests: XCTestCase {
|
||||||
|
func testMirrorDefaultsToEnabledAndPersists() {
|
||||||
|
let suite = "ThirdPartyModuleSourceStoreTests.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
|
|
||||||
|
let store = ThirdPartyModuleSourceStore(defaults: defaults)
|
||||||
|
XCTAssertTrue(store.useMirror)
|
||||||
|
|
||||||
|
store.setUseMirror(false)
|
||||||
|
XCTAssertFalse(ThirdPartyModuleSourceStore(defaults: defaults).useMirror)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,66 @@ final class ThirdPartyProxyManagerTests: XCTestCase {
|
|||||||
XCTAssertEqual(manager.connectionState, .connected(active: true))
|
XCTAssertEqual(manager.connectionState, .connected(active: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testConnectionUsesLegacySaveQueryEndpoint() async throws {
|
||||||
|
let requester = FakeThirdPartyRequester(body: #"{"success":false,"error":"无已保存的坐标"}"#)
|
||||||
|
let manager = ThirdPartyProxyManager(requester: requester)
|
||||||
|
|
||||||
|
let response = try await manager.query()
|
||||||
|
|
||||||
|
XCTAssertFalse(response.success)
|
||||||
|
XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/save"])
|
||||||
|
XCTAssertEqual(requester.requestedURLs.first?.query, "action=query")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLegacyModuleCanStillSaveBasicCoordinates() async throws {
|
||||||
|
let favorite = FavoriteLocation(
|
||||||
|
name: "深圳湾",
|
||||||
|
latitude: 22.494,
|
||||||
|
longitude: 113.951,
|
||||||
|
accuracy: 20,
|
||||||
|
mapCoordinateSystem: .gcj02
|
||||||
|
)
|
||||||
|
let wgs84 = favorite.coordinatePair.wgs84
|
||||||
|
let body = String(
|
||||||
|
format: #"{"success":true,"longitude":%.8f,"latitude":%.8f,"accuracy":20}"#,
|
||||||
|
locale: Locale(identifier: "en_US_POSIX"),
|
||||||
|
wgs84.longitude,
|
||||||
|
wgs84.latitude
|
||||||
|
)
|
||||||
|
let requester = FakeThirdPartyRequester(body: body)
|
||||||
|
let manager = ThirdPartyProxyManager(requester: requester)
|
||||||
|
|
||||||
|
let response = try await manager.save(favorite)
|
||||||
|
|
||||||
|
XCTAssertTrue(response.success)
|
||||||
|
XCTAssertEqual(manager.connectionState, .connected(active: true))
|
||||||
|
XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/save"])
|
||||||
|
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 {
|
||||||
|
let requester = FakeThirdPartyRequester(body: "not-json")
|
||||||
|
let manager = ThirdPartyProxyManager(requester: requester)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await manager.query()
|
||||||
|
XCTFail("expected interception failure")
|
||||||
|
} catch {
|
||||||
|
XCTAssertEqual(error as? ThirdPartyProxyError, .moduleNotIntercepted)
|
||||||
|
}
|
||||||
|
XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/save"])
|
||||||
|
}
|
||||||
|
|
||||||
func testSaveRejectsCoordinateMismatchWithoutMarkingActive() async {
|
func testSaveRejectsCoordinateMismatchWithoutMarkingActive() async {
|
||||||
let requester = FakeThirdPartyRequester(body: #"{"success":true,"longitude":1,"latitude":2,"accuracy":25}"#)
|
let requester = FakeThirdPartyRequester(body: #"{"success":true,"longitude":1,"latitude":2,"accuracy":25}"#)
|
||||||
let manager = ThirdPartyProxyManager(requester: requester)
|
let manager = ThirdPartyProxyManager(requester: requester)
|
||||||
@@ -65,12 +125,29 @@ final class ThirdPartyProxyManagerTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testClientLinksUseOfficialUpstreamModulesAndVerificationLabels() {
|
func testClientLinksUseUpstreamModulesAndVerificationLabels() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
ThirdPartyProxyManager.interceptionHostnamesText,
|
||||||
|
"gs-loc.apple.com, gs-loc-cn.apple.com, gsp-ssl.ls.apple.com, bluedot.is.autonavi.com, bluedot.is.autonavi.com.gds.alibabadns.com"
|
||||||
|
)
|
||||||
XCTAssertNil(ThirdPartyProxyClient.shadowrocket.verificationText)
|
XCTAssertNil(ThirdPartyProxyClient.shadowrocket.verificationText)
|
||||||
XCTAssertTrue(ThirdPartyProxyClient.surge.verificationText?.contains("尚未验证") == true)
|
XCTAssertTrue(ThirdPartyProxyClient.surge.verificationText?.contains("尚未验证") == true)
|
||||||
XCTAssertEqual(ThirdPartyProxyClient.egern.subscriptionURL, ThirdPartyProxyClient.surge.subscriptionURL)
|
XCTAssertEqual(ThirdPartyProxyClient.egern.subscriptionURL, ThirdPartyProxyClient.surge.subscriptionURL)
|
||||||
XCTAssertTrue(ThirdPartyProxyClient.stash.subscriptionURL.absoluteString.hasSuffix("/modules/wloc.stoverride"))
|
XCTAssertTrue(ThirdPartyProxyClient.stash.subscriptionURL.absoluteString.hasPrefix(
|
||||||
XCTAssertTrue(ThirdPartyProxyClient.shadowrocket.subscriptionURL.absoluteString.hasSuffix("/modules/wloc.module"))
|
"https://gh-proxy.org/https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/"
|
||||||
|
))
|
||||||
|
let stashComponents = URLComponents(
|
||||||
|
url: ThirdPartyProxyClient.stash.subscriptionURL,
|
||||||
|
resolvingAgainstBaseURL: false
|
||||||
|
)
|
||||||
|
let shadowrocketComponents = URLComponents(
|
||||||
|
url: ThirdPartyProxyClient.shadowrocket.subscriptionURL,
|
||||||
|
resolvingAgainstBaseURL: false
|
||||||
|
)
|
||||||
|
XCTAssertEqual(stashComponents?.path, "/https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.stoverride")
|
||||||
|
XCTAssertEqual(shadowrocketComponents?.path, "/https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.module")
|
||||||
|
XCTAssertTrue(stashComponents?.queryItems?.isEmpty ?? true)
|
||||||
|
XCTAssertTrue(shadowrocketComponents?.queryItems?.isEmpty ?? true)
|
||||||
XCTAssertEqual(ThirdPartyProxyClient.shadowrocket.launchURL?.scheme, "shadowrocket")
|
XCTAssertEqual(ThirdPartyProxyClient.shadowrocket.launchURL?.scheme, "shadowrocket")
|
||||||
XCTAssertEqual(ThirdPartyProxyClient.surge.launchURL?.scheme, "surge")
|
XCTAssertEqual(ThirdPartyProxyClient.surge.launchURL?.scheme, "surge")
|
||||||
XCTAssertEqual(ThirdPartyProxyClient.quantumultX.launchURL?.scheme, "quantumult-x")
|
XCTAssertEqual(ThirdPartyProxyClient.quantumultX.launchURL?.scheme, "quantumult-x")
|
||||||
@@ -95,6 +172,7 @@ final class ThirdPartyProxyManagerTests: XCTestCase {
|
|||||||
private final class FakeThirdPartyRequester: ThirdPartyProxyRequesting {
|
private final class FakeThirdPartyRequester: ThirdPartyProxyRequesting {
|
||||||
private let data: Data
|
private let data: Data
|
||||||
private(set) var lastURL: URL?
|
private(set) var lastURL: URL?
|
||||||
|
private(set) var requestedURLs: [URL] = []
|
||||||
|
|
||||||
init(body: String) {
|
init(body: String) {
|
||||||
data = Data(body.utf8)
|
data = Data(body.utf8)
|
||||||
@@ -102,6 +180,7 @@ private final class FakeThirdPartyRequester: ThirdPartyProxyRequesting {
|
|||||||
|
|
||||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||||
lastURL = request.url
|
lastURL = request.url
|
||||||
|
requestedURLs.append(request.url!)
|
||||||
let response = HTTPURLResponse(
|
let response = HTTPURLResponse(
|
||||||
url: request.url!,
|
url: request.url!,
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ MANAGER="$ROOT/Shared/ThirdPartyProxyManager.swift"
|
|||||||
CONTENT="$ROOT/App/ContentView.swift"
|
CONTENT="$ROOT/App/ContentView.swift"
|
||||||
SETUP="$ROOT/App/FirstSetupView.swift"
|
SETUP="$ROOT/App/FirstSetupView.swift"
|
||||||
SETTINGS="$ROOT/App/SettingsView.swift"
|
SETTINGS="$ROOT/App/SettingsView.swift"
|
||||||
MODULES="$ROOT/Resources/ThirdPartyProxyModules"
|
|
||||||
|
|
||||||
grep -q 'return "APP模式"' "$MODE" || fail "APP mode display name is missing"
|
grep -q 'return "APP模式"' "$MODE" || fail "APP mode display name is missing"
|
||||||
grep -q 'return "第三方代理模式"' "$MODE" || fail "third-party mode display name is missing"
|
grep -q 'return "第三方代理模式"' "$MODE" || fail "third-party mode display name is missing"
|
||||||
@@ -38,17 +37,21 @@ grep -Fq '保存:GET ?lon=<经度>&lat=<纬度>&acc=<精度>' "$SETUP" \
|
|||||||
grep -Fq '清除:GET ?action=clear' "$SETUP" \
|
grep -Fq '清除:GET ?action=clear' "$SETUP" \
|
||||||
|| fail "client integration guidance must document the clear action"
|
|| fail "client integration guidance must document the clear action"
|
||||||
|
|
||||||
for file in wloc.module wloc.sgmodule wloc.conf wloc.lpx wloc.stoverride; do
|
grep -q 'Yu9191/wloc/refs/heads/main/modules' "$MANAGER" \
|
||||||
test -s "$MODULES/$file" || fail "missing bundled module: $file"
|
|| fail "third-party subscription must point at upstream Yu9191 modules"
|
||||||
done
|
|
||||||
|
|
||||||
grep -q 'wloc.sgmodule' "$MANAGER" || fail "Surge/Egern module mapping is missing"
|
grep -q 'wloc.sgmodule' "$MANAGER" || fail "Surge/Egern module mapping is missing"
|
||||||
grep -q 'wloc.stoverride' "$MANAGER" || fail "Stash must use .stoverride directly"
|
grep -q 'wloc.stoverride' "$MANAGER" || fail "Stash must use .stoverride directly"
|
||||||
grep -q 'shadowrocket://' "$MANAGER" || fail "Shadowrocket launch URL is missing"
|
grep -q 'shadowrocket://' "$MANAGER" || fail "Shadowrocket launch URL is missing"
|
||||||
for scheme in surge quantumult-x loon stash egern; do
|
for scheme in surge quantumult-x loon stash egern; do
|
||||||
grep -q "${scheme}://" "$MANAGER" || fail "$scheme launch URL is missing"
|
grep -q "${scheme}://" "$MANAGER" || fail "$scheme launch URL is missing"
|
||||||
done
|
done
|
||||||
grep -q '复制解密域名' "$SETUP" || fail "Shadowrocket MITM hostname copy action is missing"
|
grep -q '复制解密域名' "$SETUP" || fail "all clients must expose the MITM hostname copy action"
|
||||||
|
grep -q '配置时请复制下方全部解密域名' "$SETUP" \
|
||||||
|
|| fail "setup guidance must direct users to copy all Apple location hostnames"
|
||||||
|
grep -q 'ThirdPartyProxyManager.interceptionHostnamesText' "$SETUP" \
|
||||||
|
|| fail "setup hostname copy actions must use the shared interception hostname value"
|
||||||
|
grep -q 'ThirdPartyProxyManager.interceptionHostnamesText' "$SETTINGS" \
|
||||||
|
|| fail "Settings must expose the shared interception hostname copy action"
|
||||||
grep -q '配置 → 模块' "$SETUP" || fail "Shadowrocket module import guidance is missing"
|
grep -q '配置 → 模块' "$SETUP" || fail "Shadowrocket module import guidance is missing"
|
||||||
grep -q 'HTTPS 解密' "$SETUP" || fail "Shadowrocket HTTPS decryption guidance is missing"
|
grep -q 'HTTPS 解密' "$SETUP" || fail "Shadowrocket HTTPS decryption guidance is missing"
|
||||||
! grep -q '当前可测试' "$SETUP" || fail "Shadowrocket must not show the obsolete current-test label"
|
! grep -q '当前可测试' "$SETUP" || fail "Shadowrocket must not show the obsolete current-test label"
|
||||||
@@ -65,9 +68,18 @@ grep -q 'Label("查看诊断日志"' "$SETUP" \
|
|||||||
! 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.query()' "$SETUP" \
|
grep -q 'let response = try await thirdPartyProxy.query()' "$SETUP" \
|
||||||
|| fail "the import page must query the third-party module"
|
|| fail "the import page must validate the save/query endpoint"
|
||||||
grep -A15 'let response = try await thirdPartyProxy.query()' "$SETUP" | grep -q 'onComplete()' \
|
grep -A20 'let response = try await thirdPartyProxy.query()' "$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" \
|
||||||
|
|| fail "the connection test must preserve the established save?action=query contract"
|
||||||
|
grep -B4 'Toggle("运动状态模拟"' "$SETTINGS" | grep -q 'runtimeMode.mode == .localWiFi' \
|
||||||
|
|| fail "the motion-state toggle must only appear in APP mode"
|
||||||
|
grep -B4 'Toggle("随机扰动"' "$SETTINGS" | grep -q 'runtimeMode.mode == .thirdParty' \
|
||||||
|
|| fail "the random-perturbation toggle must only appear in third-party mode"
|
||||||
|
! grep -q 'validateVersion\|refreshAdvancedFeatureAvailability\|moduleUpdateRecommended' \
|
||||||
|
"$SETUP" "$SETTINGS" "$MANAGER" \
|
||||||
|
|| fail "script version detection must be removed from the app"
|
||||||
! 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" \
|
||||||
@@ -112,7 +124,7 @@ test -s "$ROOT/docs/onboarding-screenshots/shadowrocket/shadowrocket-module-impo
|
|||||||
grep -q 'presentSuccessfulOperationTip(.activation)' "$ROOT/App/MapHomeView.swift" || fail "third-party save must present the activation tip"
|
grep -q 'presentSuccessfulOperationTip(.activation)' "$ROOT/App/MapHomeView.swift" || fail "third-party save must present the activation tip"
|
||||||
grep -q 'presentSuccessfulOperationTip(.deactivation)' "$ROOT/App/MapHomeView.swift" || fail "third-party clear must present the deactivation tip"
|
grep -q 'presentSuccessfulOperationTip(.deactivation)' "$ROOT/App/MapHomeView.swift" || fail "third-party clear must present the deactivation tip"
|
||||||
grep -q 'if spoofState == .active' "$ROOT/App/MapHomeView.swift" || fail "manual help must follow the shared spoof state"
|
grep -q 'if spoofState == .active' "$ROOT/App/MapHomeView.swift" || fail "manual help must follow the shared spoof state"
|
||||||
grep -q 'MARKETING_VERSION: "1.0.4"' "$ROOT/project.yml" || fail "marketing version must be 1.0.4"
|
grep -q 'MARKETING_VERSION: "1.0.6"' "$ROOT/project.yml" || fail "marketing version must be 1.0.6"
|
||||||
grep -q 'CURRENT_PROJECT_VERSION: "5"' "$ROOT/project.yml" || fail "build version must be 5"
|
grep -q 'CURRENT_PROJECT_VERSION: "7"' "$ROOT/project.yml" || fail "build version must be 7"
|
||||||
|
|
||||||
echo "PASS: third-party proxy mode contract"
|
echo "PASS: third-party proxy mode contract"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import sys
|
|||||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||||
config = json.load(handle)
|
config = json.load(handle)
|
||||||
|
|
||||||
assert config["latestVersion"] == "1.0.4"
|
assert config["latestVersion"] == "1.0.5"
|
||||||
assert config["minimumSupportedVersion"] == "1.0.0"
|
assert config["minimumSupportedVersion"] == "1.0.0"
|
||||||
assert "shadowrocket" not in config["communityPromptClients"]
|
assert "shadowrocket" not in config["communityPromptClients"]
|
||||||
assert set(config["communityPromptClients"]) == {
|
assert set(config["communityPromptClients"]) == {
|
||||||
@@ -39,6 +39,10 @@ grep -q 'timeoutIntervalForRequest = 1.5' "$CONFIG" \
|
|||||||
|| fail "remote configuration requests must use a short timeout"
|
|| fail "remote configuration requests must use a short timeout"
|
||||||
grep -q 'timeoutIntervalForResource = 2' "$CONFIG" \
|
grep -q 'timeoutIntervalForResource = 2' "$CONFIG" \
|
||||||
|| fail "remote configuration resource loading must use a short timeout"
|
|| fail "remote configuration resource loading must use a short timeout"
|
||||||
|
grep -q 'gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/version.txt' "$CONFIG" \
|
||||||
|
|| fail "update detection must prefer the domestic GitHub Raw mirror"
|
||||||
|
grep -q 'static let configurationURLs = \[' "$CONFIG" \
|
||||||
|
|| fail "update detection must retain multiple configuration sources"
|
||||||
! grep -q 'data.count' "$CONFIG" \
|
! grep -q 'data.count' "$CONFIG" \
|
||||||
|| fail "the client must not impose a remote configuration file-size limit"
|
|| fail "the client must not impose a remote configuration file-size limit"
|
||||||
grep -q 'docs/releases/v\\(version).md' "$CONFIG" \
|
grep -q 'docs/releases/v\\(version).md' "$CONFIG" \
|
||||||
@@ -49,6 +53,12 @@ grep -q '.task { await checkForUpdates() }' "$CONTENT" \
|
|||||||
|| fail "update detection must run asynchronously outside bootstrap"
|
|| fail "update detection must run asynchronously outside bootstrap"
|
||||||
! grep -A90 'private func bootstrap() async' "$CONTENT" | grep -q 'fetch()' \
|
! grep -A90 'private func bootstrap() async' "$CONTENT" | grep -q 'fetch()' \
|
||||||
|| fail "remote configuration must not block the startup gate"
|
|| fail "remote configuration must not block the startup gate"
|
||||||
|
grep -Fq 'Label("检查更新"' "$SETTINGS" \
|
||||||
|
|| fail "Settings must expose a manual update check"
|
||||||
|
grep -Fq 'title: Text("已是最新版本")' "$SETTINGS" \
|
||||||
|
|| fail "manual update checks must report the current-version result"
|
||||||
|
grep -Fq 'title: Text("检查更新失败")' "$SETTINGS" \
|
||||||
|
|| fail "manual update checks must report network failures"
|
||||||
|
|
||||||
grep -q '社区分享成功配置?' "$MAP" \
|
grep -q '社区分享成功配置?' "$MAP" \
|
||||||
|| fail "non-Shadowrocket success must offer community contribution"
|
|| fail "non-Shadowrocket success must offer community contribution"
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
## 已发布
|
## 已发布
|
||||||
|
|
||||||
|
- [v1.0.6](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.6) — 2026-09-01
|
||||||
|
- [v1.0.5](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.5) — 2026-08-10
|
||||||
- [v1.0.4](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.4) — 2026-08-09
|
- [v1.0.4](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.4) — 2026-08-09
|
||||||
- [v1.0.3](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.3) — 2026-08-09
|
- [v1.0.3](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.3) — 2026-08-09
|
||||||
- [v1.0.2](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.2) — 2026-08-07
|
- [v1.0.2](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.2) — 2026-08-07
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ iOS 版本:
|
|||||||
|
|
||||||
步骤 2:
|
步骤 2:
|
||||||
- 截图:02-https-decryption.jpg
|
- 截图:02-https-decryption.jpg
|
||||||
- 操作:开启 HTTPS 解密,并填写 gs-loc.apple.com
|
- 操作:开启 HTTPS 解密,并填写 gs-loc.apple.com、gs-loc-cn.apple.com
|
||||||
|
|
||||||
验证结果:
|
验证结果:
|
||||||
```
|
```
|
||||||
|
|||||||
+28
-19
@@ -1,15 +1,22 @@
|
|||||||
# Third-party proxy module snapshots
|
# Third-party proxy modules
|
||||||
|
|
||||||
The files under `Resources/ThirdPartyProxyModules/` are bundled configuration
|
Third-party proxy mode (Surge / Quantumult X / Loon / Shadowrocket / Stash /
|
||||||
snapshots from [Yu9191/wloc](https://github.com/Yu9191/wloc). They are retained
|
Egern) now relies entirely on the upstream
|
||||||
in the App bundle for release provenance and offline inspection. The setup UI
|
[Yu9191/wloc](https://github.com/Yu9191/wloc) modules. The App no longer
|
||||||
copies the official subscription URL instead of exporting these files.
|
maintains or ships project-owned module/script copies, so it never drifts
|
||||||
|
from the upstream protocol.
|
||||||
|
|
||||||
- Upstream commit: `eec07a8dc8de6dbaee8eac1fb376e4d03020154a`
|
## Subscription addresses
|
||||||
- Snapshot date: 2026-08-06
|
|
||||||
- Source directory: `modules/`
|
|
||||||
|
|
||||||
| Bundled file | Client |
|
The App builds each client's module subscription URL directly from the
|
||||||
|
upstream repository:
|
||||||
|
|
||||||
|
- default mirror (gh-proxy):
|
||||||
|
`https://gh-proxy.org/https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/<file>`
|
||||||
|
- direct:
|
||||||
|
`https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/<file>`
|
||||||
|
|
||||||
|
| Module file | Client |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `wloc.module` | Shadowrocket |
|
| `wloc.module` | Shadowrocket |
|
||||||
| `wloc.sgmodule` | Surge and Egern |
|
| `wloc.sgmodule` | Surge and Egern |
|
||||||
@@ -17,15 +24,17 @@ copies the official subscription URL instead of exporting these files.
|
|||||||
| `wloc.lpx` | Loon |
|
| `wloc.lpx` | Loon |
|
||||||
| `wloc.stoverride` | Stash |
|
| `wloc.stoverride` | Stash |
|
||||||
|
|
||||||
SHA-256:
|
No `?v=` cache-bust is appended: the URL points at upstream's latest content,
|
||||||
|
and re-importing the subscription in the proxy client re-fetches it.
|
||||||
|
|
||||||
```text
|
## Script protocol
|
||||||
bb5e17b60027704971660b0ea2df3560ceff973c27d43e7f2c2c18b48d368ac6 wloc.conf
|
|
||||||
1fb451616fb17242849f72490f016afcdb8aa81a0b086f6dd5f94e1af3d58ee1 wloc.lpx
|
|
||||||
97cab104056428aa0e90521c3bf2646e9739b0b4c83272b31790f99584bca89e wloc.module
|
|
||||||
5d6b82c31316f4a7be65e3b8d2335f4338e01af98e262948118eefe63abf7034 wloc.sgmodule
|
|
||||||
cb06593752db8b223dfa5cd1cbd089115fe3a541f5c8532491615923e83df2cb wloc.stoverride
|
|
||||||
```
|
|
||||||
|
|
||||||
The official subscription URLs are the setup UI's import path. Egern reuses the
|
The upstream `wloc.js` patches Apple WLOC responses and reads coordinates from
|
||||||
Surge module. Stash imports `.stoverride` directly.
|
the `wloc_settings` persistent key or the module `argument` config. The
|
||||||
|
upstream `wloc-settings.js` implements `wloc-settings/save` (query/clear/save)
|
||||||
|
using `lon`/`lat`/`acc`/`randomRadius` parameters.
|
||||||
|
|
||||||
|
The App's third-party save sends `lon`/`lat`/`acc`, matching the upstream
|
||||||
|
script. Motion-state simulation (fields 11/12) is **not** implemented by the
|
||||||
|
upstream scripts and is unavailable in third-party mode; it remains available
|
||||||
|
in APP mode (built-in proxy).
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 153 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
# v1.0.5
|
||||||
|
|
||||||
|
发布日期:2026-08-10
|
||||||
|
|
||||||
|
## 主要更新
|
||||||
|
|
||||||
|
- APP 模式与第三方代理模式统一支持 Wi-Fi、CellTower 字段 22/24,以及 ARPC、marker、synthetic、bare 等 WLOC 响应格式。
|
||||||
|
- 新增默认关闭的运动状态模拟,可在定位响应中同步模拟运动状态。
|
||||||
|
- 第三方模式改用项目维护的固定版本脚本,支持 Shadowrocket、Surge、Quantumult X、Loon、Stash 和 Egern。
|
||||||
|
- 设置页新增手动检查更新入口;版本配置和更新说明优先使用国内镜像,并保留 GitHub 官方源兜底。
|
||||||
|
|
||||||
|
## 第三方模式
|
||||||
|
|
||||||
|
- 基础坐标同步继续兼容旧模块,不再因缺少版本接口阻断导入或开启虚拟定位。
|
||||||
|
- 运动状态模拟属于高级功能;开启前必须通过 `/wloc-settings/version` 协议与能力检测,检测失败时保持关闭并提示更新模块。
|
||||||
|
- 完善模块缓存更新、连接诊断和失败引导,保留当前第三方客户端及详细错误信息。
|
||||||
|
- Shadowrocket 和其他第三方客户端引导统一补充 `gs-loc.apple.com`、`gs-loc-cn.apple.com` 两个 HTTPS 解密域名及复制入口。
|
||||||
|
|
||||||
|
## 引导与资源
|
||||||
|
|
||||||
|
- 更新 Shadowrocket HTTPS 解密截图,直接标注开关、双域名和证书入口步骤。
|
||||||
|
- 公共教程同步双域名配置要求,同时保留未经标注的原始截图。
|
||||||
|
|
||||||
|
## 兼容性说明
|
||||||
|
|
||||||
|
- 运动状态模拟默认关闭,升级后不会改变已有定位行为。
|
||||||
|
- 旧版第三方模块仍可使用基础坐标功能;需要运动状态模拟时请重新导入最新模块。
|
||||||
|
|
||||||
|
## 自签安装
|
||||||
|
|
||||||
|
- Release 附件为未签名 IPA,安装前需要自行签名。
|
||||||
|
- 可使用免费 Apple ID 和 Impactor 完成签名安装,无需付费开发者账号。
|
||||||
|
- 签名时请保留 Bundle ID `com.paopaolabs.location-spoofer`、App Group `group.com.paopaolabs.location-spoofer` 及原有 entitlements。
|
||||||
|
- 免费 Apple ID 签名通常只有 7 天有效期,到期后需要重新签名安装。
|
||||||
|
|
||||||
|
<!-- commit-range: v1.0.4..v1.0.5 -->
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# v1.0.6
|
||||||
|
|
||||||
|
发布日期:2026-09-01
|
||||||
|
|
||||||
|
## 主要更新
|
||||||
|
|
||||||
|
- 第三方代理模式完全改用上游 Yu9191/wloc 的模块与脚本,订阅地址直接指向上游(支持 gh-proxy 国内镜像),项目不再维护自有模块/脚本副本,避免再因版本漂移失效。
|
||||||
|
- 扩展 Apple 定位域名拦截:新增 `gsp-ssl.ls.apple.com`、`bluedot.is.autonavi.com`(国内设备真实定位域名),内置代理同步覆盖。
|
||||||
|
- 第三方模式新增「随机扰动」开关:同步坐标时给目标点添加随机偏移(默认 50 米),防止位置固定在同一点。
|
||||||
|
- 运动状态模拟改为仅 APP 模式可用(上游脚本不支持第三方运动模拟)。
|
||||||
|
- 移除脚本版本检测:不再请求 `/wloc-settings/version`,第三方模式基础坐标同步不再受版本协议影响。
|
||||||
|
- 新增 iOS 27 beta 6 起系统禁止对 `gs-loc.apple.com` 进行 MITM 拦截的警告提示(App 引导与 README 同步)。
|
||||||
|
|
||||||
|
## 提交变更总结
|
||||||
|
|
||||||
|
- `b530d52` feat: drop script version detection; split motion vs perturbation by mode
|
||||||
|
- `135e26e` feat: point third-party mode at upstream Yu9191 modules
|
||||||
|
- `d35d5c7` feat: vendor upstream modules verbatim, mirror via gh-proxy
|
||||||
|
- `cce2aef` feat: wire randomRadius perturbation argument into all modules
|
||||||
|
- `9cea683` fix: point third-party modules at upstream wloc scripts
|
||||||
|
- `fb0ca96` fix: sync third-party wloc scripts with upstream
|
||||||
|
- `a666f17` fix: intercept gsp-ssl.ls.apple.com and bluedot wloc endpoints
|
||||||
|
|
||||||
|
## 自签安装
|
||||||
|
|
||||||
|
- Release 附件为未签名 IPA,安装前需要自行签名。
|
||||||
|
- 可使用免费 Apple ID 和 Impactor 完成签名安装,无需付费开发者账号。
|
||||||
|
- 签名时请保留 Bundle ID `com.paopaolabs.location-spoofer`、App Group `group.com.paopaolabs.location-spoofer` 及原有 entitlements。
|
||||||
|
- 免费 Apple ID 签名通常只有 7 天有效期,到期后需要重新签名安装。
|
||||||
|
|
||||||
|
<!-- commit-range: v1.0.5..v1.0.6 -->
|
||||||
+2
-2
@@ -8,8 +8,8 @@ options:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
SWIFT_VERSION: "5.9"
|
SWIFT_VERSION: "5.9"
|
||||||
MARKETING_VERSION: "1.0.4"
|
MARKETING_VERSION: "1.0.6"
|
||||||
CURRENT_PROJECT_VERSION: "5"
|
CURRENT_PROJECT_VERSION: "7"
|
||||||
CODE_SIGN_STYLE: Manual
|
CODE_SIGN_STYLE: Manual
|
||||||
CODE_SIGNING_ALLOWED: "NO"
|
CODE_SIGNING_ALLOWED: "NO"
|
||||||
CODE_SIGNING_REQUIRED: "NO"
|
CODE_SIGNING_REQUIRED: "NO"
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"latestVersion": "1.0.4",
|
"latestVersion": "1.0.6",
|
||||||
"minimumSupportedVersion": "1.0.0",
|
"minimumSupportedVersion": "1.0.0",
|
||||||
"communityPromptClients": ["surge", "quantumultX", "loon", "stash", "egern"]
|
"communityPromptClients": ["surge", "quantumultX", "loon", "stash", "egern"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user