mirror of
https://github.com/xweiba/location-spoofer.git
synced 2026-09-22 14:50:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6b9e8056b | ||
|
|
9510d9aa42 | ||
|
|
41dfe7a8ed |
@@ -2,6 +2,9 @@
|
||||
.superpowers/
|
||||
build/
|
||||
dist/
|
||||
!ThirdParty/WlocScripts/dist/
|
||||
!ThirdParty/WlocScripts/dist/**
|
||||
node_modules/
|
||||
DerivedData/
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
|
||||
+38
-17
@@ -52,6 +52,7 @@ struct FirstSetupView: 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
|
||||
@State private var copiedSubscriptionURL = false
|
||||
@State private var copiedMITMHostname = false
|
||||
@State private var screenshotPreview: SetupScreenshotPreview?
|
||||
@@ -533,10 +534,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("配置时请使用 gs-loc.apple.com 和 gs-loc-cn.apple.com 两个域名。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
mitmHostnameCopyButton
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,21 +564,14 @@ struct FirstSetupView: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
instructionRow(1, "进入“配置 → 本地文件”,找到带黄点的配置,点击右侧 i 图标。")
|
||||
instructionRow(2, "进入“HTTPS 解密”,开启解密开关。")
|
||||
instructionRow(3, "在域名列表中添加 gs-loc.apple.com。")
|
||||
instructionRow(3, "在域名列表中添加 gs-loc.apple.com 和 gs-loc-cn.apple.com。")
|
||||
setupScreenshot(
|
||||
assetName: "ShadowrocketHTTPSDecryption",
|
||||
title: "配置 HTTPS 解密",
|
||||
caption: "1 开启 HTTPS 解密,2 添加 gs-loc.apple.com,3 打开证书设置。"
|
||||
caption: "1 开启 HTTPS 解密,2 添加 gs-loc.apple.com 和 gs-loc-cn.apple.com,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 +596,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)")
|
||||
@@ -818,12 +833,17 @@ struct FirstSetupView: View {
|
||||
Task { @MainActor in
|
||||
defer { isVerifying = false }
|
||||
do {
|
||||
let response = try await thirdPartyProxy.query()
|
||||
let response = try await thirdPartyProxy.validateConnection()
|
||||
let advancedFeaturesAvailable = await thirdPartyProxy.refreshAdvancedFeatureAvailability()
|
||||
if !advancedFeaturesAvailable {
|
||||
motionSimulation.setEnabled(false)
|
||||
}
|
||||
let elapsedMilliseconds = Int(Date().timeIntervalSince(startedAt) * 1_000)
|
||||
RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方代理连接检测通过", details: [
|
||||
"当前客户端": client.name,
|
||||
"请求动作": "WLOC query",
|
||||
"连接状态": response.latitude == nil || response.longitude == nil ? "已连接,无保存坐标" : "已连接,有保存坐标",
|
||||
"运动状态模拟": advancedFeaturesAvailable ? "支持" : "不支持,已关闭",
|
||||
"耗时毫秒": String(elapsedMilliseconds)
|
||||
])
|
||||
onComplete()
|
||||
@@ -842,13 +862,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 +877,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() }
|
||||
|
||||
+221
-5
@@ -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,19 @@ 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 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 +84,18 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("定位模拟") {
|
||||
Toggle("运动状态模拟", isOn: motionSimulationBinding)
|
||||
.disabled(
|
||||
modeOperationRunning ||
|
||||
actions.state.isBusy ||
|
||||
thirdPartyProxy.isRequesting
|
||||
)
|
||||
Text("实验性功能,默认关闭。开启后会同时模拟定位响应中的运动状态。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if runtimeMode.mode == .thirdParty {
|
||||
thirdPartyConfigurationSection
|
||||
} else {
|
||||
@@ -99,6 +133,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 +242,9 @@ struct SettingsView: View {
|
||||
} message: {
|
||||
Text(proxyOperationError)
|
||||
}
|
||||
.alert(item: $updateCheckResult) { result in
|
||||
updateCheckAlert(for: result)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"重置证书?",
|
||||
isPresented: $showCertificateResetConfirmation,
|
||||
@@ -207,6 +257,16 @@ struct SettingsView: View {
|
||||
} message: {
|
||||
Text("当前虚拟定位和本地代理将停止。App 会删除钥匙串中的设备 CA、立即生成新证书,并打开安装与信任引导。你还需要前往 iOS「设置 → 通用 → VPN 与设备管理」手动删除旧证书,然后重新下载安装并完全信任新证书。")
|
||||
}
|
||||
.task(id: runtimeMode.mode) {
|
||||
guard runtimeMode.mode == .thirdParty, !modeOperationRunning else { return }
|
||||
if !(await thirdPartyProxy.refreshAdvancedFeatureAvailability()) {
|
||||
disableUnsupportedThirdPartyMotionSimulation()
|
||||
}
|
||||
}
|
||||
.onChange(of: thirdPartyProxy.moduleUpdateRecommended) { updateRecommended in
|
||||
guard updateRecommended else { return }
|
||||
disableUnsupportedThirdPartyMotionSimulation()
|
||||
}
|
||||
}
|
||||
|
||||
private func valueRow(_ title: String, value: String) -> some View {
|
||||
@@ -219,6 +279,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 +373,55 @@ struct SettingsView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var motionSimulationBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { motionSimulation.isEnabled },
|
||||
set: { enabled in
|
||||
if runtimeMode.mode == .localWiFi {
|
||||
proxy.applyMotionSimulation(enabled)
|
||||
return
|
||||
}
|
||||
guard thirdPartyProxy.activeSettings?.success == true else {
|
||||
guard enabled else {
|
||||
motionSimulation.setEnabled(false)
|
||||
return
|
||||
}
|
||||
modeOperationRunning = true
|
||||
Task { @MainActor in
|
||||
if await thirdPartyProxy.refreshAdvancedFeatureAvailability() {
|
||||
motionSimulation.setEnabled(true)
|
||||
} else {
|
||||
presentMotionSimulationModuleUpdateAlert()
|
||||
}
|
||||
modeOperationRunning = false
|
||||
}
|
||||
return
|
||||
}
|
||||
modeOperationRunning = true
|
||||
Task { @MainActor in
|
||||
do {
|
||||
_ = try await thirdPartyProxy.updateMotionSimulation(enabled)
|
||||
motionSimulation.setEnabled(enabled)
|
||||
} catch {
|
||||
RuntimeLogger.error(
|
||||
"APP",
|
||||
"ThirdPartyProxy",
|
||||
"同步运动状态设置失败",
|
||||
error: error,
|
||||
details: ["当前客户端": thirdPartyClient.selectedClient.name]
|
||||
)
|
||||
if error as? ThirdPartyProxyError == .moduleOutdated {
|
||||
presentMotionSimulationModuleUpdateAlert()
|
||||
} else {
|
||||
setup.requestThirdPartySetup(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
modeOperationRunning = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var thirdPartyConfigurationSection: some View {
|
||||
Section("第三方代理配置") {
|
||||
@@ -260,6 +434,20 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("使用国内镜像下载模块", isOn: Binding(
|
||||
get: { moduleSource.useMirror },
|
||||
set: { moduleSource.setUseMirror($0) }
|
||||
))
|
||||
Text("仅影响之后复制和重新导入的模块地址;已安装模块需要重新导入后切换来源。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if thirdPartyProxy.moduleUpdateRecommended {
|
||||
Text("当前模块版本较旧,基础坐标功能仍可继续使用。重新导入最新模块后可使用版本检测和运动状态模拟。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
if let verificationText = thirdPartyClient.selectedClient.verificationText {
|
||||
HStack {
|
||||
Text("验证状态")
|
||||
@@ -277,6 +465,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 +493,7 @@ struct SettingsView: View {
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text("复制模块订阅地址后,在对应代理客户端中添加模块/重写订阅,并启用 MITM。第三方客户端保存坐标后,即使关闭本 App,坐标仍由代理客户端持久化并继续生效。")
|
||||
Text("复制模块订阅地址后,在对应代理客户端中添加模块/重写订阅,并为 gs-loc.apple.com 和 gs-loc-cn.apple.com 启用 MITM。第三方客户端保存坐标后,即使关闭本 App,坐标仍由代理客户端持久化并继续生效。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -336,7 +531,7 @@ struct SettingsView: View {
|
||||
return """
|
||||
App 在设备本地运行一个代理服务器(127.0.0.1:8888)。
|
||||
|
||||
通过 WiFi 手动代理配置,让系统的定位请求(gs-loc.apple.com/clls/wloc)经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。
|
||||
通过 WiFi 手动代理配置,让系统发往 gs-loc.apple.com 和 gs-loc-cn.apple.com 的定位请求经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -353,7 +548,8 @@ struct SettingsView: View {
|
||||
runtimeMode.setMode(.thirdParty)
|
||||
if runtimeMode.isInitialized(.thirdParty) {
|
||||
do {
|
||||
_ = try await thirdPartyProxy.query()
|
||||
_ = try await thirdPartyProxy.validateConnection()
|
||||
refreshThirdPartyAdvancedFeatures()
|
||||
proxyOperationAlertTitle = "模式已切换"
|
||||
proxyOperationError = "第三方代理模式检测通过。请关闭 Wi-Fi 中的 127.0.0.1:8888 手动代理,避免双重拦截。"
|
||||
} catch {
|
||||
@@ -395,7 +591,8 @@ struct SettingsView: View {
|
||||
let startedAt = Date()
|
||||
Task { @MainActor in
|
||||
do {
|
||||
_ = try await thirdPartyProxy.query()
|
||||
_ = try await thirdPartyProxy.validateConnection()
|
||||
refreshThirdPartyAdvancedFeatures()
|
||||
RuntimeLogger.info("APP", "ThirdPartyProxy", "设置页第三方连接检测通过", details: [
|
||||
"当前客户端": client.name,
|
||||
"请求动作": "WLOC query",
|
||||
@@ -413,7 +610,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)
|
||||
@@ -421,6 +618,25 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshThirdPartyAdvancedFeatures() {
|
||||
Task { @MainActor in
|
||||
if !(await thirdPartyProxy.refreshAdvancedFeatureAvailability()) {
|
||||
disableUnsupportedThirdPartyMotionSimulation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func presentMotionSimulationModuleUpdateAlert() {
|
||||
disableUnsupportedThirdPartyMotionSimulation()
|
||||
proxyOperationAlertTitle = "无法开启运动状态模拟"
|
||||
proxyOperationError = "当前模块脚本不支持运动状态模拟,请重新导入最新模块脚本后再开启。基础坐标功能仍可继续使用。"
|
||||
}
|
||||
|
||||
private func disableUnsupportedThirdPartyMotionSimulation() {
|
||||
guard runtimeMode.mode == .thirdParty else { return }
|
||||
motionSimulation.setEnabled(false)
|
||||
}
|
||||
|
||||
private func openThirdPartySetup(for error: Error) {
|
||||
setup.requestThirdPartySetup(message: error.localizedDescription)
|
||||
dismiss()
|
||||
|
||||
+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
|
||||
|
||||
+17
-12
@@ -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
|
||||
@@ -94,10 +95,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 +222,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 +250,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 +330,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 +339,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))
|
||||
|
||||
+34
-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
|
||||
@@ -179,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:
|
||||
@@ -199,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -113,6 +122,66 @@ func TestPatchCellLocation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionSimulationDisabledPreservesFields(t *testing.T) {
|
||||
original := testLocationWithMotion(100, 200, 25, 7, 88)
|
||||
patched, changed, err := patchLocation(original, wlocCoords{
|
||||
Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatal("coordinates were not patched")
|
||||
}
|
||||
fields, err := parseFields(patched)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, field := range fields {
|
||||
if (field.num == 11 || field.num == 12) && !bytes.Contains(original, field.raw) {
|
||||
t.Fatalf("motion field %d changed while disabled", field.num)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionSimulationEnabledReplacesFields(t *testing.T) {
|
||||
original := testLocationWithMotion(100, 200, 25, 7, 88)
|
||||
patched, _, err := patchLocation(original, wlocCoords{
|
||||
Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50,
|
||||
MotionSimulationEnabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(patched, append(writeTag(11, wireVarint), writeVarint(motionActivityType)...)) {
|
||||
t.Fatal("motion activity type was not replaced")
|
||||
}
|
||||
if !bytes.Contains(patched, append(writeTag(12, wireVarint), writeVarint(motionActivityConfidence)...)) {
|
||||
t.Fatal("motion activity confidence was not replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionSimulationEnabledAddsMissingFields(t *testing.T) {
|
||||
patched, _, err := patchLocation(testLocation(100, 200, 25), wlocCoords{
|
||||
Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50,
|
||||
MotionSimulationEnabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fields, err := parseFields(patched)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counts := map[int]int{}
|
||||
for _, field := range fields {
|
||||
counts[field.num]++
|
||||
}
|
||||
if counts[11] != 1 || counts[12] != 1 {
|
||||
t.Fatalf("expected one inserted motion field each, got %+v", counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchARPCFramePreservesEnvelopeAndSuffix(t *testing.T) {
|
||||
payload := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
|
||||
suffix := []byte{0xde, 0xad, 0xbe, 0xef}
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ responses in a controlled test environment.
|
||||
[](project.yml)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](docs/CHANGELOG.md)
|
||||
|
||||
[Features](#feature-overview) ·
|
||||
[How It Works](#how-it-works) ·
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
[](project.yml)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](docs/CHANGELOG.md)
|
||||
|
||||
[功能概览](#功能概览) ·
|
||||
[工作原理](#工作原理) ·
|
||||
|
||||
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 67 KiB |
@@ -1,11 +1,11 @@
|
||||
#!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
|
||||
#!author=xweiba
|
||||
#!homepage=https://github.com/xweiba/location-spoofer
|
||||
|
||||
[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
|
||||
^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc url script-response-body https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js
|
||||
^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/(save|version) url script-echo-response https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js
|
||||
|
||||
[mitm]
|
||||
hostname = gs-loc.apple.com, gs-loc-cn.apple.com
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
#!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
|
||||
#!author=xweiba
|
||||
#!homepage=https://github.com/xweiba/location-spoofer
|
||||
#!openUrl=https://wloc-pages.pages.dev/
|
||||
|
||||
[Argument]
|
||||
@@ -12,8 +11,8 @@ 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
|
||||
http-response ^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc script-path=https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js, requires-body=true, binary-body-mode=true, timeout=30, tag=Apple WLOC
|
||||
http-request ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/(save|version) script-path=https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js, timeout=10, tag=WLOC Settings
|
||||
|
||||
[MITM]
|
||||
hostname = gs-loc.apple.com, gs-loc-cn.apple.com
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
#!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
|
||||
#!author=xweiba
|
||||
#!homepage=https://github.com/xweiba/location-spoofer
|
||||
#!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
|
||||
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://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js
|
||||
WLOC Settings = type=http-request,pattern=^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/(save|version),requires-body=0,max-size=0,timeout=10,script-path=https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js
|
||||
|
||||
[MITM]
|
||||
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
#!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
|
||||
#!author=xweiba
|
||||
#!homepage=https://github.com/xweiba/location-spoofer
|
||||
#!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
|
||||
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://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js
|
||||
WLOC Settings = type=http-request, pattern="^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/(save|version)", requires-body=0, max-size=0, timeout=10, script-path=https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js
|
||||
|
||||
[MITM]
|
||||
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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
|
||||
author: xweiba
|
||||
homepage: https://github.com/xweiba/location-spoofer
|
||||
category: Tools
|
||||
|
||||
http:
|
||||
@@ -17,8 +16,7 @@ http:
|
||||
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
|
||||
- match: ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/(save|version)
|
||||
name: WLOC.Settings
|
||||
type: request
|
||||
require-body: false
|
||||
@@ -26,8 +24,8 @@ http:
|
||||
|
||||
script-providers:
|
||||
WLOC.Location:
|
||||
url: https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc.js
|
||||
url: https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js
|
||||
interval: 86400
|
||||
WLOC.Settings:
|
||||
url: https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/dist/wloc-settings.js
|
||||
url: https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js
|
||||
interval: 86400
|
||||
|
||||
@@ -44,6 +44,50 @@ 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 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.4",
|
||||
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,22 @@ struct ThirdPartyProxySettingsResponse: Decodable, Equatable {
|
||||
let latitude: Double?
|
||||
let accuracy: Int?
|
||||
let error: String?
|
||||
let motionSimulationEnabled: Bool?
|
||||
}
|
||||
|
||||
struct ThirdPartyProxyVersionResponse: Decodable, Equatable {
|
||||
let success: Bool
|
||||
let moduleVersion: String
|
||||
let protocolVersion: Int
|
||||
let capabilities: Set<String>
|
||||
|
||||
static let requiredCapabilities: Set<String> = [
|
||||
"wifi", "cellTower", "arpc", "marker", "synthetic", "bare", "motionSimulation"
|
||||
]
|
||||
|
||||
var isCompatible: Bool {
|
||||
success && protocolVersion >= 1 && capabilities.isSuperset(of: Self.requiredCapabilities)
|
||||
}
|
||||
}
|
||||
|
||||
enum ThirdPartyProxyConnectionState: Equatable {
|
||||
@@ -20,6 +36,7 @@ enum ThirdPartyProxyError: LocalizedError, Equatable {
|
||||
case rejected(String)
|
||||
case coordinateMismatch
|
||||
case network(String)
|
||||
case moduleOutdated
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
@@ -33,8 +50,24 @@ enum ThirdPartyProxyError: LocalizedError, Equatable {
|
||||
return "第三方代理保存的坐标与当前选点不一致"
|
||||
case .network(let message):
|
||||
return "第三方代理请求失败:\(message)"
|
||||
case .moduleOutdated:
|
||||
return "模块版本过低,请重新导入模块"
|
||||
}
|
||||
}
|
||||
|
||||
var recoverySuggestion: String {
|
||||
switch self {
|
||||
case .moduleOutdated:
|
||||
return "删除旧模块后,重新复制并导入最新模块"
|
||||
default:
|
||||
return "检查模块、MITM、证书和代理/VPN连接"
|
||||
}
|
||||
}
|
||||
|
||||
static func recoverySuggestion(for error: Error) -> String {
|
||||
(error as? Self)?.recoverySuggestion
|
||||
?? "检查模块、MITM、证书和代理/VPN连接"
|
||||
}
|
||||
}
|
||||
|
||||
protocol ThirdPartyProxyRequesting {
|
||||
@@ -46,11 +79,17 @@ 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"
|
||||
]
|
||||
static let interceptionHostnamesText = interceptionHostnames.joined(separator: ", ")
|
||||
static let configurationEndpoint = URL(string: "https://gs-loc.apple.com/wloc-settings/save")!
|
||||
static let versionEndpoint = URL(string: "https://gs-loc.apple.com/wloc-settings/version")!
|
||||
|
||||
@Published private(set) var connectionState: ThirdPartyProxyConnectionState = .unknown
|
||||
@Published private(set) var activeSettings: ThirdPartyProxySettingsResponse?
|
||||
@Published private(set) var moduleUpdateRecommended = false
|
||||
@Published private(set) var isRequesting = false
|
||||
private let requester: any ThirdPartyProxyRequesting
|
||||
|
||||
@@ -69,18 +108,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 +124,8 @@ final class ThirdPartyProxyManager: ObservableObject {
|
||||
let response = try await perform(action: .save(
|
||||
latitude: wgs84.latitude,
|
||||
longitude: wgs84.longitude,
|
||||
accuracy: favorite.accuracy
|
||||
accuracy: favorite.accuracy,
|
||||
motionEnabled: MotionSimulationStore.shared.isEnabled
|
||||
))
|
||||
guard response.success else {
|
||||
throw ThirdPartyProxyError.rejected(response.error ?? "第三方代理拒绝保存坐标")
|
||||
@@ -111,6 +146,84 @@ final class ThirdPartyProxyManager: ObservableObject {
|
||||
return response
|
||||
}
|
||||
|
||||
func updateMotionSimulation(_ enabled: Bool) async throws -> ThirdPartyProxySettingsResponse {
|
||||
guard let current = activeSettings,
|
||||
let latitude = current.latitude,
|
||||
let longitude = current.longitude else {
|
||||
throw ThirdPartyProxyError.rejected("第三方虚拟定位尚未开启")
|
||||
}
|
||||
guard await refreshAdvancedFeatureAvailability() else {
|
||||
throw ThirdPartyProxyError.moduleOutdated
|
||||
}
|
||||
let response = try await perform(action: .save(
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
accuracy: current.accuracy ?? 25,
|
||||
motionEnabled: enabled
|
||||
))
|
||||
guard response.success else {
|
||||
throw ThirdPartyProxyError.rejected(response.error ?? "第三方代理拒绝更新运动状态")
|
||||
}
|
||||
activeSettings = response
|
||||
return response
|
||||
}
|
||||
|
||||
func validateConnection() async throws -> ThirdPartyProxySettingsResponse {
|
||||
try await query()
|
||||
}
|
||||
|
||||
func validateVersion() async throws -> ThirdPartyProxyVersionResponse {
|
||||
guard !isRequesting else {
|
||||
throw ThirdPartyProxyError.rejected("已有第三方代理请求正在执行")
|
||||
}
|
||||
isRequesting = true
|
||||
defer { isRequesting = false }
|
||||
|
||||
var request = URLRequest(url: Self.versionEndpoint)
|
||||
request.httpMethod = "GET"
|
||||
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||||
request.timeoutInterval = 8
|
||||
do {
|
||||
let (data, response) = try await requester.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200,
|
||||
let version = try? JSONDecoder().decode(ThirdPartyProxyVersionResponse.self, from: data),
|
||||
version.isCompatible else {
|
||||
throw ThirdPartyProxyError.moduleOutdated
|
||||
}
|
||||
RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方模块版本检测通过", details: [
|
||||
"模块版本": version.moduleVersion,
|
||||
"协议版本": String(version.protocolVersion),
|
||||
"能力": version.capabilities.sorted().joined(separator: ",")
|
||||
])
|
||||
return version
|
||||
} catch let error as ThirdPartyProxyError {
|
||||
throw error
|
||||
} catch {
|
||||
throw ThirdPartyProxyError.network(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func refreshAdvancedFeatureAvailability() async -> Bool {
|
||||
do {
|
||||
_ = try await validateVersion()
|
||||
moduleUpdateRecommended = false
|
||||
return true
|
||||
} catch {
|
||||
moduleUpdateRecommended = true
|
||||
RuntimeLogger.warning(
|
||||
"APP",
|
||||
"ThirdPartyProxy",
|
||||
"第三方模块不支持高级功能",
|
||||
details: [
|
||||
"版本检测": error.localizedDescription,
|
||||
"处理建议": "基础坐标功能可继续使用;更新模块后可使用运动状态模拟"
|
||||
]
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func clear() async throws {
|
||||
let response = try await perform(action: .clear)
|
||||
guard response.success else {
|
||||
@@ -121,9 +234,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, motionEnabled: Bool)
|
||||
case clear
|
||||
}
|
||||
|
||||
@@ -140,11 +265,15 @@ 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 motionEnabled):
|
||||
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: "motion",
|
||||
value: motionEnabled ? "1" : "0"
|
||||
)
|
||||
]
|
||||
}
|
||||
guard let url = components.url else { throw ThirdPartyProxyError.invalidResponse }
|
||||
@@ -177,6 +306,8 @@ final class ThirdPartyProxyManager: ObservableObject {
|
||||
}
|
||||
|
||||
enum ThirdPartyProxyClient: String, CaseIterable, Identifiable {
|
||||
static let moduleSubscriptionVersion = "1.0.0"
|
||||
|
||||
case shadowrocket
|
||||
case surge
|
||||
case quantumultX
|
||||
@@ -211,21 +342,28 @@ enum ThirdPartyProxyClient: String, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var subscriptionURL: URL {
|
||||
let url: String
|
||||
let directory = ThirdPartyModuleSourceStore.shared.useMirror
|
||||
? "Resources/ThirdPartyProxyModules"
|
||||
: "ThirdParty/WlocScripts/modules/direct"
|
||||
let prefix = ThirdPartyModuleSourceStore.shared.useMirror
|
||||
? "https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/"
|
||||
: "https://raw.githubusercontent.com/xweiba/location-spoofer/main/"
|
||||
switch self {
|
||||
case .surge, .egern:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.sgmodule"
|
||||
url = "\(prefix)\(directory)/wloc.sgmodule"
|
||||
case .quantumultX:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.conf"
|
||||
url = "\(prefix)\(directory)/wloc.conf"
|
||||
case .loon:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.lpx"
|
||||
url = "\(prefix)\(directory)/wloc.lpx"
|
||||
case .stash:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.stoverride"
|
||||
url = "\(prefix)\(directory)/wloc.stoverride"
|
||||
case .shadowrocket:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.module"
|
||||
url = "\(prefix)\(directory)/wloc.module"
|
||||
}
|
||||
return URL(string: url)!
|
||||
return URL(string: "\(url)?v=\(Self.moduleSubscriptionVersion)")!
|
||||
}
|
||||
|
||||
var launchURL: URL? {
|
||||
|
||||
@@ -62,11 +62,23 @@ final class AppRemoteConfigurationTests: XCTestCase {
|
||||
func testFallbackMatchesCurrentProjectPolicy() {
|
||||
let configuration = AppRemoteConfiguration.fallback
|
||||
|
||||
XCTAssertEqual(configuration.latestVersion, "1.0.4")
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,88 @@ final class ThirdPartyProxyManagerTests: XCTestCase {
|
||||
XCTAssertEqual(latitude, wgs84.latitude, accuracy: 0.000_000_01)
|
||||
XCTAssertEqual(longitude, wgs84.longitude, accuracy: 0.000_000_01)
|
||||
XCTAssertEqual(values["acc"], "20")
|
||||
XCTAssertEqual(values["motion"], "0")
|
||||
XCTAssertEqual(manager.connectionState, .connected(active: true))
|
||||
}
|
||||
|
||||
func testVersionEndpointRequiresProtocolAndCapabilities() async throws {
|
||||
let requester = FakeThirdPartyRequester(body: #"{"success":false,"error":"无已保存的坐标"}"#)
|
||||
let manager = ThirdPartyProxyManager(requester: requester)
|
||||
|
||||
let version = try await manager.validateVersion()
|
||||
|
||||
XCTAssertEqual(requester.lastURL?.path, "/wloc-settings/version")
|
||||
XCTAssertEqual(version.moduleVersion, "1.0.0")
|
||||
XCTAssertTrue(version.isCompatible)
|
||||
}
|
||||
|
||||
func testConnectionUsesLegacySaveQueryEndpoint() async throws {
|
||||
let requester = FakeThirdPartyRequester(body: #"{"success":false,"error":"无已保存的坐标"}"#)
|
||||
let manager = ThirdPartyProxyManager(requester: requester)
|
||||
|
||||
let response = try await manager.validateConnection()
|
||||
|
||||
XCTAssertFalse(response.success)
|
||||
XCTAssertFalse(manager.moduleUpdateRecommended)
|
||||
XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/save"])
|
||||
XCTAssertEqual(requester.requestedURLs.first?.query, "action=query")
|
||||
}
|
||||
|
||||
func testMissingVersionDisablesOnlyAdvancedFeatures() async {
|
||||
let requester = FakeThirdPartyRequester(
|
||||
body: #"{"success":false,"error":"无已保存的坐标"}"#,
|
||||
versionBody: "not-json"
|
||||
)
|
||||
let manager = ThirdPartyProxyManager(requester: requester)
|
||||
|
||||
let isAvailable = await manager.refreshAdvancedFeatureAvailability()
|
||||
|
||||
XCTAssertFalse(isAvailable)
|
||||
XCTAssertTrue(manager.moduleUpdateRecommended)
|
||||
XCTAssertEqual(manager.connectionState, .unknown)
|
||||
XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/version"])
|
||||
}
|
||||
|
||||
func testLegacyModuleCanStillSaveBasicCoordinates() async throws {
|
||||
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, versionBody: "not-json")
|
||||
let manager = ThirdPartyProxyManager(requester: requester)
|
||||
|
||||
let response = try await manager.save(favorite)
|
||||
|
||||
XCTAssertTrue(response.success)
|
||||
XCTAssertFalse(manager.moduleUpdateRecommended)
|
||||
XCTAssertEqual(manager.connectionState, .connected(active: true))
|
||||
XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/save"])
|
||||
XCTAssertNil(requester.requestedURLs.first?.query)
|
||||
}
|
||||
|
||||
func testBrokenSaveQueryFailsWithoutCheckingVersion() async {
|
||||
let requester = FakeThirdPartyRequester(body: "not-json")
|
||||
let manager = ThirdPartyProxyManager(requester: requester)
|
||||
|
||||
do {
|
||||
_ = try await manager.validateConnection()
|
||||
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 +144,29 @@ final class ThirdPartyProxyManagerTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
func testClientLinksUseOfficialUpstreamModulesAndVerificationLabels() {
|
||||
func testClientLinksUseProjectOwnedMirrorModulesAndVerificationLabels() {
|
||||
XCTAssertEqual(
|
||||
ThirdPartyProxyManager.interceptionHostnamesText,
|
||||
"gs-loc.apple.com, gs-loc-cn.apple.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/xweiba/location-spoofer/main/"
|
||||
))
|
||||
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/xweiba/location-spoofer/main/Resources/ThirdPartyProxyModules/wloc.stoverride")
|
||||
XCTAssertEqual(shadowrocketComponents?.path, "/https://raw.githubusercontent.com/xweiba/location-spoofer/main/Resources/ThirdPartyProxyModules/wloc.module")
|
||||
XCTAssertEqual(stashComponents?.queryItems?.first?.value, ThirdPartyProxyClient.moduleSubscriptionVersion)
|
||||
XCTAssertEqual(shadowrocketComponents?.queryItems?.first?.value, ThirdPartyProxyClient.moduleSubscriptionVersion)
|
||||
XCTAssertEqual(ThirdPartyProxyClient.shadowrocket.launchURL?.scheme, "shadowrocket")
|
||||
XCTAssertEqual(ThirdPartyProxyClient.surge.launchURL?.scheme, "surge")
|
||||
XCTAssertEqual(ThirdPartyProxyClient.quantumultX.launchURL?.scheme, "quantumult-x")
|
||||
@@ -94,20 +190,27 @@ final class ThirdPartyProxyManagerTests: XCTestCase {
|
||||
|
||||
private final class FakeThirdPartyRequester: ThirdPartyProxyRequesting {
|
||||
private let data: Data
|
||||
private let versionData: Data
|
||||
private(set) var lastURL: URL?
|
||||
private(set) var requestedURLs: [URL] = []
|
||||
|
||||
init(body: String) {
|
||||
init(
|
||||
body: String,
|
||||
versionBody: String = #"{"success":true,"moduleVersion":"1.0.0","protocolVersion":1,"capabilities":["wifi","cellTower","arpc","marker","synthetic","bare","motionSimulation"]}"#
|
||||
) {
|
||||
data = Data(body.utf8)
|
||||
versionData = Data(versionBody.utf8)
|
||||
}
|
||||
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
lastURL = request.url
|
||||
requestedURLs.append(request.url!)
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!,
|
||||
statusCode: 200,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: ["Content-Type": "application/json"]
|
||||
)!
|
||||
return (data, response)
|
||||
return (request.url?.path == "/wloc-settings/version" ? versionData : data, response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ grep -Fq '清除:GET ?action=clear' "$SETUP" \
|
||||
|
||||
for file in wloc.module wloc.sgmodule wloc.conf wloc.lpx wloc.stoverride; do
|
||||
test -s "$MODULES/$file" || fail "missing bundled module: $file"
|
||||
grep -q 'gs-loc.apple.com' "$MODULES/$file" \
|
||||
|| fail "$file must include gs-loc.apple.com in its MITM hostnames"
|
||||
grep -q 'gs-loc-cn.apple.com' "$MODULES/$file" \
|
||||
|| fail "$file must include gs-loc-cn.apple.com in its MITM hostnames"
|
||||
done
|
||||
|
||||
grep -q 'wloc.sgmodule' "$MANAGER" || fail "Surge/Egern module mapping is missing"
|
||||
@@ -48,7 +52,13 @@ 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 both MITM hostnames"
|
||||
grep -q 'gs-loc.apple.com 和 gs-loc-cn.apple.com' "$SETUP" \
|
||||
|| fail "setup guidance must name both Apple location hostnames"
|
||||
grep -q 'ThirdPartyProxyManager.interceptionHostnamesText' "$SETUP" \
|
||||
|| fail "setup hostname copy actions must use the shared two-host value"
|
||||
grep -q 'ThirdPartyProxyManager.interceptionHostnamesText' "$SETTINGS" \
|
||||
|| fail "Settings must expose the shared two-host 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"
|
||||
@@ -64,10 +74,43 @@ grep -q 'Label("查看诊断日志"' "$SETUP" \
|
||||
|| fail "third-party connection failure must expose the diagnostics action"
|
||||
! 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()' \
|
||||
grep -q 'let response = try await thirdPartyProxy.validateConnection()' "$SETUP" \
|
||||
|| fail "the import page must validate the legacy save/query endpoint"
|
||||
grep -A20 'let response = try await thirdPartyProxy.validateConnection()' "$SETUP" \
|
||||
| grep -q 'refreshAdvancedFeatureAvailability()' \
|
||||
|| fail "the import page must also probe advanced module capabilities"
|
||||
grep -A20 'let response = try await thirdPartyProxy.validateConnection()' "$SETUP" \
|
||||
| grep -q 'motionSimulation.setEnabled(false)' \
|
||||
|| fail "an incompatible module must turn motion simulation off without failing basic setup"
|
||||
grep -A20 'let response = try await thirdPartyProxy.validateConnection()' "$SETUP" | grep -q 'onComplete()' \
|
||||
|| fail "a successful third-party connection test must close setup immediately"
|
||||
grep -q 'components.queryItems = \[URLQueryItem(name: "action", value: "query")\]' "$MANAGER" \
|
||||
|| fail "the connection test must preserve the established save?action=query contract"
|
||||
grep -q 'thirdPartyProxy.moduleUpdateRecommended' "$SETTINGS" \
|
||||
|| fail "Settings must react to legacy module compatibility mode"
|
||||
grep -q '当前模块版本较旧,基础坐标功能仍可继续使用' "$SETTINGS" \
|
||||
|| fail "Settings must explain that legacy modules retain basic coordinate support"
|
||||
! grep -A8 'Toggle("运动状态模拟"' "$SETTINGS" | grep -q 'thirdPartyProxy.moduleUpdateRecommended' \
|
||||
|| fail "legacy module compatibility must turn motion simulation off without disabling its toggle"
|
||||
grep -q 'refreshAdvancedFeatureAvailability' "$SETTINGS" \
|
||||
|| fail "Settings must probe advanced module availability independently"
|
||||
grep -A18 'guard thirdPartyProxy.activeSettings?.success == true else' "$SETTINGS" \
|
||||
| grep -q 'refreshAdvancedFeatureAvailability()' \
|
||||
|| fail "enabling inactive third-party motion simulation must validate the version endpoint first"
|
||||
grep -A18 'guard thirdPartyProxy.activeSettings?.success == true else' "$SETTINGS" \
|
||||
| grep -q 'motionSimulation.setEnabled(true)' \
|
||||
|| fail "inactive third-party motion simulation may enable only after version validation succeeds"
|
||||
grep -q 'proxyOperationAlertTitle = "无法开启运动状态模拟"' "$SETTINGS" \
|
||||
|| fail "motion simulation must show a dedicated failure alert when version validation fails"
|
||||
grep -q '请重新导入最新模块脚本后再开启' "$SETTINGS" \
|
||||
|| fail "motion simulation failure must tell the user to update the module script"
|
||||
test "$(grep -c 'presentMotionSimulationModuleUpdateAlert()' "$SETTINGS")" -ge 3 \
|
||||
|| fail "inactive and active motion simulation paths must share the module-update alert"
|
||||
grep -q 'onChange(of: thirdPartyProxy.moduleUpdateRecommended)' "$SETTINGS" \
|
||||
|| fail "Settings must react when an installed module becomes incompatible"
|
||||
grep -A5 'private func disableUnsupportedThirdPartyMotionSimulation()' "$SETTINGS" \
|
||||
| grep -q 'motionSimulation.setEnabled(false)' \
|
||||
|| fail "unsupported third-party modules must force motion simulation off before disabling the toggle"
|
||||
! grep -q 'thirdPartyTestResult?.success' "$SETUP" \
|
||||
|| fail "a successful third-party test must not leave a separate completion state"
|
||||
grep -q 'setupStep = \.thirdPartyImport' "$ROOT/App/SetupCoordinator.swift" \
|
||||
@@ -112,7 +155,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.4"' "$ROOT/project.yml" || fail "marketing version must be 1.0.4"
|
||||
grep -q 'CURRENT_PROJECT_VERSION: "5"' "$ROOT/project.yml" || fail "build version must be 5"
|
||||
grep -q 'MARKETING_VERSION: "1.0.5"' "$ROOT/project.yml" || fail "marketing version must be 1.0.5"
|
||||
grep -q 'CURRENT_PROJECT_VERSION: "6"' "$ROOT/project.yml" || fail "build version must be 6"
|
||||
|
||||
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.4"
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Third-Party Notices
|
||||
|
||||
The generated proxy scripts bundle:
|
||||
|
||||
- `pako` 2.1.0, Copyright (C) 2014-2017 Vitaly Puzrin and contributors, MIT License.
|
||||
- `esbuild` is used only as a development/build dependency and is distributed under the MIT License.
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
import { build } from "esbuild";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
|
||||
await mkdir(new URL("./dist/v1/", import.meta.url), { recursive: true });
|
||||
|
||||
for (const [entry, outfile] of [
|
||||
["src/response-entry.js", "dist/v1/wloc.js"],
|
||||
["src/settings-entry.js", "dist/v1/wloc-settings.js"]
|
||||
]) {
|
||||
await build({
|
||||
entryPoints: [entry],
|
||||
outfile,
|
||||
bundle: true,
|
||||
format: "iife",
|
||||
target: ["es2017"],
|
||||
minify: true,
|
||||
legalComments: "eof"
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
(()=>{var y="1.0.0";var m=["wifi","cellTower","arpc","marker","synthetic","bare","motionSimulation"],c="locationSpoofer.settings.v1";function u(){return typeof $task!="undefined"?"quantumultX":typeof $loon!="undefined"?"loon":typeof $rocket!="undefined"?"shadowrocket":typeof Egern!="undefined"?"egern":typeof $environment!="undefined"&&$environment["stash-version"]?"stash":typeof $environment!="undefined"&&$environment["surge-version"]?"surge":"unknown"}function h(e){let t=u()==="quantumultX"?$prefs.valueForKey(e):$persistentStore.read(e);if(!t)return null;try{return JSON.parse(t)}catch(n){return null}}function l(e,t){let n=t==null?"":JSON.stringify(t);return u()==="quantumultX"?$prefs.setValueForKey(n,e):$persistentStore.write(n,e)}function i(e){let t={status:200,headers:{"Content-Type":"application/json; charset=utf-8"},body:JSON.stringify(e)};u()==="quantumultX"?$done(Object.assign({},t,{status:"HTTP/1.1 200 OK"})):u()==="stash"?$done(t):$done({response:t})}function g(e){let t=e.split("?")[1]||"",n={};return t.split("&").forEach(s=>{if(!s)return;let o=s.indexOf("="),f=o<0?s:s.slice(0,o),d=o<0?"":s.slice(o+1),a=f,p=d;try{a=decodeURIComponent(f.replace(/\+/g," "))}catch($){}try{p=decodeURIComponent(d.replace(/\+/g," "))}catch($){}Object.prototype.hasOwnProperty.call(n,a)||(n[a]=p)}),n}function O(e){let t=e.split("?")[0],n=t.indexOf("://");if(n<0)return t;let s=t.indexOf("/",n+3);return s<0?"/":t.slice(s)}var b=typeof $request=="undefined"?"":$request.url||"",E=O(b),r=g(b);if(E==="/wloc-settings/version")i({success:!0,moduleVersion:y,protocolVersion:1,capabilities:m});else if(r.action==="query"){let e=h(c);i(e&&e.enabled?{success:!0,longitude:e.longitude,latitude:e.latitude,accuracy:e.accuracy,motionSimulationEnabled:e.motionSimulationEnabled===!0}:{success:!1,error:"\u65E0\u5DF2\u4FDD\u5B58\u7684\u5750\u6807"})}else if(r.action==="clear")l(c,null),i({success:!0});else{let e=Number(r.lon!=null?r.lon:r.longitude),t=Number(r.lat!=null?r.lat:r.latitude),n=Number(r.acc!=null?r.acc:r.accuracy!=null?r.accuracy:25);if(!Number.isFinite(e)||!Number.isFinite(t))i({success:!1,error:"\u7F3A\u5C11 lon/lat \u53C2\u6570"});else{let s={enabled:!0,longitude:e,latitude:t,accuracy:n,motionSimulationEnabled:r.motion==="1"},o=l(c,s);i(o?{success:!0,longitude:e,latitude:t,accuracy:n,motionSimulationEnabled:s.motionSimulationEnabled}:{success:!1,error:"\u4FDD\u5B58\u914D\u7F6E\u5931\u8D25"})}}})();
|
||||
+6
File diff suppressed because one or more lines are too long
+11
@@ -0,0 +1,11 @@
|
||||
#!name=Apple WLOC 定位修改
|
||||
#!desc=Location Spoofer 第三方代理模块
|
||||
#!author=xweiba
|
||||
#!homepage=https://github.com/xweiba/location-spoofer
|
||||
|
||||
[rewrite_local]
|
||||
^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc url script-response-body https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js
|
||||
^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/(save|version) url script-echo-response https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js
|
||||
|
||||
[mitm]
|
||||
hostname = gs-loc.apple.com, gs-loc-cn.apple.com
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
#!name=Apple WLOC 定位修改
|
||||
#!desc=Location Spoofer 第三方代理模块
|
||||
#!author=xweiba
|
||||
#!homepage=https://github.com/xweiba/location-spoofer
|
||||
|
||||
[Script]
|
||||
http-response ^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc script-path=https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js, requires-body=true, binary-body-mode=true, timeout=30, tag=Apple WLOC
|
||||
http-request ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/(save|version) script-path=https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js, timeout=10, tag=WLOC Settings
|
||||
|
||||
[MITM]
|
||||
hostname = gs-loc.apple.com, gs-loc-cn.apple.com
|
||||
@@ -0,0 +1,12 @@
|
||||
#!name=Apple WLOC 定位修改
|
||||
#!desc=Location Spoofer 第三方代理模块
|
||||
#!author=xweiba
|
||||
#!homepage=https://github.com/xweiba/location-spoofer
|
||||
#!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/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js
|
||||
WLOC Settings = type=http-request,pattern=^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/(save|version),requires-body=0,max-size=0,timeout=10,script-path=https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js
|
||||
|
||||
[MITM]
|
||||
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com
|
||||
@@ -0,0 +1,12 @@
|
||||
#!name=Apple WLOC 定位修改
|
||||
#!desc=Location Spoofer 第三方代理模块
|
||||
#!author=xweiba
|
||||
#!homepage=https://github.com/xweiba/location-spoofer
|
||||
#!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/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js
|
||||
WLOC Settings = type=http-request, pattern="^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/(save|version)", requires-body=0, max-size=0, timeout=10, script-path=https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js
|
||||
|
||||
[MITM]
|
||||
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Apple WLOC 定位修改
|
||||
desc: Location Spoofer 第三方代理模块
|
||||
author: xweiba
|
||||
homepage: https://github.com/xweiba/location-spoofer
|
||||
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
|
||||
- match: ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/(save|version)
|
||||
name: WLOC.Settings
|
||||
type: request
|
||||
require-body: false
|
||||
timeout: 10
|
||||
|
||||
script-providers:
|
||||
WLOC.Location:
|
||||
url: https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js
|
||||
interval: 86400
|
||||
WLOC.Settings:
|
||||
url: https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js
|
||||
interval: 86400
|
||||
+508
@@ -0,0 +1,508 @@
|
||||
{
|
||||
"name": "@location-spoofer/wloc-scripts",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@location-spoofer/wloc-scripts",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"pako": "2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.25.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz",
|
||||
"integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.8.tgz",
|
||||
"integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz",
|
||||
"integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.8.tgz",
|
||||
"integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz",
|
||||
"integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz",
|
||||
"integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz",
|
||||
"integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz",
|
||||
"integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz",
|
||||
"integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz",
|
||||
"integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz",
|
||||
"integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz",
|
||||
"integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz",
|
||||
"integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz",
|
||||
"integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz",
|
||||
"integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz",
|
||||
"integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz",
|
||||
"integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz",
|
||||
"integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz",
|
||||
"integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz",
|
||||
"integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz",
|
||||
"integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz",
|
||||
"integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz",
|
||||
"integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz",
|
||||
"integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz",
|
||||
"integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz",
|
||||
"integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.8.tgz",
|
||||
"integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.8",
|
||||
"@esbuild/android-arm": "0.25.8",
|
||||
"@esbuild/android-arm64": "0.25.8",
|
||||
"@esbuild/android-x64": "0.25.8",
|
||||
"@esbuild/darwin-arm64": "0.25.8",
|
||||
"@esbuild/darwin-x64": "0.25.8",
|
||||
"@esbuild/freebsd-arm64": "0.25.8",
|
||||
"@esbuild/freebsd-x64": "0.25.8",
|
||||
"@esbuild/linux-arm": "0.25.8",
|
||||
"@esbuild/linux-arm64": "0.25.8",
|
||||
"@esbuild/linux-ia32": "0.25.8",
|
||||
"@esbuild/linux-loong64": "0.25.8",
|
||||
"@esbuild/linux-mips64el": "0.25.8",
|
||||
"@esbuild/linux-ppc64": "0.25.8",
|
||||
"@esbuild/linux-riscv64": "0.25.8",
|
||||
"@esbuild/linux-s390x": "0.25.8",
|
||||
"@esbuild/linux-x64": "0.25.8",
|
||||
"@esbuild/netbsd-arm64": "0.25.8",
|
||||
"@esbuild/netbsd-x64": "0.25.8",
|
||||
"@esbuild/openbsd-arm64": "0.25.8",
|
||||
"@esbuild/openbsd-x64": "0.25.8",
|
||||
"@esbuild/openharmony-arm64": "0.25.8",
|
||||
"@esbuild/sunos-x64": "0.25.8",
|
||||
"@esbuild/win32-arm64": "0.25.8",
|
||||
"@esbuild/win32-ia32": "0.25.8",
|
||||
"@esbuild/win32-x64": "0.25.8"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@location-spoofer/wloc-scripts",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"pako": "2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.25.8"
|
||||
}
|
||||
}
|
||||
Vendored
+263
@@ -0,0 +1,263 @@
|
||||
export const MOTION_ACTIVITY_TYPE = 63;
|
||||
export const MOTION_ACTIVITY_CONFIDENCE = 467;
|
||||
export const WLOC_MARKER = Uint8Array.from([0, 0, 0, 1, 0, 0]);
|
||||
const UINT32_RANGE = 0x100000000;
|
||||
|
||||
const concat = (...parts) => {
|
||||
const length = parts.reduce((sum, part) => sum + part.length, 0);
|
||||
const out = new Uint8Array(length);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
out.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const equal = (left, right) =>
|
||||
left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
|
||||
function readVarint(data, offset) {
|
||||
let value = 0;
|
||||
let multiplier = 1;
|
||||
for (let index = 0; index < 10 && offset + index < data.length; index += 1) {
|
||||
const byte = data[offset + index];
|
||||
const chunk = byte & 0x7f;
|
||||
if (value !== null && chunk <= Math.floor((Number.MAX_SAFE_INTEGER - value) / multiplier)) {
|
||||
value += chunk * multiplier;
|
||||
} else {
|
||||
value = null;
|
||||
}
|
||||
if ((byte & 0x80) === 0) return { value, next: offset + index + 1 };
|
||||
multiplier *= 0x80;
|
||||
}
|
||||
throw new Error("invalid varint");
|
||||
}
|
||||
|
||||
function writeVarint(input) {
|
||||
const value = Math.trunc(Number(input));
|
||||
if (!Number.isSafeInteger(value)) throw new Error("varint value is not a safe integer");
|
||||
let low = value >>> 0;
|
||||
let high = Math.floor(value / UINT32_RANGE) >>> 0;
|
||||
const bytes = [];
|
||||
do {
|
||||
const byte = low & 0x7f;
|
||||
low = ((low >>> 7) | ((high & 0x7f) << 25)) >>> 0;
|
||||
high >>>= 7;
|
||||
const hasMore = high !== 0 || low !== 0;
|
||||
bytes.push(byte | (hasMore ? 0x80 : 0));
|
||||
} while (high !== 0 || low !== 0);
|
||||
return Uint8Array.from(bytes);
|
||||
}
|
||||
|
||||
const writeTag = (number, wireType) => writeVarint((number << 3) | wireType);
|
||||
|
||||
function writeLengthDelimited(number, value) {
|
||||
return concat(writeTag(number, 2), writeVarint(value.length), value);
|
||||
}
|
||||
|
||||
export function parseFields(data) {
|
||||
const fields = [];
|
||||
let offset = 0;
|
||||
while (offset < data.length) {
|
||||
const start = offset;
|
||||
const tag = readVarint(data, offset);
|
||||
offset = tag.next;
|
||||
if (!Number.isSafeInteger(tag.value)) throw new Error("protobuf tag is too large");
|
||||
const number = Math.floor(tag.value / 8);
|
||||
const wireType = tag.value & 7;
|
||||
if (number === 0) throw new Error("invalid protobuf field 0");
|
||||
let value;
|
||||
if (wireType === 0) {
|
||||
const item = readVarint(data, offset);
|
||||
value = data.slice(offset, item.next);
|
||||
offset = item.next;
|
||||
} else if (wireType === 1) {
|
||||
if (offset + 8 > data.length) throw new Error("truncated fixed64");
|
||||
value = data.slice(offset, offset + 8);
|
||||
offset += 8;
|
||||
} else if (wireType === 2) {
|
||||
const length = readVarint(data, offset);
|
||||
offset = length.next;
|
||||
const size = length.value;
|
||||
if (!Number.isSafeInteger(size) || offset + size > data.length) {
|
||||
throw new Error("truncated length-delimited field");
|
||||
}
|
||||
value = data.slice(offset, offset + size);
|
||||
offset += size;
|
||||
} else if (wireType === 5) {
|
||||
if (offset + 4 > data.length) throw new Error("truncated fixed32");
|
||||
value = data.slice(offset, offset + 4);
|
||||
offset += 4;
|
||||
} else {
|
||||
throw new Error(`unsupported wire type ${wireType}`);
|
||||
}
|
||||
fields.push({ number, wireType, value, raw: data.slice(start, offset) });
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function patchLocation(data, config, stats) {
|
||||
const fields = parseFields(data);
|
||||
if (!fields.some((field) => field.number === 1 && field.wireType === 0) ||
|
||||
!fields.some((field) => field.number === 2 && field.wireType === 0)) {
|
||||
return data;
|
||||
}
|
||||
let hasMotionType = false;
|
||||
let hasMotionConfidence = false;
|
||||
const parts = fields.map((field) => {
|
||||
if (field.wireType !== 0) return field.raw;
|
||||
if (field.number === 1) return concat(writeTag(1, 0), writeVarint(Math.round(config.latitude * 1e8)));
|
||||
if (field.number === 2) return concat(writeTag(2, 0), writeVarint(Math.round(config.longitude * 1e8)));
|
||||
if (field.number === 3) return concat(writeTag(3, 0), writeVarint(config.accuracy));
|
||||
if (config.motionSimulationEnabled && field.number === 11) {
|
||||
hasMotionType = true;
|
||||
return concat(writeTag(11, 0), writeVarint(MOTION_ACTIVITY_TYPE));
|
||||
}
|
||||
if (config.motionSimulationEnabled && field.number === 12) {
|
||||
hasMotionConfidence = true;
|
||||
return concat(writeTag(12, 0), writeVarint(MOTION_ACTIVITY_CONFIDENCE));
|
||||
}
|
||||
return field.raw;
|
||||
});
|
||||
if (config.motionSimulationEnabled && !hasMotionType) {
|
||||
parts.push(concat(writeTag(11, 0), writeVarint(MOTION_ACTIVITY_TYPE)));
|
||||
}
|
||||
if (config.motionSimulationEnabled && !hasMotionConfidence) {
|
||||
parts.push(concat(writeTag(12, 0), writeVarint(MOTION_ACTIVITY_CONFIDENCE)));
|
||||
}
|
||||
const out = concat(...parts);
|
||||
if (!equal(out, data)) stats.locations += 1;
|
||||
return out;
|
||||
}
|
||||
|
||||
function patchWifi(data, config, stats) {
|
||||
const fields = parseFields(data);
|
||||
const hasMac = fields.some((field) =>
|
||||
field.number === 1 && field.wireType === 2 &&
|
||||
/^[0-9a-fA-F]{1,2}(:[0-9a-fA-F]{1,2}){5}$/.test(
|
||||
Array.from(field.value, (byte) => String.fromCharCode(byte)).join("")
|
||||
)
|
||||
);
|
||||
if (!hasMac) return data;
|
||||
let changed = false;
|
||||
const parts = fields.map((field) => {
|
||||
if (field.number !== 2 || field.wireType !== 2) return field.raw;
|
||||
const value = patchLocation(field.value, config, stats);
|
||||
changed ||= !equal(value, field.value);
|
||||
return writeLengthDelimited(2, value);
|
||||
});
|
||||
if (changed) stats.wifi += 1;
|
||||
return concat(...parts);
|
||||
}
|
||||
|
||||
function patchCell(data, config, stats) {
|
||||
let changed = false;
|
||||
const parts = parseFields(data).map((field) => {
|
||||
if (field.number !== 5 || field.wireType !== 2) return field.raw;
|
||||
const value = patchLocation(field.value, config, stats);
|
||||
changed ||= !equal(value, field.value);
|
||||
return writeLengthDelimited(5, value);
|
||||
});
|
||||
if (changed) stats.cell += 1;
|
||||
return concat(...parts);
|
||||
}
|
||||
|
||||
export function patchPayload(data, config, stats = { wifi: 0, cell: 0, locations: 0 }) {
|
||||
const parts = parseFields(data).map((field) => {
|
||||
if (field.number === 2 && field.wireType === 2) {
|
||||
return writeLengthDelimited(2, patchWifi(field.value, config, stats));
|
||||
}
|
||||
if ((field.number === 22 || field.number === 24) && field.wireType === 2) {
|
||||
return writeLengthDelimited(field.number, patchCell(field.value, config, stats));
|
||||
}
|
||||
return field.raw;
|
||||
});
|
||||
return { data: concat(...parts), stats };
|
||||
}
|
||||
|
||||
const uint16 = (data, offset) => (data[offset] << 8) | data[offset + 1];
|
||||
const uint32 = (data, offset) =>
|
||||
((data[offset] * 0x1000000) + (data[offset + 1] << 16) +
|
||||
(data[offset + 2] << 8) + data[offset + 3]) >>> 0;
|
||||
const writeUint16 = (value) => Uint8Array.from([(value >>> 8) & 0xff, value & 0xff]);
|
||||
const writeUint32 = (value) => Uint8Array.from([
|
||||
(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff
|
||||
]);
|
||||
|
||||
function findBytes(data, marker) {
|
||||
outer: for (let offset = 0; offset <= data.length - marker.length; offset += 1) {
|
||||
for (let index = 0; index < marker.length; index += 1) {
|
||||
if (data[offset + index] !== marker[index]) continue outer;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function patchARPC(body, config) {
|
||||
if (body.length < 2) throw new Error("short ARPC");
|
||||
let offset = 2;
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (offset + 2 > body.length) throw new Error("truncated ARPC string");
|
||||
offset += 2 + uint16(body, offset);
|
||||
}
|
||||
const lengthOffset = offset + 4;
|
||||
const payloadOffset = lengthOffset + 4;
|
||||
if (payloadOffset > body.length) throw new Error("truncated ARPC header");
|
||||
const length = uint32(body, lengthOffset);
|
||||
if (!length || payloadOffset + length > body.length) throw new Error("invalid ARPC length");
|
||||
const patched = patchPayload(body.slice(payloadOffset, payloadOffset + length), config);
|
||||
if (equal(patched.data, body.slice(payloadOffset, payloadOffset + length))) throw new Error("unchanged ARPC");
|
||||
return { data: concat(body.slice(0, lengthOffset), writeUint32(patched.data.length),
|
||||
patched.data, body.slice(payloadOffset + length)), stats: patched.stats };
|
||||
}
|
||||
|
||||
function patchMarker(body, config) {
|
||||
const markerOffset = findBytes(body, WLOC_MARKER);
|
||||
if (markerOffset < 0) throw new Error("marker not found");
|
||||
const lengthOffset = markerOffset + WLOC_MARKER.length;
|
||||
const payloadOffset = lengthOffset + 2;
|
||||
const length = uint16(body, lengthOffset);
|
||||
if (!length || payloadOffset + length > body.length) throw new Error("invalid marker length");
|
||||
const patched = patchPayload(body.slice(payloadOffset, payloadOffset + length), config);
|
||||
if (patched.data.length > 65535 || equal(patched.data, body.slice(payloadOffset, payloadOffset + length))) {
|
||||
throw new Error("unchanged marker");
|
||||
}
|
||||
return { data: concat(body.slice(0, lengthOffset), writeUint16(patched.data.length),
|
||||
patched.data, body.slice(payloadOffset + length)), stats: patched.stats };
|
||||
}
|
||||
|
||||
function patchSynthetic(body, offset, config) {
|
||||
if (offset + 10 > body.length) throw new Error("short frame");
|
||||
const length = uint16(body, offset + 8);
|
||||
if (!length || offset + 10 + length > body.length) throw new Error("invalid frame");
|
||||
const patched = patchPayload(body.slice(offset + 10, offset + 10 + length), config);
|
||||
if (patched.data.length > 65535 || equal(patched.data, body.slice(offset + 10, offset + 10 + length))) {
|
||||
throw new Error("unchanged frame");
|
||||
}
|
||||
return { data: concat(body.slice(0, offset + 8), writeUint16(patched.data.length),
|
||||
patched.data, body.slice(offset + 10 + length)), stats: patched.stats };
|
||||
}
|
||||
|
||||
export function patchWlocBody(body, config) {
|
||||
for (const patcher of [patchARPC, patchMarker]) {
|
||||
try { return patcher(body, config); } catch {}
|
||||
}
|
||||
const offsets = [...new Set([0, 2, 4, 6, 8, 10, 12, 14, 16,
|
||||
...Array.from({ length: Math.min(96, Math.max(0, body.length - 10)) + 1 }, (_, index) => index)])];
|
||||
for (const offset of offsets) {
|
||||
try { return patchSynthetic(body, offset, config); } catch {}
|
||||
}
|
||||
for (let offset = 0; offset <= Math.min(256, body.length); offset += 1) {
|
||||
try {
|
||||
const patched = patchPayload(body.slice(offset), config);
|
||||
if (!equal(patched.data, body.slice(offset))) {
|
||||
return { data: concat(body.slice(0, offset), patched.data), stats: patched.stats };
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
throw new Error("no patchable wloc payload found");
|
||||
}
|
||||
|
||||
export const internals = { concat, writeVarint, writeTag, writeLengthDelimited, equal };
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { ungzip } from "pako";
|
||||
import { patchWlocBody } from "./core.js";
|
||||
import {
|
||||
STORAGE_KEY, finishBinary, finishPassthrough, readPersistent, responseBytes
|
||||
} from "./runtime.js";
|
||||
|
||||
try {
|
||||
const settings = readPersistent(STORAGE_KEY);
|
||||
const input = responseBytes();
|
||||
if (!settings || !settings.enabled || !input.length) {
|
||||
finishPassthrough();
|
||||
} else {
|
||||
const isGzip = input.length >= 2 && input[0] === 0x1f && input[1] === 0x8b;
|
||||
const body = isGzip ? ungzip(input) : input;
|
||||
const patched = patchWlocBody(body, {
|
||||
latitude: Number(settings.latitude),
|
||||
longitude: Number(settings.longitude),
|
||||
accuracy: Number(settings.accuracy != null ? settings.accuracy : 25),
|
||||
motionSimulationEnabled: settings.motionSimulationEnabled === true
|
||||
});
|
||||
finishBinary(patched.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`[Location Spoofer] ${error && error.message ? error.message : error}`);
|
||||
finishPassthrough();
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
export const MODULE_VERSION = "1.0.0";
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
export const CAPABILITIES = [
|
||||
"wifi", "cellTower", "arpc", "marker", "synthetic", "bare", "motionSimulation"
|
||||
];
|
||||
export const STORAGE_KEY = "locationSpoofer.settings.v1";
|
||||
|
||||
export function environment() {
|
||||
if (typeof $task !== "undefined") return "quantumultX";
|
||||
if (typeof $loon !== "undefined") return "loon";
|
||||
if (typeof $rocket !== "undefined") return "shadowrocket";
|
||||
if (typeof Egern !== "undefined") return "egern";
|
||||
if (typeof $environment !== "undefined" && $environment["stash-version"]) return "stash";
|
||||
if (typeof $environment !== "undefined" && $environment["surge-version"]) return "surge";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function readPersistent(key) {
|
||||
const raw = environment() === "quantumultX"
|
||||
? $prefs.valueForKey(key)
|
||||
: $persistentStore.read(key);
|
||||
if (!raw) return null;
|
||||
try { return JSON.parse(raw); } catch { return null; }
|
||||
}
|
||||
|
||||
export function writePersistent(key, value) {
|
||||
const raw = value == null ? "" : JSON.stringify(value);
|
||||
return environment() === "quantumultX"
|
||||
? $prefs.setValueForKey(raw, key)
|
||||
: $persistentStore.write(raw, key);
|
||||
}
|
||||
|
||||
export function responseBytes() {
|
||||
const response = typeof $response === "undefined" ? null : $response;
|
||||
const value = response && response.bodyBytes != null ? response.bodyBytes : response && response.body;
|
||||
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
||||
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
||||
if (typeof value === "string") {
|
||||
return Uint8Array.from(value, (character) => character.charCodeAt(0) & 0xff);
|
||||
}
|
||||
return new Uint8Array();
|
||||
}
|
||||
|
||||
function cleanHeaders(headers, length) {
|
||||
const out = Object.assign({}, headers || {});
|
||||
for (const name of ["Content-Encoding", "content-encoding", "Transfer-Encoding", "transfer-encoding"]) {
|
||||
delete out[name];
|
||||
}
|
||||
out["Content-Length"] = String(length);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function finishBinary(bytes) {
|
||||
const response = typeof $response === "undefined" ? {} : $response;
|
||||
const headers = cleanHeaders(response.headers, bytes.length);
|
||||
const env = environment();
|
||||
if (env === "quantumultX") {
|
||||
delete headers["Content-Length"];
|
||||
$done({ status: "HTTP/1.1 200 OK", headers, bodyBytes: bytes.buffer });
|
||||
} else if (env === "stash") {
|
||||
$done(Object.assign({}, response, { status: 200, headers, body: bytes }));
|
||||
} else {
|
||||
$done({ response: Object.assign({}, response, { status: 200, headers, body: bytes }) });
|
||||
}
|
||||
}
|
||||
|
||||
export function finishPassthrough() {
|
||||
$done({});
|
||||
}
|
||||
|
||||
export function finishJSON(value) {
|
||||
const response = {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify(value)
|
||||
};
|
||||
if (environment() === "quantumultX") {
|
||||
$done(Object.assign({}, response, { status: "HTTP/1.1 200 OK" }));
|
||||
} else if (environment() === "stash") {
|
||||
$done(response);
|
||||
} else {
|
||||
$done({ response });
|
||||
}
|
||||
}
|
||||
|
||||
export function queryParameters(url) {
|
||||
const query = url.split("?")[1] || "";
|
||||
const values = {};
|
||||
query.split("&").forEach((item) => {
|
||||
if (!item) return;
|
||||
const separator = item.indexOf("=");
|
||||
const rawKey = separator < 0 ? item : item.slice(0, separator);
|
||||
const rawValue = separator < 0 ? "" : item.slice(separator + 1);
|
||||
let key = rawKey;
|
||||
let value = rawValue;
|
||||
try { key = decodeURIComponent(rawKey.replace(/\+/g, " ")); } catch {}
|
||||
try { value = decodeURIComponent(rawValue.replace(/\+/g, " ")); } catch {}
|
||||
if (!Object.prototype.hasOwnProperty.call(values, key)) values[key] = value;
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
export function requestPath(url) {
|
||||
const withoutQuery = url.split("?")[0];
|
||||
const scheme = withoutQuery.indexOf("://");
|
||||
if (scheme < 0) return withoutQuery;
|
||||
const path = withoutQuery.indexOf("/", scheme + 3);
|
||||
return path < 0 ? "/" : withoutQuery.slice(path);
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
CAPABILITIES, MODULE_VERSION, PROTOCOL_VERSION, STORAGE_KEY,
|
||||
finishJSON, queryParameters, readPersistent, requestPath, writePersistent
|
||||
} from "./runtime.js";
|
||||
|
||||
const url = typeof $request === "undefined" ? "" : ($request.url || "");
|
||||
const path = requestPath(url);
|
||||
const parameters = queryParameters(url);
|
||||
|
||||
if (path === "/wloc-settings/version") {
|
||||
finishJSON({
|
||||
success: true,
|
||||
moduleVersion: MODULE_VERSION,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: CAPABILITIES
|
||||
});
|
||||
} else if (parameters.action === "query") {
|
||||
const settings = readPersistent(STORAGE_KEY);
|
||||
finishJSON(settings && settings.enabled
|
||||
? { success: true, longitude: settings.longitude, latitude: settings.latitude,
|
||||
accuracy: settings.accuracy, motionSimulationEnabled: settings.motionSimulationEnabled === true }
|
||||
: { success: false, error: "无已保存的坐标" });
|
||||
} else if (parameters.action === "clear") {
|
||||
writePersistent(STORAGE_KEY, null);
|
||||
finishJSON({ success: true });
|
||||
} else {
|
||||
const longitude = Number(parameters.lon != null ? parameters.lon : parameters.longitude);
|
||||
const latitude = Number(parameters.lat != null ? parameters.lat : parameters.latitude);
|
||||
const accuracy = Number(parameters.acc != null
|
||||
? parameters.acc
|
||||
: (parameters.accuracy != null ? parameters.accuracy : 25));
|
||||
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) {
|
||||
finishJSON({ success: false, error: "缺少 lon/lat 参数" });
|
||||
} else {
|
||||
const settings = {
|
||||
enabled: true,
|
||||
longitude,
|
||||
latitude,
|
||||
accuracy,
|
||||
motionSimulationEnabled: parameters.motion === "1"
|
||||
};
|
||||
const success = writePersistent(STORAGE_KEY, settings);
|
||||
finishJSON(success
|
||||
? { success: true, longitude, latitude, accuracy,
|
||||
motionSimulationEnabled: settings.motionSimulationEnabled }
|
||||
: { success: false, error: "保存配置失败" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import vm from "node:vm";
|
||||
|
||||
const bundles = [
|
||||
new URL("../dist/v1/wloc.js", import.meta.url),
|
||||
new URL("../dist/v1/wloc-settings.js", import.meta.url)
|
||||
];
|
||||
|
||||
test("generated bundles avoid newer JavaScriptCore runtime requirements", async () => {
|
||||
for (const bundle of bundles) {
|
||||
const source = await readFile(bundle, "utf8");
|
||||
for (const unsupported of [
|
||||
/\bBigInt\b/,
|
||||
/\bglobalThis\b/,
|
||||
/\bURLSearchParams\b/,
|
||||
/\bObject\.fromEntries\b/,
|
||||
/\?\./,
|
||||
/\?\?/
|
||||
]) {
|
||||
assert.equal(unsupported.test(source), false, `${bundle.pathname} contains ${unsupported}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("settings bundle runs without modern URL and text globals", async () => {
|
||||
const source = await readFile(bundles[1], "utf8");
|
||||
let result;
|
||||
const storage = new Map();
|
||||
vm.runInNewContext(source, {
|
||||
$rocket: {},
|
||||
$request: {
|
||||
url: "https://gs-loc.apple.com/wloc-settings/save?lon=121.1&lat=31.2&acc=25"
|
||||
},
|
||||
$persistentStore: {
|
||||
read: (key) => storage.get(key) || null,
|
||||
write: (value, key) => {
|
||||
storage.set(key, value);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
$done: (value) => { result = value; },
|
||||
JSON,
|
||||
Number,
|
||||
Object,
|
||||
String,
|
||||
decodeURIComponent
|
||||
});
|
||||
|
||||
assert.equal(JSON.parse(result.response.body).success, true);
|
||||
});
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
MOTION_ACTIVITY_CONFIDENCE, MOTION_ACTIVITY_TYPE, internals, parseFields, patchWlocBody
|
||||
} from "../src/core.js";
|
||||
|
||||
const { concat, writeVarint, writeTag, writeLengthDelimited } = internals;
|
||||
|
||||
function location(withMotion = false) {
|
||||
const fields = [
|
||||
concat(writeTag(1, 0), writeVarint(100)),
|
||||
concat(writeTag(2, 0), writeVarint(200)),
|
||||
concat(writeTag(3, 0), writeVarint(25))
|
||||
];
|
||||
if (withMotion) {
|
||||
fields.push(concat(writeTag(11, 0), writeVarint(7)));
|
||||
fields.push(concat(writeTag(12, 0), writeVarint(88)));
|
||||
}
|
||||
return concat(...fields);
|
||||
}
|
||||
|
||||
function wifiPayload(value = location()) {
|
||||
const device = concat(
|
||||
writeLengthDelimited(1, new TextEncoder().encode("aa:bb:cc:dd:ee:ff")),
|
||||
writeLengthDelimited(2, value)
|
||||
);
|
||||
return writeLengthDelimited(2, device);
|
||||
}
|
||||
|
||||
function frame(payload) {
|
||||
return concat(Uint8Array.from([0, 1, 0, 0, 0, 1, 0, 0]),
|
||||
Uint8Array.from([payload.length >> 8, payload.length & 0xff]), payload);
|
||||
}
|
||||
|
||||
const config = {
|
||||
latitude: 31.230416,
|
||||
longitude: 121.473701,
|
||||
accuracy: 50,
|
||||
motionSimulationEnabled: false
|
||||
};
|
||||
|
||||
test("patches synthetic Wi-Fi response", () => {
|
||||
const result = patchWlocBody(frame(wifiPayload()), config);
|
||||
assert.equal(result.stats.wifi, 1);
|
||||
assert.equal(result.stats.locations, 1);
|
||||
});
|
||||
|
||||
test("preserves motion fields while disabled", () => {
|
||||
const result = patchWlocBody(frame(wifiPayload(location(true))), config);
|
||||
const payload = result.data.slice(10);
|
||||
assert.ok(payload.includes(7));
|
||||
assert.ok(payload.includes(88));
|
||||
});
|
||||
|
||||
test("replaces motion fields while enabled", () => {
|
||||
const result = patchWlocBody(frame(wifiPayload(location(true))), {
|
||||
...config, motionSimulationEnabled: true
|
||||
});
|
||||
const root = parseFields(result.data.slice(10));
|
||||
const device = parseFields(root[0].value);
|
||||
const fields = parseFields(device.find((field) => field.number === 2).value);
|
||||
const motionType = fields.find((field) => field.number === 11);
|
||||
const motionConfidence = fields.find((field) => field.number === 12);
|
||||
assert.deepEqual(motionType.value, writeVarint(MOTION_ACTIVITY_TYPE));
|
||||
assert.deepEqual(motionConfidence.value, writeVarint(MOTION_ACTIVITY_CONFIDENCE));
|
||||
});
|
||||
|
||||
test("patches CellTower fields 22 and 24", () => {
|
||||
for (const number of [22, 24]) {
|
||||
const cell = writeLengthDelimited(5, location());
|
||||
const result = patchWlocBody(frame(writeLengthDelimited(number, cell)), config);
|
||||
assert.equal(result.stats.cell, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("encodes signed int64 coordinates without BigInt", () => {
|
||||
assert.deepEqual(
|
||||
Array.from(writeVarint(-18_000_000_000)),
|
||||
[128, 152, 247, 248, 188, 255, 255, 255, 255, 1]
|
||||
);
|
||||
assert.deepEqual(
|
||||
Array.from(writeVarint(18_000_000_000)),
|
||||
[128, 232, 136, 135, 67]
|
||||
);
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
## 已发布
|
||||
|
||||
- [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
|
||||
|
||||
@@ -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
|
||||
|
||||
验证结果:
|
||||
```
|
||||
|
||||
+44
-17
@@ -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.
|
||||
The files under `Resources/ThirdPartyProxyModules/` are project-owned module
|
||||
definitions used by the App's default domestic-mirror subscription path. Their
|
||||
executable scripts are built from `ThirdParty/WlocScripts/src/` and checked in
|
||||
under the versioned `ThirdParty/WlocScripts/dist/v1/` directory.
|
||||
|
||||
- Upstream commit: `eec07a8dc8de6dbaee8eac1fb376e4d03020154a`
|
||||
- Snapshot date: 2026-08-06
|
||||
- Source directory: `modules/`
|
||||
The direct GitHub Raw variants are stored under
|
||||
`ThirdParty/WlocScripts/modules/direct/`. The Settings switch selects which
|
||||
module URL the App copies:
|
||||
|
||||
| Bundled file | Client |
|
||||
- enabled by default: `gh-proxy.org` in front of GitHub Raw;
|
||||
- disabled: GitHub Raw directly.
|
||||
|
||||
The App appends the module version as a query parameter, such as `?v=1.0.0`.
|
||||
Increment this value whenever an existing module path changes so proxy clients
|
||||
do not reuse a previously cached subscription body.
|
||||
|
||||
| Module file | Client |
|
||||
|---|---|
|
||||
| `wloc.module` | Shadowrocket |
|
||||
| `wloc.sgmodule` | Surge and Egern |
|
||||
@@ -17,15 +24,35 @@ copies the official subscription URL instead of exporting these files.
|
||||
| `wloc.lpx` | Loon |
|
||||
| `wloc.stoverride` | Stash |
|
||||
|
||||
SHA-256:
|
||||
Egern reuses the Surge module. Stash imports `.stoverride` directly.
|
||||
|
||||
Both script entry points are owned by this repository:
|
||||
|
||||
- `wloc.js` patches Apple WLOC response bodies;
|
||||
- `wloc-settings.js` implements `/wloc-settings/save` and
|
||||
`/wloc-settings/version`.
|
||||
|
||||
The generated scripts target ES2017 and avoid hard dependencies on `BigInt`,
|
||||
optional chaining, nullish coalescing, `globalThis`, `URLSearchParams`, and
|
||||
`Object.fromEntries`. This keeps them usable by older proxy-client releases
|
||||
that still implement the established module syntax, binary response body,
|
||||
`$done`, and persistent-storage APIs.
|
||||
|
||||
Already-installed legacy modules that point to another repository are not
|
||||
silently migrated because their remote script URL is outside this project's
|
||||
control. Those users must re-import the project-owned module before using the
|
||||
versioned protocol and motion setting.
|
||||
|
||||
Current bundled module SHA-256 values:
|
||||
|
||||
```text
|
||||
bb5e17b60027704971660b0ea2df3560ceff973c27d43e7f2c2c18b48d368ac6 wloc.conf
|
||||
1fb451616fb17242849f72490f016afcdb8aa81a0b086f6dd5f94e1af3d58ee1 wloc.lpx
|
||||
97cab104056428aa0e90521c3bf2646e9739b0b4c83272b31790f99584bca89e wloc.module
|
||||
5d6b82c31316f4a7be65e3b8d2335f4338e01af98e262948118eefe63abf7034 wloc.sgmodule
|
||||
cb06593752db8b223dfa5cd1cbd089115fe3a541f5c8532491615923e83df2cb wloc.stoverride
|
||||
263f3eae0ec4ef19d03eefa58f28e6545cccbc6a2d32c5e1d3493ba207ca7605 wloc.conf
|
||||
c0755a9edb2a1686190d12d156e9aa53693e15721efc4a29f9a06c2bf3115a5f wloc.lpx
|
||||
06a426e4f37828d18b80abea04a8ade4fa7f93817cb1e37928c52da3e46f693a wloc.module
|
||||
f6b9fc51c4d3c4fca837ff896dbe544f99604d9646f05841bad82bfdfdf5c4fa wloc.sgmodule
|
||||
100e569e6ca3183f7da15fbb38ddb5cd91178488c0d9774acabc2721fa85a58c wloc.stoverride
|
||||
```
|
||||
|
||||
The official subscription URLs are the setup UI's import path. Egern reuses the
|
||||
Surge module. Stash imports `.stoverride` directly.
|
||||
The project acknowledges [Yu9191/wloc](https://github.com/Yu9191/wloc) as a
|
||||
reference for earlier WLOC implementation ideas. That acknowledgement is not
|
||||
an executable dependency or subscription source.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 153 KiB |
@@ -0,0 +1,36 @@
|
||||
# v1.0.5
|
||||
|
||||
发布日期:2026-08-10
|
||||
|
||||
## 主要更新
|
||||
|
||||
- APP 模式与第三方代理模式统一支持 Wi-Fi、CellTower 字段 22/24,以及 ARPC、marker、synthetic、bare 等 WLOC 响应格式。
|
||||
- 新增默认关闭的运动状态模拟,可在定位响应中同步模拟运动状态。
|
||||
- 第三方模式改用项目维护的固定版本脚本,支持 Shadowrocket、Surge、Quantumult X、Loon、Stash 和 Egern。
|
||||
- 设置页新增手动检查更新入口;版本配置和更新说明优先使用国内镜像,并保留 GitHub 官方源兜底。
|
||||
|
||||
## 第三方模式
|
||||
|
||||
- 基础坐标同步继续兼容旧模块,不再因缺少版本接口阻断导入或开启虚拟定位。
|
||||
- 运动状态模拟属于高级功能;开启前必须通过 `/wloc-settings/version` 协议与能力检测,检测失败时保持关闭并提示更新模块。
|
||||
- 完善模块缓存更新、连接诊断和失败引导,保留当前第三方客户端及详细错误信息。
|
||||
- Shadowrocket 和其他第三方客户端引导统一补充 `gs-loc.apple.com`、`gs-loc-cn.apple.com` 两个 HTTPS 解密域名及复制入口。
|
||||
|
||||
## 引导与资源
|
||||
|
||||
- 更新 Shadowrocket HTTPS 解密截图,直接标注开关、双域名和证书入口步骤。
|
||||
- 公共教程同步双域名配置要求,同时保留未经标注的原始截图。
|
||||
|
||||
## 兼容性说明
|
||||
|
||||
- 运动状态模拟默认关闭,升级后不会改变已有定位行为。
|
||||
- 旧版第三方模块仍可使用基础坐标功能;需要运动状态模拟时请重新导入最新模块。
|
||||
|
||||
## 自签安装
|
||||
|
||||
- Release 附件为未签名 IPA,安装前需要自行签名。
|
||||
- 可使用免费 Apple ID 和 Impactor 完成签名安装,无需付费开发者账号。
|
||||
- 签名时请保留 Bundle ID `com.paopaolabs.location-spoofer`、App Group `group.com.paopaolabs.location-spoofer` 及原有 entitlements。
|
||||
- 免费 Apple ID 签名通常只有 7 天有效期,到期后需要重新签名安装。
|
||||
|
||||
<!-- commit-range: v1.0.4..v1.0.5 -->
|
||||
+2
-2
@@ -8,8 +8,8 @@ options:
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.9"
|
||||
MARKETING_VERSION: "1.0.4"
|
||||
CURRENT_PROJECT_VERSION: "5"
|
||||
MARKETING_VERSION: "1.0.5"
|
||||
CURRENT_PROJECT_VERSION: "6"
|
||||
CODE_SIGN_STYLE: Manual
|
||||
CODE_SIGNING_ALLOWED: "NO"
|
||||
CODE_SIGNING_REQUIRED: "NO"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"latestVersion": "1.0.4",
|
||||
"latestVersion": "1.0.5",
|
||||
"minimumSupportedVersion": "1.0.0",
|
||||
"communityPromptClients": ["surge", "quantumultX", "loon", "stash", "egern"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user