mirror of
https://github.com/xweiba/location-spoofer.git
synced 2026-09-22 14:50:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eadeca636d | ||
|
|
b530d52add | ||
|
|
135e26ea7a | ||
|
|
d35d5c788a | ||
|
|
cce2aef58f | ||
|
|
9cea68392e | ||
|
|
fb0ca961a4 | ||
|
|
a666f177e6 | ||
|
|
a6b9e8056b | ||
|
|
9510d9aa42 | ||
|
|
41dfe7a8ed | ||
|
|
27af50375d | ||
|
|
a2f46dd166 | ||
|
|
b0d2f0e8e4 | ||
|
|
7cf38363cf |
@@ -2,6 +2,9 @@
|
||||
.superpowers/
|
||||
build/
|
||||
dist/
|
||||
!ThirdParty/WlocScripts/dist/
|
||||
!ThirdParty/WlocScripts/dist/**
|
||||
node_modules/
|
||||
DerivedData/
|
||||
xcuserdata/
|
||||
*.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 {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
thirdPartyMITMWarning
|
||||
if !setup.message.isEmpty {
|
||||
Label(setup.message, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.footnote)
|
||||
@@ -463,7 +477,7 @@ struct FirstSetupView: View {
|
||||
|
||||
Text("适配要求")
|
||||
.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)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -475,6 +489,7 @@ struct FirstSetupView: View {
|
||||
private var thirdPartyImportStep: some View {
|
||||
let client = thirdPartyClient.selectedClient
|
||||
return VStack(alignment: .leading, spacing: 16) {
|
||||
thirdPartyMITMWarning
|
||||
if showThirdPartyRepairReason {
|
||||
Label(
|
||||
"检测到第三方代理连接异常,请检查模块、MITM 和代理连接后重新检测。",
|
||||
@@ -533,10 +548,17 @@ struct FirstSetupView: View {
|
||||
shadowrocketHTTPSDecryptionGuide
|
||||
} else {
|
||||
GroupBox(label: Label("第 2 步:完成 \(client.name) 配置", systemImage: "slider.horizontal.3")) {
|
||||
Text("请在 \(client.name) 中完成相应配置。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("请在 \(client.name) 中完成相应配置。")
|
||||
.font(.caption)
|
||||
.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) {
|
||||
instructionRow(1, "进入“配置 → 本地文件”,找到带黄点的配置,点击右侧 i 图标。")
|
||||
instructionRow(2, "进入“HTTPS 解密”,开启解密开关。")
|
||||
instructionRow(3, "在域名列表中添加 gs-loc.apple.com。")
|
||||
instructionRow(3, "在域名列表中添加下方复制的全部解密域名。")
|
||||
setupScreenshot(
|
||||
assetName: "ShadowrocketHTTPSDecryption",
|
||||
title: "配置 HTTPS 解密",
|
||||
caption: "1 开启 HTTPS 解密,2 添加 gs-loc.apple.com,3 打开证书设置。"
|
||||
caption: "1 开启 HTTPS 解密,2 添加复制的全部解密域名,3 打开证书设置。"
|
||||
)
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = ThirdPartyProxyManager.interceptionHostname
|
||||
copiedMITMHostname = true
|
||||
} label: {
|
||||
Label(copiedMITMHostname ? "已复制 gs-loc.apple.com" : "复制解密域名", systemImage: "doc.on.doc")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
mitmHostnameCopyButton
|
||||
|
||||
instructionRow(4, "按 Shadowrocket 提示生成并完成证书授权。")
|
||||
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 {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("\(number)")
|
||||
@@ -842,13 +871,14 @@ struct FirstSetupView: View {
|
||||
"连接状态": connectionState,
|
||||
"耗时毫秒": String(elapsedMilliseconds),
|
||||
"错误类型": errorType,
|
||||
"处理建议": "检查模块、MITM、证书和代理/VPN连接"
|
||||
"处理建议": ThirdPartyProxyError.recoverySuggestion(for: error)
|
||||
]
|
||||
)
|
||||
thirdPartyTestFailure = ThirdPartyConnectionTestFailure(
|
||||
message: """
|
||||
======== 第三方代理连接检测 ========
|
||||
当前客户端:\(client.name)
|
||||
配置接口:/wloc-settings/save
|
||||
请求动作:WLOC query
|
||||
检查范围:模块拦截、MITM、证书、代理/VPN 连接
|
||||
连接状态:\(connectionState)
|
||||
@@ -856,7 +886,7 @@ struct FirstSetupView: View {
|
||||
耗时:\(elapsedMilliseconds) ms
|
||||
错误类型:\(errorType)
|
||||
错误详情:\(error.localizedDescription)
|
||||
处理建议:确认模块已启用,并检查 MITM、证书和代理/VPN 连接后重试。
|
||||
处理建议:\(ThirdPartyProxyError.recoverySuggestion(for: error))。
|
||||
"""
|
||||
)
|
||||
showsThirdPartyFailureLog = true
|
||||
|
||||
@@ -662,7 +662,7 @@ struct MapHomeView: View {
|
||||
"当前客户端": thirdPartyClient.selectedClient.name,
|
||||
"请求动作": "WLOC save",
|
||||
"恢复状态": wasActive ? "保留原第三方坐标" : "保持未启用",
|
||||
"处理建议": "检查模块、MITM、证书和代理/VPN连接"
|
||||
"处理建议": ThirdPartyProxyError.recoverySuggestion(for: error)
|
||||
]
|
||||
)
|
||||
setup.requestThirdPartySetup(message: error.localizedDescription)
|
||||
@@ -744,7 +744,7 @@ struct MapHomeView: View {
|
||||
"当前客户端": thirdPartyClient.selectedClient.name,
|
||||
"请求动作": "WLOC clear",
|
||||
"恢复状态": "保留已启用状态",
|
||||
"处理建议": "检查模块、MITM、证书和代理/VPN连接"
|
||||
"处理建议": ThirdPartyProxyError.recoverySuggestion(for: error)
|
||||
]
|
||||
)
|
||||
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 enabled = (settings?.enabled ?? false) ? CInt(1) : CInt(0)
|
||||
let accuracy = CInt(settings?.accuracy ?? 25)
|
||||
let motionEnabled = MotionSimulationStore.shared.isEnabled ? CInt(1) : CInt(0)
|
||||
if enabled != 0 {
|
||||
RuntimeLogger.info("APP", "坐标转换", "启动代理: 恢复上次 WGS-84 定位")
|
||||
}
|
||||
let result: UInt = authority.certPEM.withCString { cp 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 }
|
||||
@@ -58,7 +67,13 @@ final class ProxyManager: ObservableObject {
|
||||
@discardableResult
|
||||
func setCoords(lat: Double, lon: Double, enabled: Bool, accuracy: Int = 25) -> UInt64 {
|
||||
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: [
|
||||
"revision": String(coordinateRevision),
|
||||
"enabled": String(enabled),
|
||||
@@ -119,6 +134,22 @@ final class ProxyManager: ObservableObject {
|
||||
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? {
|
||||
do {
|
||||
if !isRunning { try await start() }
|
||||
|
||||
+158
-3
@@ -1,5 +1,22 @@
|
||||
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 {
|
||||
@ObservedObject var setup: SetupCoordinator
|
||||
@ObservedObject var actions: LocationActionCoordinator
|
||||
@@ -7,14 +24,20 @@ struct SettingsView: View {
|
||||
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
|
||||
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.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
|
||||
@State private var activeTip: TipKind?
|
||||
@State private var proxyOperationError = ""
|
||||
@State private var proxyOperationAlertTitle = "代理操作失败"
|
||||
@State private var modeOperationRunning = false
|
||||
@State private var copiedClient: ThirdPartyProxyClient?
|
||||
@State private var copiedMITMHostnames = false
|
||||
@State private var showCertificateResetConfirmation = false
|
||||
@State private var githubDestination: SafariDestination?
|
||||
@State private var isCheckingForUpdates = false
|
||||
@State private var updateCheckResult: UpdateCheckResult?
|
||||
|
||||
var body: some View {
|
||||
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 {
|
||||
thirdPartyConfigurationSection
|
||||
} else {
|
||||
@@ -99,6 +142,19 @@ struct SettingsView: View {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -195,6 +251,9 @@ struct SettingsView: View {
|
||||
} message: {
|
||||
Text(proxyOperationError)
|
||||
}
|
||||
.alert(item: $updateCheckResult) { result in
|
||||
updateCheckAlert(for: result)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"重置证书?",
|
||||
isPresented: $showCertificateResetConfirmation,
|
||||
@@ -219,6 +278,71 @@ struct SettingsView: View {
|
||||
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> {
|
||||
Binding(get: { proxy.isRunning }, set: { on in
|
||||
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
|
||||
private var thirdPartyConfigurationSection: some View {
|
||||
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 {
|
||||
HStack {
|
||||
Text("验证状态")
|
||||
@@ -277,6 +425,13 @@ struct SettingsView: View {
|
||||
Label(copiedClient == thirdPartyClient.selectedClient ? "已复制模块订阅地址" : "复制模块订阅地址", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = ThirdPartyProxyManager.interceptionHostnamesText
|
||||
copiedMITMHostnames = true
|
||||
} label: {
|
||||
Label(copiedMITMHostnames ? "已复制解密域名" : "复制解密域名", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
Button {
|
||||
openThirdPartyClient(thirdPartyClient.selectedClient)
|
||||
} label: {
|
||||
@@ -298,7 +453,7 @@ struct SettingsView: View {
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text("复制模块订阅地址后,在对应代理客户端中添加模块/重写订阅,并启用 MITM。第三方客户端保存坐标后,即使关闭本 App,坐标仍由代理客户端持久化并继续生效。")
|
||||
Text("复制模块订阅地址后,在对应代理客户端中添加模块/重写订阅,并为复制的全部域名(含 gsp-ssl.ls.apple.com、bluedot.is.autonavi.com)启用 MITM。第三方客户端保存坐标后,即使关闭本 App,坐标仍由代理客户端持久化并继续生效。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -336,7 +491,7 @@ struct SettingsView: View {
|
||||
return """
|
||||
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",
|
||||
"连接状态": String(describing: thirdPartyProxy.connectionState),
|
||||
"耗时毫秒": String(Int(Date().timeIntervalSince(startedAt) * 1_000)),
|
||||
"处理建议": "检查模块、MITM、证书和代理/VPN连接"
|
||||
"处理建议": ThirdPartyProxyError.recoverySuggestion(for: error)
|
||||
]
|
||||
)
|
||||
openThirdPartySetup(for: error)
|
||||
|
||||
+15
-1
@@ -55,6 +55,11 @@ func wloccore_validateca(certData, keyData *C.char) C.int {
|
||||
|
||||
//export wloccore_startproxy
|
||||
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 {
|
||||
return 0
|
||||
}
|
||||
@@ -65,6 +70,7 @@ func wloccore_startproxy(certData, keyData *C.char, lat, lon C.double, enabled C
|
||||
float64(lon),
|
||||
enabled != 0,
|
||||
int(accuracy),
|
||||
motionEnabled != 0,
|
||||
)
|
||||
if err != nil {
|
||||
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
|
||||
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()
|
||||
currentLat = float64(lat)
|
||||
currentLon = float64(lon)
|
||||
currentEnabled = enabled != 0
|
||||
currentAccuracy = int(accuracy)
|
||||
currentMotionSimulationEnabled = motionEnabled != 0
|
||||
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
|
||||
|
||||
+24
-13
@@ -22,13 +22,14 @@ import (
|
||||
const proxyPort = 8888
|
||||
|
||||
var (
|
||||
stateMu sync.Mutex
|
||||
currentLat float64
|
||||
currentLon float64
|
||||
currentEnabled bool
|
||||
currentAccuracy int
|
||||
globalCACert *tls.Certificate
|
||||
verifyToken string
|
||||
stateMu sync.Mutex
|
||||
currentLat float64
|
||||
currentLon float64
|
||||
currentEnabled bool
|
||||
currentAccuracy int
|
||||
currentMotionSimulationEnabled bool
|
||||
globalCACert *tls.Certificate
|
||||
verifyToken string
|
||||
|
||||
logMu sync.Mutex
|
||||
logEntries []string
|
||||
@@ -63,7 +64,13 @@ func isWlocHost(host string) bool {
|
||||
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 {
|
||||
@@ -94,10 +101,10 @@ func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer {
|
||||
}
|
||||
if r.URL.Path == "/coords" {
|
||||
stateMu.Lock()
|
||||
enabled, lat, lon, accuracy := currentEnabled, currentLat, currentLon, currentAccuracy
|
||||
enabled, lat, lon, accuracy, motionEnabled := currentEnabled, currentLat, currentLon, currentAccuracy, currentMotionSimulationEnabled
|
||||
stateMu.Unlock()
|
||||
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
|
||||
}
|
||||
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()
|
||||
enabled, lat, lon, accuracy := currentEnabled, currentLat, currentLon, currentAccuracy
|
||||
enabled, lat, lon, accuracy, motionEnabled := currentEnabled, currentLat, currentLon, currentAccuracy, currentMotionSimulationEnabled
|
||||
stateMu.Unlock()
|
||||
|
||||
const maxPatchBodyBytes int64 = 1 << 20
|
||||
@@ -249,7 +256,10 @@ func patchWlocResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Respons
|
||||
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 {
|
||||
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])
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -335,6 +345,7 @@ func startProxy(certPEM, keyPEM []byte, lat, lon float64, enabled bool, accuracy
|
||||
stateMu.Lock()
|
||||
globalCACert = cert
|
||||
currentLat, currentLon, currentEnabled, currentAccuracy = lat, lon, enabled, accuracy
|
||||
currentMotionSimulationEnabled = motionEnabled
|
||||
stateMu.Unlock()
|
||||
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", proxyPort))
|
||||
|
||||
+136
-3
@@ -20,11 +20,17 @@ const (
|
||||
)
|
||||
|
||||
type wlocCoords struct {
|
||||
Latitude float64
|
||||
Longitude float64
|
||||
Accuracy int
|
||||
Latitude float64
|
||||
Longitude float64
|
||||
Accuracy int
|
||||
MotionSimulationEnabled bool
|
||||
}
|
||||
|
||||
const (
|
||||
motionActivityType = 63
|
||||
motionActivityConfidence = 467
|
||||
)
|
||||
|
||||
type patchStats struct {
|
||||
WiFi int
|
||||
Cell int
|
||||
@@ -41,6 +47,8 @@ type wireField struct {
|
||||
|
||||
var macPattern = regexp.MustCompile(`^[0-9a-fA-F]{1,2}(:[0-9a-fA-F]{1,2}){5}$`)
|
||||
|
||||
var wlocMarker = []byte{0, 0, 0, 1, 0, 0}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
@@ -177,6 +185,7 @@ func patchLocation(loc []byte, c wlocCoords) ([]byte, bool, error) {
|
||||
lon := int64(math.Round(c.Longitude * 1e8))
|
||||
var out []byte
|
||||
changed := false
|
||||
hasMotionType, hasMotionConfidence := false, false
|
||||
for _, f := range fields {
|
||||
switch {
|
||||
case f.num == 1 && f.wireType == wireVarint:
|
||||
@@ -197,10 +206,34 @@ func patchLocation(loc []byte, c wlocCoords) ([]byte, bool, error) {
|
||||
changed = true
|
||||
}
|
||||
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:
|
||||
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
|
||||
}
|
||||
|
||||
@@ -314,6 +347,99 @@ func patchWlocPayload(payload []byte, c wlocCoords, st *patchStats) ([]byte, boo
|
||||
return out, changed, nil
|
||||
}
|
||||
|
||||
func parseARPCPayloadBounds(body []byte) (lengthOffset, payloadOffset, payloadEnd int, err error) {
|
||||
if len(body) < 2 {
|
||||
return 0, 0, 0, errors.New("ARPC body too short")
|
||||
}
|
||||
|
||||
offset := 2 // version
|
||||
for range 3 {
|
||||
if offset+2 > len(body) {
|
||||
return 0, 0, 0, errors.New("truncated ARPC string length")
|
||||
}
|
||||
length := int(binary.BigEndian.Uint16(body[offset : offset+2]))
|
||||
offset += 2
|
||||
if length > len(body)-offset {
|
||||
return 0, 0, 0, errors.New("truncated ARPC string")
|
||||
}
|
||||
offset += length
|
||||
}
|
||||
|
||||
const functionAndLengthBytes = 8
|
||||
if offset+functionAndLengthBytes > len(body) {
|
||||
return 0, 0, 0, errors.New("truncated ARPC header")
|
||||
}
|
||||
lengthOffset = offset + 4
|
||||
payloadOffset = lengthOffset + 4
|
||||
payloadLength := uint64(binary.BigEndian.Uint32(body[lengthOffset:payloadOffset]))
|
||||
if payloadLength == 0 || payloadLength > uint64(len(body)-payloadOffset) {
|
||||
return 0, 0, 0, errors.New("invalid ARPC payload length")
|
||||
}
|
||||
return lengthOffset, payloadOffset, payloadOffset + int(payloadLength), nil
|
||||
}
|
||||
|
||||
func patchARPCFrame(body []byte, c wlocCoords) ([]byte, patchStats, error) {
|
||||
lengthOffset, payloadOffset, payloadEnd, err := parseARPCPayloadBounds(body)
|
||||
if err != nil {
|
||||
return nil, patchStats{}, err
|
||||
}
|
||||
|
||||
var st patchStats
|
||||
payload := body[payloadOffset:payloadEnd]
|
||||
newPayload, changed, err := patchWlocPayload(payload, c, &st)
|
||||
if err != nil {
|
||||
return nil, patchStats{}, err
|
||||
}
|
||||
if !changed || bytes.Equal(newPayload, payload) {
|
||||
return nil, patchStats{}, errors.New("ARPC envelope has no patchable wloc payload")
|
||||
}
|
||||
|
||||
var lenBytes [4]byte
|
||||
binary.BigEndian.PutUint32(lenBytes[:], uint32(len(newPayload)))
|
||||
out := append(cloneBytes(body[:lengthOffset]), lenBytes[:]...)
|
||||
out = append(out, newPayload...)
|
||||
out = append(out, body[payloadEnd:]...)
|
||||
return out, st, nil
|
||||
}
|
||||
|
||||
func patchMarkerFrame(body []byte, c wlocCoords) ([]byte, patchStats, error) {
|
||||
markerOffset := bytes.Index(body, wlocMarker)
|
||||
if markerOffset < 0 {
|
||||
return nil, patchStats{}, errors.New("wloc marker not found")
|
||||
}
|
||||
|
||||
lengthOffset := markerOffset + len(wlocMarker)
|
||||
payloadOffset := lengthOffset + 2
|
||||
if payloadOffset > len(body) {
|
||||
return nil, patchStats{}, errors.New("truncated marker frame")
|
||||
}
|
||||
payloadLength := int(binary.BigEndian.Uint16(body[lengthOffset:payloadOffset]))
|
||||
if payloadLength == 0 || payloadLength > len(body)-payloadOffset {
|
||||
return nil, patchStats{}, errors.New("invalid marker payload length")
|
||||
}
|
||||
payloadEnd := payloadOffset + payloadLength
|
||||
|
||||
var st patchStats
|
||||
payload := body[payloadOffset:payloadEnd]
|
||||
newPayload, changed, err := patchWlocPayload(payload, c, &st)
|
||||
if err != nil {
|
||||
return nil, patchStats{}, err
|
||||
}
|
||||
if !changed || bytes.Equal(newPayload, payload) {
|
||||
return nil, patchStats{}, errors.New("marker frame has no patchable wloc payload")
|
||||
}
|
||||
if len(newPayload) > 65535 {
|
||||
return nil, patchStats{}, errors.New("patched marker payload too large")
|
||||
}
|
||||
|
||||
var lenBytes [2]byte
|
||||
binary.BigEndian.PutUint16(lenBytes[:], uint16(len(newPayload)))
|
||||
out := append(cloneBytes(body[:lengthOffset]), lenBytes[:]...)
|
||||
out = append(out, newPayload...)
|
||||
out = append(out, body[payloadEnd:]...)
|
||||
return out, st, nil
|
||||
}
|
||||
|
||||
func patchFrame(body []byte, offset int, c wlocCoords, st *patchStats) ([]byte, patchStats, error) {
|
||||
if len(body) < offset+10 {
|
||||
return nil, *st, fmt.Errorf("body too short: %d, base=%d", len(body), offset)
|
||||
@@ -352,6 +478,13 @@ func patchFrame(body []byte, offset int, c wlocCoords, st *patchStats) ([]byte,
|
||||
}
|
||||
|
||||
func patchWlocBody(body []byte, c wlocCoords) ([]byte, patchStats, error) {
|
||||
if out, st, err := patchARPCFrame(body, c); err == nil {
|
||||
return out, st, nil
|
||||
}
|
||||
if out, st, err := patchMarkerFrame(body, c); err == nil {
|
||||
return out, st, nil
|
||||
}
|
||||
|
||||
var st patchStats
|
||||
offsets := []int{0, 2, 4, 6, 8, 10, 12, 14, 16}
|
||||
seen := map[int]bool{}
|
||||
|
||||
+200
-7
@@ -25,6 +25,15 @@ func testLocation(lat, lon int64, accuracy uint64) []byte {
|
||||
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 {
|
||||
mac := []byte("aa:bb:cc:dd:ee:ff")
|
||||
var out []byte
|
||||
@@ -44,6 +53,29 @@ func testFrame(payload []byte) []byte {
|
||||
return out
|
||||
}
|
||||
|
||||
func testARPCFrame(payload, suffix []byte) ([]byte, int) {
|
||||
var out []byte
|
||||
var version [2]byte
|
||||
binary.BigEndian.PutUint16(version[:], 1)
|
||||
out = append(out, version[:]...)
|
||||
for _, value := range [][]byte{[]byte("zh_CN"), []byte("com.apple.locationd"), []byte("20A123")} {
|
||||
var length [2]byte
|
||||
binary.BigEndian.PutUint16(length[:], uint16(len(value)))
|
||||
out = append(out, length[:]...)
|
||||
out = append(out, value...)
|
||||
}
|
||||
var functionID [4]byte
|
||||
binary.BigEndian.PutUint32(functionID[:], 1)
|
||||
out = append(out, functionID[:]...)
|
||||
lengthOffset := len(out)
|
||||
var payloadLength [4]byte
|
||||
binary.BigEndian.PutUint32(payloadLength[:], uint32(len(payload)))
|
||||
out = append(out, payloadLength[:]...)
|
||||
out = append(out, payload...)
|
||||
out = append(out, suffix...)
|
||||
return out, lengthOffset
|
||||
}
|
||||
|
||||
func TestPatchWifiLocation(t *testing.T) {
|
||||
payload := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
|
||||
body := testFrame(payload)
|
||||
@@ -69,20 +101,181 @@ func TestPatchWifiLocation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPatchCellLocation(t *testing.T) {
|
||||
cell := writeLengthDelimited(5, testLocation(300, 400, 25))
|
||||
payload := writeLengthDelimited(22, cell)
|
||||
body := testFrame(payload)
|
||||
c := wlocCoords{Latitude: 22.544577, Longitude: 113.94114, Accuracy: 25}
|
||||
for _, field := range []int{22, 24} {
|
||||
t.Run(fmt.Sprintf("field_%d", field), func(t *testing.T) {
|
||||
cell := writeLengthDelimited(5, testLocation(300, 400, 25))
|
||||
payload := writeLengthDelimited(field, cell)
|
||||
body := testFrame(payload)
|
||||
c := wlocCoords{Latitude: 22.544577, Longitude: 113.94114, Accuracy: 25}
|
||||
|
||||
patched, stats, err := patchWlocBody(body, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.Cell != 1 || stats.Locations != 1 {
|
||||
t.Fatalf("unexpected stats: %+v", stats)
|
||||
}
|
||||
if bytes.Equal(patched, body) {
|
||||
t.Fatal("body was not patched")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
payload := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
|
||||
suffix := []byte{0xde, 0xad, 0xbe, 0xef}
|
||||
body, lengthOffset := testARPCFrame(payload, suffix)
|
||||
originalPrefix := cloneBytes(body[:lengthOffset])
|
||||
c := wlocCoords{Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50}
|
||||
|
||||
patched, stats, err := patchWlocBody(body, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.Cell != 1 || stats.Locations != 1 {
|
||||
if stats.WiFi != 1 || stats.Locations != 1 {
|
||||
t.Fatalf("unexpected stats: %+v", stats)
|
||||
}
|
||||
if bytes.Equal(patched, body) {
|
||||
t.Fatal("body was not patched")
|
||||
if !bytes.Equal(patched[:lengthOffset], originalPrefix) {
|
||||
t.Fatal("ARPC metadata changed")
|
||||
}
|
||||
newLength := int(binary.BigEndian.Uint32(patched[lengthOffset : lengthOffset+4]))
|
||||
if newLength == len(payload) {
|
||||
t.Fatal("ARPC payload length was not updated")
|
||||
}
|
||||
if !bytes.Equal(patched[lengthOffset+4+newLength:], suffix) {
|
||||
t.Fatal("ARPC suffix changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchARPCPayloadLargerThanUint16(t *testing.T) {
|
||||
padding := writeLengthDelimited(99, bytes.Repeat([]byte{0x7f}, 70_000))
|
||||
location := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
|
||||
payload := append(padding, location...)
|
||||
body, lengthOffset := testARPCFrame(payload, nil)
|
||||
c := wlocCoords{Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50}
|
||||
|
||||
patched, stats, err := patchWlocBody(body, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.WiFi != 1 || stats.Locations != 1 {
|
||||
t.Fatalf("unexpected stats: %+v", stats)
|
||||
}
|
||||
newLength := int(binary.BigEndian.Uint32(patched[lengthOffset : lengthOffset+4]))
|
||||
if newLength <= 65535 {
|
||||
t.Fatalf("expected 32-bit ARPC payload length, got %d", newLength)
|
||||
}
|
||||
if !bytes.Contains(patched[lengthOffset+4:lengthOffset+4+newLength], padding) {
|
||||
t.Fatal("unknown ARPC payload field changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchMarkerFramePreservesPrefixAndSuffix(t *testing.T) {
|
||||
payload := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
|
||||
prefix := []byte{0xaa, 0xbb, 0xcc}
|
||||
suffix := []byte{0xdd, 0xee}
|
||||
var length [2]byte
|
||||
binary.BigEndian.PutUint16(length[:], uint16(len(payload)))
|
||||
body := append(cloneBytes(prefix), wlocMarker...)
|
||||
body = append(body, length[:]...)
|
||||
body = append(body, payload...)
|
||||
body = append(body, suffix...)
|
||||
c := wlocCoords{Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50}
|
||||
|
||||
patched, stats, err := patchWlocBody(body, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.WiFi != 1 || stats.Locations != 1 {
|
||||
t.Fatalf("unexpected stats: %+v", stats)
|
||||
}
|
||||
lengthOffset := len(prefix) + len(wlocMarker)
|
||||
newLength := int(binary.BigEndian.Uint16(patched[lengthOffset : lengthOffset+2]))
|
||||
if newLength == len(payload) {
|
||||
t.Fatal("marker payload length was not updated")
|
||||
}
|
||||
if !bytes.Equal(patched[:len(prefix)], prefix) {
|
||||
t.Fatal("marker prefix changed")
|
||||
}
|
||||
if !bytes.Equal(patched[lengthOffset+2+newLength:], suffix) {
|
||||
t.Fatal("marker suffix changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchBareWlocPayload(t *testing.T) {
|
||||
body := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
|
||||
c := wlocCoords{Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50}
|
||||
|
||||
patched, stats, err := patchWlocBody(body, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.WiFi != 1 || stats.Locations != 1 {
|
||||
t.Fatalf("unexpected stats: %+v", stats)
|
||||
}
|
||||
if len(patched) > 0 && bytes.HasPrefix(patched, []byte{0, 1, 0, 0}) {
|
||||
t.Fatal("bare payload was unexpectedly wrapped")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-2
@@ -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
|
||||
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)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](docs/CHANGELOG.md)
|
||||
|
||||
[Features](#feature-overview) ·
|
||||
[How It Works](#how-it-works) ·
|
||||
@@ -443,10 +446,17 @@ guarantee for every app or release.
|
||||
The core location-response handling approach, Go implementation, and third-party modules are based on:
|
||||
|
||||
- [Yu9191/wloc](https://github.com/Yu9191/wloc)
|
||||
- [ios-location-spoofer](https://github.com/mekos2772/ios-location-spoofer)
|
||||
|
||||
Community link:
|
||||
Thanks to the following LINUX DO users for their contributions:
|
||||
|
||||
- Bug fixes: [Chen Ze](https://linux.do/u/lixiaobaivv)
|
||||
- Ideas and suggestions: [Alex](https://linux.do/u/_alex), [ye4241](https://linux.do/u/ye4241)
|
||||
|
||||
Links:
|
||||
|
||||
- [LINUX DO](https://linux.do/)
|
||||
- [iOS-Location-Spoofer-Web](https://github.com/akudamatata/iOS-Location-Spoofer-Web)
|
||||
|
||||
Thanks to the open-source contributors working on iOS location-service research, network proxies, and mobile testing
|
||||
tools.
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
项目通过本机代理或第三方代理客户端,对 Apple 定位服务的指定响应进行测试环境模拟,帮助开发者验证应用在
|
||||
不同地理位置和定位场景下的行为。
|
||||
|
||||
> ⚠️ **iOS 27 beta 6 起,系统已禁止对 `gs-loc.apple.com` 进行 MITM 拦截。** 目前该版本及之后的 beta 版本暂时无法使用本项目,等待后续适配方案。
|
||||
|
||||
[](project.yml)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](docs/CHANGELOG.md)
|
||||
|
||||
[功能概览](#功能概览) ·
|
||||
[工作原理](#工作原理) ·
|
||||
@@ -431,9 +433,16 @@ GitHub Issue Form 中的“App 生成的诊断报告”字段与 App 复制内
|
||||
核心定位响应处理思路、Go 实现和第三方模块参考自:
|
||||
|
||||
- [Yu9191/wloc](https://github.com/Yu9191/wloc)
|
||||
- [ios-location-spoofer](https://github.com/mekos2772/ios-location-spoofer)
|
||||
|
||||
感谢以下 LINUX DO 用户对项目的贡献:
|
||||
|
||||
- 功能修复:[陈泽](https://linux.do/u/lixiaobaivv)
|
||||
- 思路及建议:[Alex](https://linux.do/u/_alex)、[ye4241](https://linux.do/u/ye4241)
|
||||
|
||||
友链:
|
||||
|
||||
- [LINUX DO](https://linux.do/)
|
||||
- [iOS-Location-Spoofer-Web](https://github.com/akudamatata/iOS-Location-Spoofer-Web)
|
||||
|
||||
感谢开源社区中参与 iOS 定位服务研究、网络代理和移动端测试工具建设的贡献者。
|
||||
|
||||
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 {
|
||||
case activation
|
||||
case deactivation
|
||||
|
||||
@@ -23,7 +23,7 @@ struct AppRemoteConfiguration: Decodable, Equatable {
|
||||
let communityPromptClients: [String]
|
||||
|
||||
static let fallback = AppRemoteConfiguration(
|
||||
latestVersion: "1.0.3",
|
||||
latestVersion: "1.0.5",
|
||||
minimumSupportedVersion: "1.0.0",
|
||||
communityPromptClients: [
|
||||
ThirdPartyProxyClient.surge.rawValue,
|
||||
@@ -107,87 +107,99 @@ final class AppRemoteConfigurationStore: ObservableObject {
|
||||
}
|
||||
|
||||
enum AppRemoteConfigurationService {
|
||||
static let configurationURL = URL(
|
||||
string: "https://raw.githubusercontent.com/xweiba/location-spoofer/main/version.txt"
|
||||
)!
|
||||
static let configurationURLs = [
|
||||
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(
|
||||
string: "https://github.com/xweiba/location-spoofer/releases/latest"
|
||||
)!
|
||||
|
||||
static func fetch() async -> AppRemoteConfiguration? {
|
||||
let sessionConfiguration = URLSessionConfiguration.ephemeral
|
||||
sessionConfiguration.timeoutIntervalForRequest = 1.5
|
||||
sessionConfiguration.timeoutIntervalForResource = 2
|
||||
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
sessionConfiguration.waitsForConnectivity = false
|
||||
let session = URLSession(configuration: sessionConfiguration)
|
||||
let session = makeSession()
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
var request = URLRequest(url: configurationURL)
|
||||
request.timeoutInterval = 1.5
|
||||
for url in configurationURLs {
|
||||
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.warning("APP", "Update", "版本配置请求返回非 200,继续使用内置配置")
|
||||
return nil
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
continue
|
||||
}
|
||||
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? {
|
||||
guard let url = URL(
|
||||
string: "https://raw.githubusercontent.com/xweiba/location-spoofer/main/docs/releases/v\(version).md"
|
||||
) else {
|
||||
return nil
|
||||
let session = makeSession()
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
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
|
||||
sessionConfiguration.timeoutIntervalForRequest = 1.5
|
||||
sessionConfiguration.timeoutIntervalForResource = 2
|
||||
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
sessionConfiguration.waitsForConnectivity = false
|
||||
let session = 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
|
||||
}
|
||||
return URLSession(configuration: sessionConfiguration)
|
||||
}
|
||||
|
||||
private static func releaseNotesSummary(_ markdown: String) -> String? {
|
||||
|
||||
@@ -6,6 +6,7 @@ struct ThirdPartyProxySettingsResponse: Decodable, Equatable {
|
||||
let latitude: Double?
|
||||
let accuracy: Int?
|
||||
let error: String?
|
||||
let motionSimulationEnabled: Bool?
|
||||
}
|
||||
|
||||
enum ThirdPartyProxyConnectionState: Equatable {
|
||||
@@ -35,6 +36,15 @@ enum ThirdPartyProxyError: LocalizedError, Equatable {
|
||||
return "第三方代理请求失败:\(message)"
|
||||
}
|
||||
}
|
||||
|
||||
var recoverySuggestion: String {
|
||||
return "检查模块、MITM、证书和代理/VPN连接"
|
||||
}
|
||||
|
||||
static func recoverySuggestion(for error: Error) -> String {
|
||||
(error as? Self)?.recoverySuggestion
|
||||
?? "检查模块、MITM、证书和代理/VPN连接"
|
||||
}
|
||||
}
|
||||
|
||||
protocol ThirdPartyProxyRequesting {
|
||||
@@ -46,7 +56,14 @@ extension URLSession: ThirdPartyProxyRequesting {}
|
||||
@MainActor
|
||||
final class ThirdPartyProxyManager: ObservableObject {
|
||||
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")!
|
||||
|
||||
@Published private(set) var connectionState: ThirdPartyProxyConnectionState = .unknown
|
||||
@@ -69,18 +86,13 @@ final class ThirdPartyProxyManager: ObservableObject {
|
||||
|
||||
func query() async throws -> ThirdPartyProxySettingsResponse {
|
||||
let response = try await perform(action: .query)
|
||||
if response.success,
|
||||
response.latitude != nil,
|
||||
response.longitude != nil {
|
||||
let active = try validatedQueryState(response)
|
||||
if active {
|
||||
activeSettings = response
|
||||
connectionState = .connected(active: true)
|
||||
} else if response.error?.contains("无已保存") == true {
|
||||
} else {
|
||||
activeSettings = nil
|
||||
connectionState = .connected(active: false)
|
||||
} else {
|
||||
let error = ThirdPartyProxyError.rejected(response.error ?? "第三方代理查询失败")
|
||||
connectionState = .failed(error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
return response
|
||||
}
|
||||
@@ -90,7 +102,8 @@ final class ThirdPartyProxyManager: ObservableObject {
|
||||
let response = try await perform(action: .save(
|
||||
latitude: wgs84.latitude,
|
||||
longitude: wgs84.longitude,
|
||||
accuracy: favorite.accuracy
|
||||
accuracy: favorite.accuracy,
|
||||
randomRadius: RandomRadiusStore.shared.isEnabled ? RandomRadiusStore.shared.radius : 0
|
||||
))
|
||||
guard response.success else {
|
||||
throw ThirdPartyProxyError.rejected(response.error ?? "第三方代理拒绝保存坐标")
|
||||
@@ -121,9 +134,21 @@ final class ThirdPartyProxyManager: ObservableObject {
|
||||
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 {
|
||||
case query
|
||||
case save(latitude: Double, longitude: Double, accuracy: Int)
|
||||
case save(latitude: Double, longitude: Double, accuracy: Int, randomRadius: Double)
|
||||
case clear
|
||||
}
|
||||
|
||||
@@ -140,11 +165,12 @@ final class ThirdPartyProxyManager: ObservableObject {
|
||||
components.queryItems = [URLQueryItem(name: "action", value: "query")]
|
||||
case .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 = [
|
||||
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: "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 }
|
||||
@@ -211,21 +237,23 @@ enum ThirdPartyProxyClient: String, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
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 {
|
||||
case .surge, .egern:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.sgmodule"
|
||||
case .quantumultX:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.conf"
|
||||
case .loon:
|
||||
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"
|
||||
case .surge, .egern: fileName = "wloc.sgmodule"
|
||||
case .quantumultX: fileName = "wloc.conf"
|
||||
case .loon: fileName = "wloc.lpx"
|
||||
case .stash: fileName = "wloc.stoverride"
|
||||
case .shadowrocket: fileName = "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? {
|
||||
|
||||
@@ -62,11 +62,23 @@ final class AppRemoteConfigurationTests: XCTestCase {
|
||||
func testFallbackMatchesCurrentProjectPolicy() {
|
||||
let configuration = AppRemoteConfiguration.fallback
|
||||
|
||||
XCTAssertEqual(configuration.latestVersion, "1.0.3")
|
||||
XCTAssertEqual(configuration.latestVersion, "1.0.5")
|
||||
XCTAssertEqual(configuration.minimumSupportedVersion, "1.0.0")
|
||||
XCTAssertFalse(configuration.requestsCommunityPrompt(for: .shadowrocket))
|
||||
for client in ThirdPartyProxyClient.allCases where client != .shadowrocket {
|
||||
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))
|
||||
}
|
||||
|
||||
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 {
|
||||
let requester = FakeThirdPartyRequester(body: #"{"success":true,"longitude":1,"latitude":2,"accuracy":25}"#)
|
||||
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)
|
||||
XCTAssertTrue(ThirdPartyProxyClient.surge.verificationText?.contains("尚未验证") == true)
|
||||
XCTAssertEqual(ThirdPartyProxyClient.egern.subscriptionURL, ThirdPartyProxyClient.surge.subscriptionURL)
|
||||
XCTAssertTrue(ThirdPartyProxyClient.stash.subscriptionURL.absoluteString.hasSuffix("/modules/wloc.stoverride"))
|
||||
XCTAssertTrue(ThirdPartyProxyClient.shadowrocket.subscriptionURL.absoluteString.hasSuffix("/modules/wloc.module"))
|
||||
XCTAssertTrue(ThirdPartyProxyClient.stash.subscriptionURL.absoluteString.hasPrefix(
|
||||
"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.surge.launchURL?.scheme, "surge")
|
||||
XCTAssertEqual(ThirdPartyProxyClient.quantumultX.launchURL?.scheme, "quantumult-x")
|
||||
@@ -95,6 +172,7 @@ final class ThirdPartyProxyManagerTests: XCTestCase {
|
||||
private final class FakeThirdPartyRequester: ThirdPartyProxyRequesting {
|
||||
private let data: Data
|
||||
private(set) var lastURL: URL?
|
||||
private(set) var requestedURLs: [URL] = []
|
||||
|
||||
init(body: String) {
|
||||
data = Data(body.utf8)
|
||||
@@ -102,6 +180,7 @@ private final class FakeThirdPartyRequester: ThirdPartyProxyRequesting {
|
||||
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
lastURL = request.url
|
||||
requestedURLs.append(request.url!)
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!,
|
||||
statusCode: 200,
|
||||
|
||||
@@ -9,7 +9,6 @@ MANAGER="$ROOT/Shared/ThirdPartyProxyManager.swift"
|
||||
CONTENT="$ROOT/App/ContentView.swift"
|
||||
SETUP="$ROOT/App/FirstSetupView.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 "第三方代理模式"' "$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" \
|
||||
|| fail "client integration guidance must document the clear action"
|
||||
|
||||
for file in wloc.module wloc.sgmodule wloc.conf wloc.lpx wloc.stoverride; do
|
||||
test -s "$MODULES/$file" || fail "missing bundled module: $file"
|
||||
done
|
||||
|
||||
grep -q 'Yu9191/wloc/refs/heads/main/modules' "$MANAGER" \
|
||||
|| fail "third-party subscription must point at upstream Yu9191 modules"
|
||||
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 'shadowrocket://' "$MANAGER" || fail "Shadowrocket launch URL is missing"
|
||||
for scheme in surge quantumult-x loon stash egern; do
|
||||
grep -q "${scheme}://" "$MANAGER" || fail "$scheme launch URL is missing"
|
||||
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 'HTTPS 解密' "$SETUP" || fail "Shadowrocket HTTPS decryption guidance is missing"
|
||||
! 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" \
|
||||
|| fail "third-party connection failure must not use the generic failure alert"
|
||||
grep -q 'let response = try await thirdPartyProxy.query()' "$SETUP" \
|
||||
|| fail "the import page must query the third-party module"
|
||||
grep -A15 'let response = try await thirdPartyProxy.query()' "$SETUP" | grep -q 'onComplete()' \
|
||||
|| fail "the import page must validate the save/query endpoint"
|
||||
grep -A20 'let response = try await thirdPartyProxy.query()' "$SETUP" | grep -q 'onComplete()' \
|
||||
|| 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" \
|
||||
|| fail "a successful third-party test must not leave a separate completion state"
|
||||
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(.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 'MARKETING_VERSION: "1.0.3"' "$ROOT/project.yml" || fail "marketing version must be 1.0.3"
|
||||
grep -q 'CURRENT_PROJECT_VERSION: "4"' "$ROOT/project.yml" || fail "build version must be 4"
|
||||
grep -q 'MARKETING_VERSION: "1.0.6"' "$ROOT/project.yml" || fail "marketing version must be 1.0.6"
|
||||
grep -q 'CURRENT_PROJECT_VERSION: "7"' "$ROOT/project.yml" || fail "build version must be 7"
|
||||
|
||||
echo "PASS: third-party proxy mode contract"
|
||||
|
||||
@@ -25,7 +25,7 @@ import sys
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
config = json.load(handle)
|
||||
|
||||
assert config["latestVersion"] == "1.0.3"
|
||||
assert config["latestVersion"] == "1.0.5"
|
||||
assert config["minimumSupportedVersion"] == "1.0.0"
|
||||
assert "shadowrocket" not in 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"
|
||||
grep -q 'timeoutIntervalForResource = 2' "$CONFIG" \
|
||||
|| 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" \
|
||||
|| fail "the client must not impose a remote configuration file-size limit"
|
||||
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"
|
||||
! grep -A90 'private func bootstrap() async' "$CONTENT" | grep -q 'fetch()' \
|
||||
|| 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" \
|
||||
|| fail "non-Shadowrocket success must offer community contribution"
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
## 已发布
|
||||
|
||||
- [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.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.1](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.1) — 2026-08-06
|
||||
|
||||
@@ -46,7 +46,7 @@ iOS 版本:
|
||||
|
||||
步骤 2:
|
||||
- 截图: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
|
||||
snapshots from [Yu9191/wloc](https://github.com/Yu9191/wloc). They are retained
|
||||
in the App bundle for release provenance and offline inspection. The setup UI
|
||||
copies the official subscription URL instead of exporting these files.
|
||||
Third-party proxy mode (Surge / Quantumult X / Loon / Shadowrocket / Stash /
|
||||
Egern) now relies entirely on the upstream
|
||||
[Yu9191/wloc](https://github.com/Yu9191/wloc) modules. The App no longer
|
||||
maintains or ships project-owned module/script copies, so it never drifts
|
||||
from the upstream protocol.
|
||||
|
||||
- Upstream commit: `eec07a8dc8de6dbaee8eac1fb376e4d03020154a`
|
||||
- Snapshot date: 2026-08-06
|
||||
- Source directory: `modules/`
|
||||
## Subscription addresses
|
||||
|
||||
| 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.sgmodule` | Surge and Egern |
|
||||
@@ -17,15 +24,17 @@ copies the official subscription URL instead of exporting these files.
|
||||
| `wloc.lpx` | Loon |
|
||||
| `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
|
||||
bb5e17b60027704971660b0ea2df3560ceff973c27d43e7f2c2c18b48d368ac6 wloc.conf
|
||||
1fb451616fb17242849f72490f016afcdb8aa81a0b086f6dd5f94e1af3d58ee1 wloc.lpx
|
||||
97cab104056428aa0e90521c3bf2646e9739b0b4c83272b31790f99584bca89e wloc.module
|
||||
5d6b82c31316f4a7be65e3b8d2335f4338e01af98e262948118eefe63abf7034 wloc.sgmodule
|
||||
cb06593752db8b223dfa5cd1cbd089115fe3a541f5c8532491615923e83df2cb wloc.stoverride
|
||||
```
|
||||
## Script protocol
|
||||
|
||||
The official subscription URLs are the setup UI's import path. Egern reuses the
|
||||
Surge module. Stash imports `.stoverride` directly.
|
||||
The upstream `wloc.js` patches Apple WLOC responses and reads coordinates from
|
||||
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,29 @@
|
||||
# v1.0.4
|
||||
|
||||
发布日期:2026-08-09
|
||||
|
||||
## 主要更新
|
||||
|
||||
- 增加结构化 ARPC 响应解析,修改定位数据后会正确更新 32 位 payload 长度。
|
||||
- 完善 marker、synthetic 和 bare protobuf 响应兼容,保留原始封装前后缀及未知字段。
|
||||
- 补充 CellTower 字段 22/24、ARPC 大 payload 和多响应格式回归测试。
|
||||
|
||||
## 文档与致谢
|
||||
|
||||
- 整理项目参考、社区贡献和友链结构。
|
||||
- 补充相关定位服务研究项目致谢。
|
||||
|
||||
## 兼容性说明
|
||||
|
||||
- WiFi 和 CellTower 坐标替换逻辑保持不变。
|
||||
- 无法识别的新响应封装会回退原有检测路径,不会截断响应。
|
||||
- 本版本未写入语义尚未确认的运动状态字段。
|
||||
|
||||
## 自签安装
|
||||
|
||||
- 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.3..v1.0.4 -->
|
||||
@@ -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:
|
||||
base:
|
||||
SWIFT_VERSION: "5.9"
|
||||
MARKETING_VERSION: "1.0.3"
|
||||
CURRENT_PROJECT_VERSION: "4"
|
||||
MARKETING_VERSION: "1.0.6"
|
||||
CURRENT_PROJECT_VERSION: "7"
|
||||
CODE_SIGN_STYLE: Manual
|
||||
CODE_SIGNING_ALLOWED: "NO"
|
||||
CODE_SIGNING_REQUIRED: "NO"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"latestVersion": "1.0.3",
|
||||
"latestVersion": "1.0.6",
|
||||
"minimumSupportedVersion": "1.0.0",
|
||||
"communityPromptClients": ["surge", "quantumultX", "loon", "stash", "egern"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user