15 Commits
Author SHA1 Message Date
xweiba eadeca636d chore: prepare v1.0.6 release 2026-09-01 18:17:34 +08:00
xweiba b530d52add feat: drop script version detection; split motion vs perturbation by mode
Third-party mode no longer probes /wloc-settings/version or capability
metadata since the upstream scripts are unmaintained. Settings now show:
- APP mode: motion-state simulation toggle only
- third-party mode: random-perturbation (randomRadius) toggle only

save() sends randomRadius so upstream wloc.js applies the offset.
2026-09-01 18:10:01 +08:00
xweiba 135e26ea7a feat: point third-party mode at upstream Yu9191 modules
- subscription URLs now resolve directly to upstream modules (gh-proxy
  mirror / raw), dropping project-owned module and script copies
- remove Resources/ThirdPartyProxyModules and ThirdParty/WlocScripts,
  their pbxproj references, and the contract-test module checks
- third-party save sends lon/lat/acc matching the upstream settings
  script; motion-state simulation is unavailable (upstream lacks it)
- surface the iOS 27 beta 6 gs-loc MITM limitation in the app setup and
  README (zh/en)
2026-09-01 17:57:27 +08:00
xweiba d35d5c788a feat: vendor upstream modules verbatim, mirror via gh-proxy
All five module files now match Yu9191/wloc exactly (author, description,
icon, wloc-settings/save pattern, argument config). The only difference is
the mirror variant wraps GitHub Raw URLs in gh-proxy for regions where raw
is unreachable.
2026-09-01 17:42:34 +08:00
xweiba cce2aef58f feat: wire randomRadius perturbation argument into all modules
The upstream wloc.js already applies randomRadius. Surf/Loon/Stash/
Shadowrocket modules now pass the perturbation argument through so users
can configure it in the proxy client UI (QX rewrite cannot carry args).
2026-09-01 17:41:23 +08:00
xweiba 9cea68392e fix: point third-party modules at upstream wloc scripts
Modules now load wloc.js and wloc-settings.js directly from Yu9191/wloc
(raw for direct, gh-proxy for the mirror path) instead of the project's
own dist copies, so proxy clients run the battle-tested upstream scripts.
The Shadowrocket module also carries the upstream argument config and icon.
2026-09-01 17:40:26 +08:00
xweiba fb0ca961a4 fix: sync third-party wloc scripts with upstream
Vendor the Yu9191/wloc dist scripts so the third-party modules run the
battle-tested response/settings handlers instead of the rewritten ones.
The upstream scripts read the wloc_settings key, module $argument, and
handle the save/query/clear settings endpoints.
2026-09-01 17:33:15 +08:00
xweiba a666f177e6 fix: intercept gsp-ssl.ls.apple.com and bluedot wloc endpoints
Devices in China can send clls/wloc requests to gsp-ssl.ls.apple.com or
bluedot.is.autonavi.com instead of gs-loc.apple.com, so the third-party
modules only MITM'd gs-loc hosts and silently failed to spoof location.

- Add gsp-ssl.ls.apple.com, bluedot.is.autonavi.com and
  bluedot.is.autonavi.com.gds.alibabadns.com to all 10 module files
  (pattern + MITM hostname) and to the built-in proxy isWlocHost
- Update app interceptionHostnames and the copy-hostnames guidance
- Bump module subscription version to 1.0.1 to bust proxy client cache
2026-09-01 17:08:34 +08:00
xweiba a6b9e8056b chore: prepare v1.0.5 release 2026-08-10 16:19:56 +08:00
xweiba 9510d9aa42 fix: bust third-party module cache 2026-08-10 13:39:58 +08:00
xweiba 41dfe7a8ed feat: unify app and third-party wloc features 2026-08-10 13:35:47 +08:00
xweiba 27af50375d chore: prepare v1.0.4 release 2026-08-09 18:36:02 +08:00
xweiba a2f46dd166 docs: refine acknowledgements and links 2026-08-09 17:59:21 +08:00
xweiba b0d2f0e8e4 feat: improve wloc response compatibility 2026-08-09 16:23:41 +08:00
xweiba 7cf38363cf docs: add community acknowledgements 2026-08-09 16:00:37 +08:00
35 changed files with 1135 additions and 267 deletions
+3
View File
@@ -2,6 +2,9 @@
.superpowers/
build/
dist/
!ThirdParty/WlocScripts/dist/
!ThirdParty/WlocScripts/dist/**
node_modules/
DerivedData/
xcuserdata/
*.xcuserstate
+47 -17
View File
@@ -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.com3 打开证书设置。"
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
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
[![iOS 15+](https://img.shields.io/badge/iOS-15%2B-111111?logo=apple)](project.yml)
[![Swift 5.9](https://img.shields.io/badge/Swift-5.9-F05138)](project.yml)
[![Go 1.23+](https://img.shields.io/badge/Go-1.23%2B-00ADD8?logo=go)](Core/go.mod)
[![Version](https://img.shields.io/badge/version-v1.0.3-2563EB)](docs/CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-v1.0.5-2563EB)](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.
+10 -1
View File
@@ -9,10 +9,12 @@
项目通过本机代理或第三方代理客户端,对 Apple 定位服务的指定响应进行测试环境模拟,帮助开发者验证应用在
不同地理位置和定位场景下的行为。
> ⚠️ **iOS 27 beta 6 起,系统已禁止对 `gs-loc.apple.com` 进行 MITM 拦截。** 目前该版本及之后的 beta 版本暂时无法使用本项目,等待后续适配方案。
[![iOS 15+](https://img.shields.io/badge/iOS-15%2B-111111?logo=apple)](project.yml)
[![Swift 5.9](https://img.shields.io/badge/Swift-5.9-F05138)](project.yml)
[![Go 1.23+](https://img.shields.io/badge/Go-1.23%2B-00ADD8?logo=go)](Core/go.mod)
[![Version](https://img.shields.io/badge/version-v1.0.3-2563EB)](docs/CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-v1.0.5-2563EB)](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 定位服务研究、网络代理和移动端测试工具建设的贡献者。
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
-19
View File
@@ -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
+74
View File
@@ -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
+75 -63
View File
@@ -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? {
+53 -25
View File
@@ -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,
+22 -10
View File
@@ -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"
+11 -1
View File
@@ -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"
+3
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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

+29
View File
@@ -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 -->
+36
View File
@@ -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 -->
+31
View File
@@ -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
View File
@@ -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
View File
@@ -1,5 +1,5 @@
{
"latestVersion": "1.0.3",
"latestVersion": "1.0.6",
"minimumSupportedVersion": "1.0.0",
"communityPromptClients": ["surge", "quantumultX", "loon", "stash", "egern"]
}