11 Commits
94 changed files with 4710 additions and 459 deletions
@@ -0,0 +1,59 @@
title: "[第三方配置分享] "
body:
- type: markdown
attributes:
value: |
感谢分享已验证的第三方客户端配置。请先移除账号、订阅、密码、设备标识、真实位置等敏感信息。
截图除敏感信息遮挡外不要添加箭头、编号或说明文字,维护者会统一裁剪和标注。
- type: dropdown
id: client
attributes:
label: 第三方客户端
options:
- Shadowrocket
- Surge
- Quantumult X
- Loon
- Stash
- Egern
validations:
required: true
- type: input
id: client-version
attributes:
label: 第三方客户端版本
placeholder: 例如 2.2.70
validations:
required: true
- type: input
id: ios-version
attributes:
label: iOS 版本
placeholder: 例如 iOS 26.5
validations:
required: true
- type: textarea
id: setup-steps
attributes:
label: 配置步骤
description: 请描述模块导入、证书或解密设置、代理连接和最终验证过程。
placeholder: |
1. ...
2. ...
3. ...
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: 截图与补充说明
description: 可以直接拖入脱敏原图,并说明每张图片对应的步骤。
validations:
required: false
- type: checkboxes
id: attribution
attributes:
label: README 收录署名
options:
- label: 匿名收录,不在 README 展示投稿账号
required: false
+45
View File
@@ -0,0 +1,45 @@
name: Bug 报告
description: 报告 App 功能异常和可复现问题
title: "[Bug] "
labels:
- bug
body:
- type: markdown
attributes:
value: |
请优先在 App 的“设置 → 支持 → 报告 Bug”中生成诊断报告,再粘贴到下方字段。
提交前请移除真实位置、账号、订阅、密码、证书私钥和其他敏感信息。
- type: textarea
id: app-report
attributes:
label: App 生成的诊断报告
description: 粘贴 App 自动复制的完整报告;如果无法进入 App,请按占位格式手动填写。
placeholder: |
### 环境信息
App 版本:
系统版本:
运行模式:
第三方客户端:
可复现环境:
### 问题描述
### 诊断日志
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: 截图或录屏
description: 可选。请先遮挡账号、通知、真实位置和其他敏感信息。
validations:
required: false
- type: checkboxes
id: confirmations
attributes:
label: 提交确认
options:
- label: 我已搜索现有 Issues,确认没有相同问题
required: true
- label: 我已移除真实位置、认证信息、订阅地址、证书私钥和其他敏感信息
required: true
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: 使用帮助
url: https://github.com/xweiba/location-spoofer/discussions/categories/%E4%BD%BF%E7%94%A8%E5%B8%AE%E5%8A%A9
about: 安装、证书、代理配置、环境检测和问题排查
- name: 功能建议
url: https://github.com/xweiba/location-spoofer/discussions/categories/%E5%8A%9F%E8%83%BD%E5%BB%BA%E8%AE%AE
about: 提交功能改进、交互优化和新场景建议
- name: 第三方配置分享
url: https://github.com/xweiba/location-spoofer/discussions/categories/%E7%AC%AC%E4%B8%89%E6%96%B9%E9%85%8D%E7%BD%AE%E5%88%86%E4%BA%AB
about: 分享已验证的第三方客户端模块、配置步骤和脱敏截图
+7
View File
@@ -2,6 +2,9 @@
.superpowers/
build/
dist/
!ThirdParty/WlocScripts/dist/
!ThirdParty/WlocScripts/dist/**
node_modules/
DerivedData/
xcuserdata/
*.xcuserstate
@@ -23,5 +26,9 @@ Wloc.xcodeproj/
.github/*
!.github/workflows/
!.github/workflows/**
!.github/DISCUSSION_TEMPLATE/
!.github/DISCUSSION_TEMPLATE/**
!.github/ISSUE_TEMPLATE/
!.github/ISSUE_TEMPLATE/**
Core/core
docs/superpowers/
+13 -9
View File
@@ -7,15 +7,17 @@ struct BugReportView: View {
@State private var isReproducible = true
@State private var isRunning = false
@State private var showCopiedAlert = false
@State private var githubDestination: SafariDestination?
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
//
Text("遇到问题时,在这里生成 Issue 报告。系统会运行一次诊断测试,将测试结果和你的描述一起复制到剪切板,然后跳转到 GitHub 提交")
Text("遇到问题时,在这里生成 Bug 报告。系统会运行一次诊断测试,并将完整报告复制到剪切板跳转到 GitHub 后,请粘贴到“App 生成的诊断报告”字段")
.font(.caption)
.foregroundStyle(.secondary)
@@ -54,7 +56,7 @@ struct BugReportView: View {
if isRunning {
ProgressView().tint(.white)
}
Text(isRunning ? "正在生成报告…" : "生成 Issue 报告")
Text(isRunning ? "正在生成报告…" : "生成 Bug 报告")
.font(.body.weight(.medium))
}
.frame(maxWidth: .infinity)
@@ -67,20 +69,21 @@ struct BugReportView: View {
.padding(.horizontal, 16)
.padding(.bottom, 12)
}
.navigationTitle("报告 Issue")
.navigationTitle("报告 Bug")
.navigationBarTitleDisplayMode(.inline)
.alert("已生成", isPresented: $showCopiedAlert) {
Button("跳转到 GitHub") {
dismiss()
if let url = URL(string: "https://github.com/xweiba/location-spoofer/issues/new") {
UIApplication.shared.open(url)
}
Button("打开 GitHub 表单") {
githubDestination = SafariDestination(url: GitHubSubmission.bugReportURL)
}
Button("稍后再说", role: .cancel) {
dismiss()
}
} message: {
Text("Issue 报告已复制到剪切板。请在 GitHub Issues 页面粘贴并提交。")
Text("Bug 报告已复制到剪切板。请在 GitHub 表单的“App 生成的诊断报告”字段中粘贴并提交。")
}
.sheet(item: $githubDestination) { destination in
SafariView(url: destination.url)
.ignoresSafeArea()
}
}
@@ -115,6 +118,7 @@ struct BugReportView: View {
App 版本: \(appVersion)
系统版本: iOS \(systemVersion)
运行模式: \(runtimeMode.mode.displayName)
第三方客户端: \(runtimeMode.mode == .thirdParty ? thirdPartyClient.selectedClient.name : "不适用")
可复现环境: \(isReproducible ? "" : "")
### 问题描述
+93 -22
View File
@@ -1,11 +1,14 @@
import SwiftUI
import UIKit
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@StateObject private var setup = SetupCoordinator()
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
@ObservedObject private var remoteConfiguration = AppRemoteConfigurationStore.shared
@State private var phase: AppPhase = .splash
@State private var verifiedDuringInitialSetup = false
@AppStorage("setupCompleted") private var setupCompleted = false
@State private var updatePrompt: AppUpdatePrompt?
@State private var requiredUpdatePrompt: AppUpdatePrompt?
enum AppPhase { case splash, setup, map }
@@ -29,14 +32,19 @@ struct ContentView: View {
MapHomeView(setup: setup)
}
.fullScreenCover(isPresented: $setup.needsSetup) {
FirstSetupView(setup: setup, onComplete: {
setupCompleted = true
setup.completeSetup()
})
FirstSetupView(setup: setup, onComplete: finishPresentedSetup)
}
}
}
.task { await bootstrap() }
.task { await checkForUpdates() }
.onChange(of: scenePhase) { newPhase in
guard newPhase == .active, let requiredUpdatePrompt else { return }
updatePrompt = requiredUpdatePrompt
}
.alert(item: $updatePrompt) { prompt in
updateAlert(for: prompt)
}
}
@MainActor
@@ -50,14 +58,14 @@ struct ContentView: View {
}
let launchMode = runtimeMode.mode
guard setupCompleted else {
guard runtimeMode.isInitialized(launchMode) else {
if launchMode == .localWiFi {
await setup.prepareLocalServices()
setup.requestSetup()
} else {
ProxyManager.shared.stop()
BackgroundKeepAlive.shared.stop()
setup.requestThirdPartySetup()
setup.requestThirdPartyOnboarding()
}
phase = .setup
return
@@ -67,7 +75,7 @@ struct ContentView: View {
await setup.prepareLocalServices()
} else {
ProxyManager.shared.stop()
setup.completeSetup()
BackgroundKeepAlive.shared.stop()
RuntimeLogger.info("APP", "Startup", "第三方代理测试模式:跳过本地 CA、代理和环境检测")
}
do {
@@ -118,26 +126,89 @@ struct ContentView: View {
}
guard !Task.isCancelled else { return }
if launchMode == .thirdParty {
setup.completeSetup()
} else if verifiedDuringInitialSetup {
verifiedDuringInitialSetup = false
setup.completeSetup()
} else if setupCompleted {
let result = await setup.runVerificationTest()
setup.applyVerificationResult(result)
} else {
setup.requestSetup()
}
setup.completeSetup()
RuntimeLogger.info("APP", "Startup", "启动门禁全部完成,现在创建 MapHomeView")
phase = .map
}
private func finishInitialSetup() {
verifiedDuringInitialSetup = runtimeMode.mode == .localWiFi
setupCompleted = true
let completedMode = runtimeMode.mode
runtimeMode.markInitialized(completedMode)
setup.completeSetup()
phase = .splash
Task { await bootstrap() }
}
private func finishPresentedSetup() {
runtimeMode.markInitialized(runtimeMode.mode)
setup.completeSetup()
}
@MainActor
private func checkForUpdates() async {
let currentVersion = Bundle.main.object(
forInfoDictionaryKey: "CFBundleShortVersionString"
) as? String ?? AppRemoteConfiguration.fallback.latestVersion
let configuration: AppRemoteConfiguration
if let remote = await AppRemoteConfigurationService.fetch() {
remoteConfiguration.apply(remote)
configuration = remote
} else {
configuration = remoteConfiguration.configuration
}
guard let pendingPrompt = configuration.updatePrompt(currentVersion: currentVersion) else { return }
let releaseNotes = await AppRemoteConfigurationService.fetchReleaseNotes(
version: pendingPrompt.latestVersion
)
guard !Task.isCancelled,
let prompt = configuration.updatePrompt(
currentVersion: currentVersion,
releaseNotes: releaseNotes
) else {
return
}
if prompt.requirement == .required {
requiredUpdatePrompt = prompt
}
updatePrompt = prompt
}
private func updateAlert(for prompt: AppUpdatePrompt) -> Alert {
switch prompt.requirement {
case .required:
let details = prompt.releaseNotes
?? "更新说明暂时无法加载,请前往最新 Release 页面查看。"
return Alert(
title: Text("需要更新"),
message: Text(
"当前版本 \(prompt.currentVersion) 已停止支持,请更新到 \(prompt.latestVersion) 后继续使用。\n\n\(details)"
),
dismissButton: .default(Text("立即更新")) {
openUpdatePage(requiredPrompt: prompt)
}
)
case .recommended:
let details = prompt.releaseNotes
?? "更新说明暂时无法加载,请前往最新 Release 页面查看。"
return Alert(
title: Text("发现新版本"),
message: Text(
"当前版本 \(prompt.currentVersion),最新版本 \(prompt.latestVersion)\n\n\(details)"
),
primaryButton: .default(Text("前往更新")) {
openUpdatePage(requiredPrompt: nil)
},
secondaryButton: .cancel(Text("稍后"))
)
}
}
private func openUpdatePage(requiredPrompt: AppUpdatePrompt?) {
UIApplication.shared.open(AppRemoteConfigurationService.releasesURL)
guard requiredPrompt != nil else { return }
Task { @MainActor in
try? await Task.sleep(nanoseconds: 800_000_000)
updatePrompt = self.requiredUpdatePrompt
}
}
}
+406 -83
View File
@@ -1,4 +1,20 @@
import SwiftUI
import UIKit
private struct SetupScreenshotPreview: Identifiable {
let id = UUID()
let image: UIImage
let title: String
}
private struct CertificateDownloadDestination: Identifiable {
let id = UUID()
let url: URL
}
private struct ThirdPartyConnectionTestFailure {
let message: String
}
enum SetupStep: Int, CaseIterable {
case mode
@@ -6,7 +22,6 @@ enum SetupStep: Int, CaseIterable {
case cert
case thirdPartyClient
case thirdPartyImport
case thirdPartyTest
var title: String {
switch self {
@@ -14,8 +29,7 @@ enum SetupStep: Int, CaseIterable {
case .proxy: return "配置 Wi-Fi 代理"
case .cert: return "初始化 CA 证书"
case .thirdPartyClient: return "选择客户端"
case .thirdPartyImport: return "导入配置"
case .thirdPartyTest: return "连接检测"
case .thirdPartyImport: return "导入并检测"
}
}
}
@@ -38,13 +52,30 @@ 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?
@State private var certificateDownloadDestination: CertificateDownloadDestination?
@State private var thirdPartyTestFailure: ThirdPartyConnectionTestFailure?
@State private var showThirdPartyRepairReason: Bool
@State private var showsVerificationResult: Bool
@State private var showsThirdPartyFailureLog: Bool
init(setup: SetupCoordinator, onComplete: @escaping () -> Void) {
self.setup = setup
self.onComplete = onComplete
_step = State(initialValue: setup.setupStep)
_showThirdPartyRepairReason = State(
initialValue: setup.setupStep == .thirdPartyImport && !setup.message.isEmpty
)
_showsVerificationResult = State(
initialValue: [.proxy, .cert].contains(setup.setupStep)
&& setup.lastVerificationResult != nil
)
_showsThirdPartyFailureLog = State(
initialValue: setup.setupStep == .thirdPartyImport && !setup.message.isEmpty
)
}
private var certificateStepsComplete: Bool { downloadedDone && installedDone && trustedDone }
@@ -56,30 +87,49 @@ struct FirstSetupView: View {
NavigationView {
VStack(spacing: 0) {
progress
ScrollView {
VStack(alignment: .leading, spacing: 20) {
switch step {
case .mode: modeStep
case .proxy: proxyStep
case .cert: certificateStep
case .thirdPartyClient: thirdPartyClientStep
case .thirdPartyImport: thirdPartyImportStep
case .thirdPartyTest: thirdPartyTestStep
ScrollViewReader { scrollProxy in
ScrollView {
VStack(alignment: .leading, spacing: 20) {
switch step {
case .mode: modeStep
case .proxy: proxyStep
case .cert: certificateStep
case .thirdPartyClient: thirdPartyClientStep
case .thirdPartyImport: thirdPartyImportStep
}
if let displayedVerificationResult { resultView(displayedVerificationResult) }
}
if let result { resultView(result) }
.padding(20)
}
.onChange(of: thirdPartyFailureLog) { failureLog in
guard step == .thirdPartyImport, failureLog != nil else { return }
DispatchQueue.main.async {
withAnimation {
scrollProxy.scrollTo("thirdPartyFailureLog", anchor: .bottom)
}
}
}
.onChange(of: step) { _ in
showsVerificationResult = false
showsThirdPartyFailureLog = false
}
.padding(20)
}
Divider()
VStack(spacing: 10) {
primaryAction
if step != .mode {
Button("上一步") { returnToPreviousStep() }
.disabled(isVerifying || thirdPartyProxy.isRequesting)
if step != .mode {
HStack(spacing: 12) {
Button {
returnToPreviousStep()
} label: {
Label("上一步", systemImage: "chevron.left")
}
.buttonStyle(.bordered)
.disabled(isVerifying || thirdPartyProxy.isRequesting)
Spacer(minLength: 12)
primaryAction
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
}
.navigationTitle("开始使用")
.navigationBarTitleDisplayMode(.inline)
@@ -92,6 +142,28 @@ struct FirstSetupView: View {
)
}
}
.sheet(item: $screenshotPreview) { preview in
NavigationView {
ScrollView {
Image(uiImage: preview.image)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding()
}
.navigationTitle(preview.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("完成") { screenshotPreview = nil }
}
}
}
}
.sheet(item: $certificateDownloadDestination) { destination in
SafariView(url: destination.url)
.ignoresSafeArea()
}
.alert("无法直接跳转", isPresented: Binding(
get: { !manualHint.isEmpty },
set: { if !$0 { manualHint = "" } }
@@ -133,11 +205,33 @@ struct FirstSetupView: View {
return [.mode]
case .proxy, .cert:
return [.mode, .proxy, .cert]
case .thirdPartyClient, .thirdPartyImport, .thirdPartyTest:
return [.mode, .thirdPartyClient, .thirdPartyImport, .thirdPartyTest]
case .thirdPartyClient, .thirdPartyImport:
return [.mode, .thirdPartyClient, .thirdPartyImport]
}
}
private var displayedVerificationResult: VerificationResult? {
guard showsVerificationResult else { return nil }
return result ?? setup.lastVerificationResult
}
private var thirdPartyFailureLog: String? {
guard showsThirdPartyFailureLog else { return nil }
if let thirdPartyTestFailure {
return thirdPartyTestFailure.message
}
guard !setup.message.isEmpty else { return nil }
return """
======== 第三方代理运行检测 ========
当前客户端:\(thirdPartyClient.selectedClient.name)
触发来源:地图或设置中的第三方代理操作
请求动作:WLOC 配置接口
检测结果:失败
错误详情:\(setup.message)
处理建议:确认模块已启用,并检查 MITM、证书和代理/VPN 连接。
"""
}
private var modeStep: some View {
VStack(alignment: .leading, spacing: 16) {
Text("选择运行模式")
@@ -217,12 +311,23 @@ struct FirstSetupView: View {
private var proxyStep: some View {
VStack(alignment: .leading, spacing: 16) {
if !setup.message.isEmpty {
Label(setup.message, systemImage: "exclamationmark.triangle.fill")
.font(.footnote)
.foregroundStyle(.orange)
.frame(maxWidth: .infinity, alignment: .leading)
}
GroupBox(label: Label("先配置 Wi-Fi 系统代理", systemImage: "wifi")) {
Text("在当前 Wi-Fi 的详情页,将「HTTP 代理」设为「手动」:服务器填 127.0.0.1,端口填 8888。完成后点击下方「我已配置,开始检测」。检测会自动判断是 Wi-Fi 代理还是证书信任有问题。")
Text("在当前 Wi-Fi 的详情页,将「HTTP 代理」设为「手动」:服务器填 127.0.0.1,端口填 8888。配置后点击下方「完成」。检测会自动判断是 Wi-Fi 代理还是证书信任有问题。")
.font(.caption).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 4)
}
setupScreenshot(
assetName: "AppModeWiFiProxy",
title: "Wi-Fi 代理设置",
caption: "1 选择手动,2 填写服务器 127.0.0.13 填写端口 8888。"
)
HStack(spacing: 12) {
Button { UIPasteboard.general.string = "127.0.0.1:8888" } label: {
Label("复制地址", systemImage: "doc.on.doc").frame(maxWidth: .infinity)
@@ -241,15 +346,16 @@ struct FirstSetupView: View {
certificateCard(
title: "第 1 步:下载证书",
icon: "arrow.down.circle",
description: "下载本机随机生成的 CA 根证书。私钥仅保存在此设备的钥匙串中,不会随证书文件导出。Safari 出现配置描述文件下载提示时,选择「允许」。",
actionTitle: "下载",
description: "下载本机随机生成的 CA 根证书。私钥仅保存在此设备的钥匙串中,不会随证书文件导出。App 会弹出 Safari 下载页;出现配置描述文件下载提示时,选择「允许」。",
actionTitle: "打开下载",
actionIcon: "arrow.down.circle.fill",
complete: downloadedDone,
action: {
Task {
let opened = await setup.proxy.openCertificateDownload()
if !opened {
setupActionError = setup.proxy.error ?? "无法打开证书下载页面,请查看诊断日志"
if let url = await setup.proxy.prepareCertificateDownloadURL() {
certificateDownloadDestination = CertificateDownloadDestination(url: url)
} else {
setupActionError = setup.proxy.error ?? "无法准备证书下载页面,请查看诊断日志"
}
}
},
@@ -258,28 +364,43 @@ struct FirstSetupView: View {
certificateCard(
title: "第 2 步:安装证书",
icon: "square.and.arrow.down",
description: "下载完成后打开系统「设置」。如果顶部显示「已下载描述文件」,点进去安装;否则进入「通用 → VPN 与设备管理」,找到 WLOC CA 并完成安装。",
description: "下载完成后打开系统「设置」。如果顶部显示「已下载描述文件」,点进去安装;否则进入「通用 → VPN 与设备管理」,找到 Location Spoofer CA 并完成安装。",
actionTitle: "去安装",
actionIcon: "gearshape",
complete: installedDone,
action: { openSettings(.general) },
markComplete: { installedDone = true }
)
setupScreenshot(
assetName: "AppModeCertificateInstall",
title: "安装证书",
caption: "1 在「VPN 与设备管理」中打开 Location Spoofer CA 描述文件并完成安装。"
)
certificateCard(
title: "第 3 步:信任证书",
icon: "shield.checkered",
description: "安装后进入「设置 → 通用 → 关于本机 → 证书信任设置」,找到 WLOC CA 并开启完全信任。iOS 保留钥匙串数据时,重装 App 会继续复用同一证书。",
description: "安装后进入「设置 → 通用 → 关于本机 → 证书信任设置」,找到 Location Spoofer CA 并开启完全信任。iOS 保留钥匙串数据时,重装 App 会继续复用同一证书。",
actionTitle: "去信任",
actionIcon: "shield.checkered",
complete: trustedDone,
action: { openSettings(.general) },
markComplete: { trustedDone = true }
)
setupScreenshot(
assetName: "AppModeCertificateTrust",
title: "信任证书",
caption: "1 在「证书信任设置」中为 Location Spoofer CA 开启完全信任。"
)
}
}
private var thirdPartyClientStep: some View {
VStack(alignment: .leading, spacing: 16) {
if !setup.message.isEmpty {
Label(setup.message, systemImage: "exclamationmark.triangle.fill")
.font(.footnote)
.foregroundStyle(.orange)
}
GroupBox(label: Label("选择第三方代理客户端", systemImage: "app.badge.checkmark")) {
VStack(spacing: 0) {
ForEach(ThirdPartyProxyClient.allCases) { client in
@@ -289,9 +410,11 @@ struct FirstSetupView: View {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(client.name).foregroundStyle(.primary)
Text(client.verificationText)
.font(.caption2)
.foregroundStyle(client == .shadowrocket ? .green : .orange)
if let verificationText = client.verificationText {
Text(verificationText)
.font(.caption2)
.foregroundStyle(.orange)
}
}
Spacer()
Image(systemName: thirdPartyClient.selectedClient == client ? "checkmark.circle.fill" : "circle")
@@ -305,30 +428,79 @@ struct FirstSetupView: View {
}
}
Text("Egern 直接使用 Surge 模块。Stash 直接订阅 .stoverride,不需要 Script Hub 转换。除 Shadowrocket 外,当前仅提供配置尚未完成真机验证。")
Text("除 Shadowrocket 外,当前客户端配置尚未完成真机验证,页面只提供模块导入入口和通用配置提醒")
.font(.footnote)
.foregroundStyle(.secondary)
DisclosureGroup("第三方客户端适配说明") {
VStack(alignment: .leading, spacing: 12) {
Text("工作原理")
.font(.subheadline.bold())
Text("App 不连接远程坐标服务器,而是向 Apple 域名发起一个约定请求。第三方客户端需要在本机拦截该请求、保存 WGS-84 坐标并返回 JSON;定位模块再读取同一份数据,修改 Apple WLOC 响应。")
.font(.footnote)
.foregroundStyle(.secondary)
Text("配置接口")
.font(.subheadline.bold())
Text(ThirdPartyProxyManager.configurationEndpoint.absoluteString)
.font(.caption.monospaced())
.textSelection(.enabled)
Text("""
查询:GET ?action=query
保存:GET ?lon=<经度>&lat=<纬度>&acc=<精度>
清除:GET ?action=clear
""")
.font(.caption.monospaced())
.textSelection(.enabled)
Text("返回格式")
.font(.subheadline.bold())
Text("""
成功:{"success":true,"longitude":113.0,"latitude":22.0,"accuracy":25}
失败:{"success":false,"error":""}
""")
.font(.caption.monospaced())
.textSelection(.enabled)
Text("适配要求")
.font(.subheadline.bold())
Text("客户端需要支持请求脚本、持久化存储、HTTP 200 JSON 响应、Apple WLOC 响应脚本,以及 gs-loc.apple.com / gs-loc-cn.apple.com 的 HTTPS 解密。保存接口和 WLOC 响应脚本必须读取同一份持久化数据。")
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(.top, 8)
}
}
}
private var thirdPartyImportStep: some View {
let client = thirdPartyClient.selectedClient
return VStack(alignment: .leading, spacing: 16) {
if showThirdPartyRepairReason {
Label(
"检测到第三方代理连接异常,请检查模块、MITM 和代理连接后重新检测。",
systemImage: "exclamationmark.triangle.fill"
)
.font(.footnote)
.foregroundStyle(.orange)
.frame(maxWidth: .infinity, alignment: .leading)
}
GroupBox(label: Label("第 1 步:导入 \(client.name) 模块", systemImage: "square.and.arrow.down")) {
VStack(alignment: .leading, spacing: 12) {
Text(importInstructions(for: client))
.font(.caption)
.foregroundStyle(.secondary)
instructionRow(1, "复制 \(client.name) 的模块订阅地址。")
Button {
UIPasteboard.general.string = client.subscriptionURL.absoluteString
copiedSubscriptionURL = true
} label: {
Label(copiedSubscriptionURL ? "已复制订阅地址" : "复制订阅地址", systemImage: "doc.on.doc")
Label(copiedSubscriptionURL ? "已复制模块订阅地址" : "复制模块订阅地址", systemImage: "doc.on.doc")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
instructionRow(2, client == .shadowrocket
? "打开 Shadowrocket,进入“配置 → 模块”。"
: "打开 \(client.name)")
Button {
openThirdPartyClient(client)
} label: {
@@ -336,15 +508,53 @@ struct FirstSetupView: View {
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
if client == .shadowrocket {
setupScreenshot(
assetName: "ShadowrocketConfigDetails",
title: "进入 Shadowrocket 配置",
caption: "1 点击「模块」进入模块列表,2 可打开当前本地配置详情。"
)
}
if client == .shadowrocket {
instructionRow(3, "点击右上角“+”,粘贴模块订阅地址并导入,然后确认模块已启用。")
setupScreenshot(
assetName: "ShadowrocketModuleImport",
title: "导入 Shadowrocket 模块",
caption: "1 点击右上角加号导入模块,2 确认模块已启用。"
)
} else {
instructionRow(3, "\(client.name) 中导入刚才复制的模块订阅地址。")
}
}
}
if client == .shadowrocket {
shadowrocketHTTPSDecryptionGuide
} else {
Text("请复制订阅地址,在客户端的模块、重写或覆写订阅入口中添加。证书、MITM、VPN 和代理连接请按第三方客户端自己的流程配置")
.font(.footnote)
.foregroundStyle(.secondary)
GroupBox(label: Label("第 2 步:完成 \(client.name) 配置", systemImage: "slider.horizontal.3")) {
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
}
}
}
if let thirdPartyFailureLog {
testResultView(
success: false,
title: "接口连接失败",
log: thirdPartyFailureLog
)
.id("thirdPartyFailureLog")
}
}
}
@@ -354,18 +564,21 @@ 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 和 gs-loc-cn.apple.com3 打开证书设置。"
)
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(
assetName: "ShadowrocketHTTPSCA",
title: "授权 Shadowrocket 证书",
caption: "1 打开 Shadowrocket 证书项并按提示安装、授权。"
)
instructionRow(5, "返回 HTTPS 解密页面,点击右上角勾号保存,然后开启代理。")
Button {
@@ -383,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)")
@@ -397,11 +624,44 @@ struct FirstSetupView: View {
}
}
private func importInstructions(for client: ThirdPartyProxyClient) -> String {
if client == .shadowrocket {
return "先复制订阅地址。然后打开 Shadowrocket,进入“配置 → 模块”,点击右上角“+”,粘贴订阅地址并完成导入。导入完成后,模块配置由 Shadowrocket 保存,不依赖本 App 持续运行。"
@ViewBuilder
private func setupScreenshot(
assetName: String,
title: String,
caption: String
) -> some View {
if let image = UIImage(named: assetName) {
Button {
screenshotPreview = SetupScreenshotPreview(
image: image,
title: title
)
} label: {
VStack(alignment: .leading, spacing: 8) {
Image(uiImage: image)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 6))
HStack(spacing: 6) {
Image(systemName: "arrow.up.left.and.arrow.down.right")
Text(caption)
}
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
}
.padding(8)
.background(Color(uiColor: .secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.secondary.opacity(0.18))
)
}
.buttonStyle(.plain)
.accessibilityLabel("\(title)\(caption)")
.accessibilityHint("轻点查看大图")
}
return "先复制订阅地址,然后打开 \(client.name),在模块、重写或覆写订阅入口中粘贴并导入。配置由 \(client.name) 保存,不依赖本 App 持续运行。"
}
private func openThirdPartyClient(_ client: ThirdPartyProxyClient) {
@@ -414,18 +674,6 @@ struct FirstSetupView: View {
}
}
private var thirdPartyTestStep: some View {
VStack(alignment: .leading, spacing: 16) {
GroupBox(label: Label("检测配置接口", systemImage: "network")) {
Text("请先在第三方客户端中启用刚导入的配置,并按客户端要求完成 MITM、证书和代理/VPN 连接。App 只调用 WLOC 查询接口确认模块能否正常响应,不检查或管理第三方证书。")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 4)
}
}
}
private func returnToPreviousStep() {
result = nil
setupActionError = ""
@@ -437,9 +685,8 @@ struct FirstSetupView: View {
case .cert:
step = .proxy
case .thirdPartyImport:
thirdPartyTestFailure = nil
step = .thirdPartyClient
case .thirdPartyTest:
step = .thirdPartyImport
}
}
@@ -483,12 +730,20 @@ struct FirstSetupView: View {
@ViewBuilder
private func resultView(_ result: VerificationResult) -> some View {
let success = result.isSuccess
testResultView(
success: success,
title: success ? "环境检测通过" : failureSummary(result),
log: setup.testLog
)
}
private func testResultView(success: Bool, title: String, log: String) -> some View {
VStack(alignment: .leading, spacing: 10) {
Label(success ? "环境检测通过" : failureSummary(result), systemImage: success ? "checkmark.circle.fill" : "xmark.circle.fill")
Label(title, systemImage: success ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(success ? .green : .red)
.font(.subheadline.weight(.semibold))
if !success {
Text(setup.testLog).font(.caption.monospaced()).textSelection(.enabled)
Text(log).font(.caption.monospaced()).textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(8)
Button {
@@ -512,7 +767,7 @@ struct FirstSetupView: View {
Button {
verifyAfterProxyConfirmation()
} label: {
actionLabel("我已配置,开始检测")
actionLabel("完成")
}
.buttonStyle(.borderedProminent)
.disabled(isVerifying)
@@ -520,23 +775,22 @@ struct FirstSetupView: View {
Button {
verifyAfterCertificateConfirmation()
} label: {
actionLabel("确认完成,重新检测")
actionLabel("完成")
}
.buttonStyle(.borderedProminent)
.disabled(!certificateStepsComplete || isVerifying)
} else if step == .thirdPartyClient {
Button("下一步:导入配置") { step = .thirdPartyImport }
.frame(maxWidth: .infinity)
.buttonStyle(.borderedProminent)
} else if step == .thirdPartyImport {
Button("我已导入,下一步") { step = .thirdPartyTest }
.frame(maxWidth: .infinity)
Button {
step = .thirdPartyImport
} label: {
actionLabel("完成")
}
.buttonStyle(.borderedProminent)
} else {
Button {
verifyThirdPartyConnection()
} label: {
actionLabel("检测接口连接")
actionLabel("完成")
}
.buttonStyle(.borderedProminent)
.disabled(isVerifying || thirdPartyProxy.isRequesting)
@@ -564,23 +818,90 @@ struct FirstSetupView: View {
private func verifyThirdPartyConnection() {
guard !isVerifying else { return }
let client = thirdPartyClient.selectedClient
let startedAt = Date()
isVerifying = true
result = nil
thirdPartyTestFailure = nil
showsThirdPartyFailureLog = false
setup.message = ""
RuntimeLogger.info("APP", "ThirdPartyProxy", "开始第三方代理连接检测", details: [
"当前客户端": client.name,
"请求动作": "WLOC query",
"检查范围": "模块拦截、MITM、代理/VPN连接"
])
Task { @MainActor in
defer { isVerifying = false }
do {
_ = 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()
} catch {
setupActionError = error.localizedDescription
let elapsedMilliseconds = Int(Date().timeIntervalSince(startedAt) * 1_000)
let connectionState = thirdPartyConnectionStateDescription
let errorType = String(describing: type(of: error))
RuntimeLogger.error(
"APP",
"ThirdPartyProxy",
"第三方代理连接检测失败",
error: error,
details: [
"当前客户端": client.name,
"请求动作": "WLOC query",
"连接状态": connectionState,
"耗时毫秒": String(elapsedMilliseconds),
"错误类型": errorType,
"处理建议": ThirdPartyProxyError.recoverySuggestion(for: error)
]
)
thirdPartyTestFailure = ThirdPartyConnectionTestFailure(
message: """
======== 第三方代理连接检测 ========
当前客户端:\(client.name)
配置接口:/wloc-settings/save
请求动作:WLOC query
检查范围:模块拦截、MITM、证书、代理/VPN 连接
连接状态:\(connectionState)
检测结果:失败
耗时:\(elapsedMilliseconds) ms
错误类型:\(errorType)
错误详情:\(error.localizedDescription)
处理建议:\(ThirdPartyProxyError.recoverySuggestion(for: error))
"""
)
showsThirdPartyFailureLog = true
}
}
}
private var thirdPartyConnectionStateDescription: String {
switch thirdPartyProxy.connectionState {
case .unknown:
return "未检测"
case .connected(let active):
return active ? "已连接,有保存坐标" : "已连接,无保存坐标"
case .failed(let message):
return "连接失败(\(message)"
}
}
private func actionLabel(_ title: String) -> some View {
HStack {
if isVerifying { ProgressView().tint(.white).controlSize(.small) }
Text(title).frame(maxWidth: .infinity)
Text(title)
.lineLimit(1)
.minimumScaleFactor(0.78)
}
}
@@ -610,11 +931,13 @@ struct FirstSetupView: View {
guard !isVerifying else { return }
isVerifying = true
result = nil
showsVerificationResult = false
Task {
let verification = await setup.runVerificationTest()
setup.applyVerificationResult(verification)
guard !Task.isCancelled else { return }
result = verification
showsVerificationResult = true
isVerifying = false
completion(verification)
}
+120 -21
View File
@@ -53,6 +53,8 @@ struct MapHomeView: View {
@ObservedObject private var proxy = ProxyManager.shared
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
@ObservedObject private var remoteConfiguration = AppRemoteConfigurationStore.shared
@StateObject private var realtime = RealtimeLocationManager.shared
@StateObject private var mapState: MapLocationState
@ObservedObject private var net = NetworkMonitor.shared
@@ -69,6 +71,11 @@ struct MapHomeView: View {
@State private var activeTip: TipKind?
@State private var manualHint = ""
private let tipPreferences = VirtualLocationTipPreferences()
private let communityPromptPreferences = ThirdPartyCommunityPromptPreferences()
@State private var pendingCommunityContributionClient: ThirdPartyProxyClient?
@State private var communityContributionClient: ThirdPartyProxyClient?
@State private var showCommunityTemplateCopied = false
@State private var githubDestination: SafariDestination?
@State private var editingFavorite: FavoriteLocation?
@State private var editName = ""
@State private var reverseGeocodeTask: Task<Void, Never>?
@@ -261,6 +268,47 @@ struct MapHomeView: View {
.sheet(item: $activeTip) { kind in
TipSheetView(kind: kind, runtimeMode: runtimeMode.mode)
}
.sheet(item: $githubDestination) { destination in
SafariView(url: destination.url)
.ignoresSafeArea()
}
.alert("社区分享成功配置?", isPresented: Binding(
get: { communityContributionClient != nil },
set: { if !$0 { communityContributionClient = nil } }
)) {
Button("去提交") {
guard let client = communityContributionClient else { return }
UIPasteboard.general.string = GitHubSubmission.communityContributionTemplate(
for: client,
systemVersion: UIDevice.current.systemVersion
)
openCommunityContributionPage()
}
Button("复制模板") {
guard let client = communityContributionClient else { return }
UIPasteboard.general.string = GitHubSubmission.communityContributionTemplate(
for: client,
systemVersion: UIDevice.current.systemVersion
)
showCommunityTemplateCopied = true
}
if communityPromptPreferences.canSuppress() {
Button("不再提示", role: .cancel) {
communityPromptPreferences.suppress()
}
} else {
Button("取消", role: .cancel) {}
}
} message: {
Text(
"你正在使用 \(communityContributionClient?.name ?? "第三方客户端")。点击“去提交”会先复制投稿模板,并在 App 内打开社区页面。采纳后将收录到 README,可选择是否匿名署名。"
)
}
.alert("已复制投稿模板", isPresented: $showCommunityTemplateCopied) {
Button("知道了", role: .cancel) {}
} message: {
Text("如果 GitHub 登录或浏览器跳转后模板没有自动填充,可以直接粘贴。")
}
.alert("无法直接跳转", isPresented: Binding(
get: { !manualHint.isEmpty },
set: { if !$0 { manualHint = "" } }
@@ -269,8 +317,6 @@ struct MapHomeView: View {
} message: { Text(manualHint) }
.onAppear {
startMapRuntimeOnce()
// ContentView performs the startup environment test before this map
// view is constructed. Do not immediately run it again here.
if runtimeMode.mode == .localWiFi {
registerWiFiChangeObserver()
} else {
@@ -305,6 +351,13 @@ struct MapHomeView: View {
actions.clear()
}
}
.onChange(of: showEnableTip) { isPresented in
guard !isPresented, let client = pendingCommunityContributionClient else { return }
pendingCommunityContributionClient = nil
DispatchQueue.main.async {
communityContributionClient = client
}
}
.onChange(of: runtimeMode.mode) { mode in
locationOperationTask?.cancel()
locationOperationTask = nil
@@ -588,17 +641,31 @@ struct MapHomeView: View {
activeSpoofLat = response.latitude
activeSpoofLon = response.longitude
RuntimeLogger.info("APP", "定位", "第三方代理坐标同步成功", details: [
"当前客户端": thirdPartyClient.selectedClient.name,
"坐标标准": "WGS-84",
"客户端模式": "测试模式",
"选点期间发生变化": String(selectionRevision != mapState.selection.revision)
])
presentSuccessfulOperationTip(.activation)
queueCommunityContributionPrompt(for: thirdPartyClient.selectedClient)
} catch {
guard operationID == locationOperationID else { return }
// A failed replacement does not clear the coordinate that
// was already persisted inside the third-party client.
spoofState = wasActive ? .active : .idle
manualHint = error.localizedDescription
RuntimeLogger.error(
"APP",
"ThirdPartyProxy",
"同步坐标到第三方客户端失败",
error: error,
details: [
"当前客户端": thirdPartyClient.selectedClient.name,
"请求动作": "WLOC save",
"恢复状态": wasActive ? "保留原第三方坐标" : "保持未启用",
"处理建议": ThirdPartyProxyError.recoverySuggestion(for: error)
]
)
setup.requestThirdPartySetup(message: error.localizedDescription)
}
if operationID == locationOperationID {
locationOperationTask = nil
@@ -640,12 +707,13 @@ struct MapHomeView: View {
"result": result.id,
"spoofState": String(describing: spoofState)
])
if result == .certNotTrusted {
RuntimeLogger.warning("APP", "定位", "开启前检测发现证书异常,进入证书安装引导")
if result != .verificationInProgress,
result != .verificationSuperseded {
RuntimeLogger.warning("APP", "定位", "开启前检测失败,进入对应环境引导", details: [
"结果": result.id
])
activeTip = nil
setup.applyVerificationResult(result)
} else if let tip = result.tipKind {
activeTip = tip
}
}
locationOperationTask = nil
@@ -667,7 +735,19 @@ struct MapHomeView: View {
presentSuccessfulOperationTip(.deactivation)
} catch {
spoofState = .active
manualHint = error.localizedDescription
RuntimeLogger.error(
"APP",
"ThirdPartyProxy",
"清除第三方客户端坐标失败",
error: error,
details: [
"当前客户端": thirdPartyClient.selectedClient.name,
"请求动作": "WLOC clear",
"恢复状态": "保留已启用状态",
"处理建议": ThirdPartyProxyError.recoverySuggestion(for: error)
]
)
setup.requestThirdPartySetup(message: error.localizedDescription)
}
locationOperationTask = nil
}
@@ -700,6 +780,23 @@ struct MapHomeView: View {
}
}
private func queueCommunityContributionPrompt(for client: ThirdPartyProxyClient) {
guard remoteConfiguration.requestsCommunityPrompt(for: client),
communityPromptPreferences.shouldPresent() else {
return
}
communityPromptPreferences.recordPresentation()
if showEnableTip {
pendingCommunityContributionClient = client
} else {
communityContributionClient = client
}
}
private func openCommunityContributionPage() {
githubDestination = SafariDestination(url: GitHubSubmission.communityContributionURL)
}
private func favoriteChip(_ f: FavoriteLocation) -> some View {
HStack(spacing: 0) {
@@ -990,14 +1087,21 @@ struct MapHomeView: View {
} else {
spoofState = .idle
RuntimeLogger.warning("APP", "ThirdPartyProxy", "第三方代理查询返回失败", details: [
"当前客户端": thirdPartyClient.selectedClient.name,
"请求动作": "WLOC query",
"错误": response.error ?? "未知错误"
])
setup.requestThirdPartySetup(message: response.error ?? "第三方代理查询失败")
}
} catch {
spoofState = .idle
RuntimeLogger.warning("APP", "ThirdPartyProxy", "启动后第三方代理状态查询失败", details: [
"当前客户端": thirdPartyClient.selectedClient.name,
"请求动作": "WLOC query",
"连接状态": String(describing: thirdPartyProxy.connectionState),
"错误": error.localizedDescription
])
setup.requestThirdPartySetup(message: error.localizedDescription)
}
locationOperationTask = nil
}
@@ -1039,7 +1143,8 @@ struct MapHomeView: View {
"网络可用": String(net.isSatisfied),
"Wi-Fi接口": String(net.isWiFiEnabled)
])
activeTip = .proxySetup
activeTip = nil
setup.requestSetup(message: "当前未连接可用的 Wi-Fi,请连接 Wi-Fi 后配置 127.0.0.1:8888 手动代理。")
return
}
@@ -1066,21 +1171,15 @@ struct MapHomeView: View {
continue
}
if result == .certNotTrusted {
RuntimeLogger.warning("APP", "WiFi", "后台环境检测发现证书异常,进入证书安装引导")
activeTip = nil
setup.applyVerificationResult(result)
return
}
let tip = result.wifiChangeReminderTipKind
RuntimeLogger.info("APP", "WiFi", "后台环境检测完成", details: [
"结果": result.id,
"success": String(result.isSuccess),
"提示": tip?.rawValue ?? ""
"success": String(result.isSuccess)
])
if !result.isSuccess, let tip {
activeTip = tip
if !result.isSuccess,
result != .verificationInProgress,
result != .verificationSuperseded {
activeTip = nil
setup.applyVerificationResult(result)
} else if result == .verificationInProgress {
RuntimeLogger.warning("APP", "WiFi", "环境检测连续被占用,本次不重复弹窗")
}
+38 -19
View File
@@ -25,12 +25,21 @@ final class ProxyManager: ObservableObject {
let lon = settings.flatMap { $0.enabled ? $0.longitude : nil } ?? 0
let enabled = (settings?.enabled ?? false) ? CInt(1) : CInt(0)
let accuracy = CInt(settings?.accuracy ?? 25)
let motionEnabled = MotionSimulationStore.shared.isEnabled ? CInt(1) : CInt(0)
if enabled != 0 {
RuntimeLogger.info("APP", "坐标转换", "启动代理: 恢复上次 WGS-84 定位")
}
let result: UInt = authority.certPEM.withCString { cp in
authority.keyPEM.withCString { kp in
UInt(wloccore_startproxy(UnsafeMutablePointer(mutating: cp), UnsafeMutablePointer(mutating: kp), CDouble(lat), CDouble(lon), enabled, accuracy))
UInt(wloccore_startproxyv2(
UnsafeMutablePointer(mutating: cp),
UnsafeMutablePointer(mutating: kp),
CDouble(lat),
CDouble(lon),
enabled,
accuracy,
motionEnabled
))
}
}
guard result != 0 else { CoreBridge.flushLogs(category: "Proxy"); throw ProxyError.startFailed }
@@ -58,7 +67,13 @@ final class ProxyManager: ObservableObject {
@discardableResult
func setCoords(lat: Double, lon: Double, enabled: Bool, accuracy: Int = 25) -> UInt64 {
coordinateRevision &+= 1
wloccore_setcoords(CDouble(lat), CDouble(lon), enabled ? 1 : 0, CInt(accuracy))
wloccore_setpatchconfig(
CDouble(lat),
CDouble(lon),
enabled ? 1 : 0,
CInt(accuracy),
MotionSimulationStore.shared.isEnabled ? 1 : 0
)
RuntimeLogger.info("APP", "Proxy.coords", "写入坐标", details: [
"revision": String(coordinateRevision),
"enabled": String(enabled),
@@ -119,31 +134,35 @@ final class ProxyManager: ObservableObject {
return (Double(r.r0), Double(r.r1), r.r2 != 0)
}
@discardableResult
func openCertificateDownload() async -> Bool {
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() }
// CA
guard let url = URL(string: "http://127.0.0.1:8888/cert") else {
error = "证书下载地址无效"
return false
}
let opened = await withCheckedContinuation { continuation in
UIApplication.shared.open(url, options: [:]) { accepted in
continuation.resume(returning: accepted)
}
}
guard opened else {
error = "系统无法打开证书下载页面,请稍后重试"
RuntimeLogger.warning("APP", "Certificate", "系统拒绝打开证书下载地址")
return false
return nil
}
error = nil
return true
return url
} catch {
self.error = "启动代理失败: \(error.localizedDescription)"
RuntimeLogger.error("APP", "Certificate", "打开证书下载失败", error: error)
return false
RuntimeLogger.error("APP", "Certificate", "准备证书下载失败", error: error)
return nil
}
}
+19
View File
@@ -0,0 +1,19 @@
import SafariServices
import SwiftUI
struct SafariDestination: Identifiable {
let id = UUID()
let url: URL
}
struct SafariView: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context: Context) -> SFSafariViewController {
let controller = SFSafariViewController(url: url)
controller.dismissButtonStyle = .close
return controller
}
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {}
}
+381 -42
View File
@@ -1,5 +1,22 @@
import SwiftUI
private enum UpdateCheckResult: Identifiable {
case current(currentVersion: String, latestVersion: String)
case available(AppUpdatePrompt)
case failed
var id: String {
switch self {
case .current(let currentVersion, let latestVersion):
return "current-\(currentVersion)-\(latestVersion)"
case .available(let prompt):
return "available-\(prompt.id)"
case .failed:
return "failed"
}
}
}
struct SettingsView: View {
@ObservedObject var setup: SetupCoordinator
@ObservedObject var actions: LocationActionCoordinator
@@ -7,12 +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 {
@@ -25,11 +49,6 @@ struct SettingsView: View {
.pickerStyle(.inline)
.disabled(modeOperationRunning || actions.state.isBusy || thirdPartyProxy.isRequesting)
if runtimeMode.mode == .thirdParty {
Label("测试模式:仅 Shadowrocket 当前可测试", systemImage: "testtube.2")
.font(.footnote)
.foregroundStyle(.orange)
}
}
Section("状态") {
@@ -65,26 +84,39 @@ 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 {
Section("说明") {
Button {
activeTip = .activation
} label: {
Label("生效说明", systemImage: "checklist")
}
Button {
activeTip = .deactivation
} label: {
Label("失效说明", systemImage: "arrow.uturn.backward.circle")
}
Button {
activeTip = .removeProxy
} label: {
Label("关闭 WiFi 代理", systemImage: "wifi.slash")
}
Button {
activeTip = .activation
} label: {
Label("生效说明", systemImage: "checklist")
}
Button {
activeTip = .deactivation
} label: {
Label("失效说明", systemImage: "arrow.uturn.backward.circle")
}
Button {
activeTip = .removeProxy
} label: {
Label("关闭 WiFi 代理", systemImage: "wifi.slash")
}
}
}
Section("工作原理") {
@@ -101,14 +133,68 @@ 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)
}
if runtimeMode.mode == .localWiFi {
Section("证书") {
Button(role: .destructive) {
showCertificateResetConfirmation = true
} label: {
Label("重置证书", systemImage: "arrow.clockwise.circle")
}
.disabled(modeOperationRunning || actions.state.isBusy)
Text("仅删除 App 钥匙串中的设备 CA。iOS 中已经安装的旧证书需要在系统设置里手动移除。")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Section("支持") {
NavigationLink {
BugReportView(setup: setup)
} label: {
Label("报告 Issue", systemImage: "ladybug")
Label("报告 Bug", systemImage: "ladybug")
}
Button {
githubDestination = SafariDestination(url: GitHubSubmission.usageHelpURL)
} label: {
Label("使用帮助", systemImage: "questionmark.circle")
}
Button {
githubDestination = SafariDestination(url: GitHubSubmission.featureRequestURL)
} label: {
Label("功能建议", systemImage: "lightbulb")
}
if runtimeMode.mode == .thirdParty {
Button {
UIPasteboard.general.string = GitHubSubmission.communityContributionTemplate(
for: thirdPartyClient.selectedClient,
systemVersion: UIDevice.current.systemVersion
)
githubDestination = SafariDestination(
url: GitHubSubmission.communityContributionURL
)
} label: {
Label("分享第三方配置", systemImage: "square.and.arrow.up")
}
}
}
@@ -144,6 +230,10 @@ struct SettingsView: View {
.sheet(item: $activeTip) { kind in
TipSheetView(kind: kind)
}
.sheet(item: $githubDestination) { destination in
SafariView(url: destination.url)
.ignoresSafeArea()
}
.alert(proxyOperationAlertTitle, isPresented: Binding(
get: { !proxyOperationError.isEmpty },
set: { if !$0 { proxyOperationError = "" } }
@@ -152,6 +242,31 @@ struct SettingsView: View {
} message: {
Text(proxyOperationError)
}
.alert(item: $updateCheckResult) { result in
updateCheckAlert(for: result)
}
.confirmationDialog(
"重置证书?",
isPresented: $showCertificateResetConfirmation,
titleVisibility: .visible
) {
Button("重置并生成新证书", role: .destructive) {
resetCertificateAuthority()
}
Button("取消", role: .cancel) {}
} 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 {
@@ -164,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 {
@@ -193,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("第三方代理配置") {
@@ -205,19 +434,42 @@ struct SettingsView: View {
}
}
HStack {
Text("验证状态")
Spacer()
Text(thirdPartyClient.selectedClient.verificationText)
Toggle("使用国内镜像下载模块", isOn: Binding(
get: { moduleSource.useMirror },
set: { moduleSource.setUseMirror($0) }
))
Text("仅影响之后复制和重新导入的模块地址;已安装模块需要重新导入后切换来源。")
.font(.footnote)
.foregroundStyle(.secondary)
if thirdPartyProxy.moduleUpdateRecommended {
Text("当前模块版本较旧,基础坐标功能仍可继续使用。重新导入最新模块后可使用版本检测和运动状态模拟。")
.font(.footnote)
.foregroundStyle(thirdPartyClient.selectedClient == .shadowrocket ? .green : .orange)
.foregroundStyle(.orange)
}
if let verificationText = thirdPartyClient.selectedClient.verificationText {
HStack {
Text("验证状态")
Spacer()
Text(verificationText)
.font(.footnote)
.foregroundStyle(.orange)
}
}
Button {
UIPasteboard.general.string = thirdPartyClient.selectedClient.subscriptionURL.absoluteString
copiedClient = thirdPartyClient.selectedClient
} label: {
Label(copiedClient == thirdPartyClient.selectedClient ? "已复制订阅链接" : "复制订阅链接", systemImage: "doc.on.doc")
Label(copiedClient == thirdPartyClient.selectedClient ? "已复制模块订阅地址" : "复制模块订阅地址", systemImage: "doc.on.doc")
}
Button {
UIPasteboard.general.string = ThirdPartyProxyManager.interceptionHostnamesText
copiedMITMHostnames = true
} label: {
Label(copiedMITMHostnames ? "已复制两个解密域名" : "复制两个解密域名", systemImage: "doc.on.doc")
}
Button {
@@ -227,7 +479,7 @@ struct SettingsView: View {
}
Button {
setup.requestThirdPartySetup()
setup.requestThirdPartyOnboarding()
dismiss()
} label: {
Label("重新打开配置引导", systemImage: "arrow.clockwise.circle")
@@ -241,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)
}
}
@@ -279,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 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。
"""
}
@@ -294,9 +546,19 @@ struct SettingsView: View {
proxy.stop()
setup.completeSetup()
runtimeMode.setMode(.thirdParty)
setup.requestThirdPartySetup()
proxyOperationAlertTitle = "模式已切换"
proxyOperationError = "已切换到第三方代理模式。请关闭 Wi-Fi 中的 127.0.0.1:8888 手动代理,并按引导导入第三方配置。"
if runtimeMode.isInitialized(.thirdParty) {
do {
_ = try await thirdPartyProxy.validateConnection()
refreshThirdPartyAdvancedFeatures()
proxyOperationAlertTitle = "模式已切换"
proxyOperationError = "第三方代理模式检测通过。请关闭 Wi-Fi 中的 127.0.0.1:8888 手动代理,避免双重拦截。"
} catch {
openThirdPartySetup(for: error)
}
} else {
setup.requestThirdPartyOnboarding()
dismiss()
}
case .localWiFi:
do {
try await thirdPartyProxy.clear()
@@ -307,23 +569,100 @@ struct SettingsView: View {
}
runtimeMode.setMode(.localWiFi)
await setup.prepareLocalServices()
setup.requestSetup()
proxyOperationAlertTitle = "模式已切换"
proxyOperationError = "已切换到 APP 模式。请停用第三方 WLOC 模块或代理连接,避免双重拦截。"
if runtimeMode.isInitialized(.localWiFi) {
let result = await setup.runVerificationTest()
setup.applyVerificationResult(result)
if result.isSuccess {
proxyOperationAlertTitle = "模式已切换"
proxyOperationError = "APP 模式环境检测通过。请停用第三方 WLOC 模块或代理连接,避免双重拦截。"
} else {
dismiss()
}
} else {
setup.requestSetup()
dismiss()
}
}
}
}
private func detectThirdPartyConnection() {
let client = thirdPartyClient.selectedClient
let startedAt = Date()
Task { @MainActor in
do {
let response = try await thirdPartyProxy.query()
if !response.success, response.error?.contains("无已保存") != true {
proxyOperationAlertTitle = "检测失败"
proxyOperationError = response.error ?? "第三方代理模块返回失败"
}
_ = try await thirdPartyProxy.validateConnection()
refreshThirdPartyAdvancedFeatures()
RuntimeLogger.info("APP", "ThirdPartyProxy", "设置页第三方连接检测通过", details: [
"当前客户端": client.name,
"请求动作": "WLOC query",
"耗时毫秒": String(Int(Date().timeIntervalSince(startedAt) * 1_000))
])
runtimeMode.markInitialized(.thirdParty)
} catch {
proxyOperationAlertTitle = "检测失败"
RuntimeLogger.error(
"APP",
"ThirdPartyProxy",
"设置页第三方连接检测失败",
error: error,
details: [
"当前客户端": client.name,
"请求动作": "WLOC query",
"连接状态": String(describing: thirdPartyProxy.connectionState),
"耗时毫秒": String(Int(Date().timeIntervalSince(startedAt) * 1_000)),
"处理建议": ThirdPartyProxyError.recoverySuggestion(for: error)
]
)
openThirdPartySetup(for: error)
}
}
}
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()
}
private func resetCertificateAuthority() {
guard runtimeMode.mode == .localWiFi, !modeOperationRunning else { return }
modeOperationRunning = true
Task { @MainActor in
defer { modeOperationRunning = false }
if actions.virtualLocationEnabled {
actions.clear()
}
proxy.stop()
do {
try setup.certificateStore.reset()
runtimeMode.resetInitialization(.localWiFi)
guard await setup.prepareLocalServices() else {
proxyOperationAlertTitle = "证书重置失败"
proxyOperationError = setup.message
return
}
setup.requestCertificateSetup()
dismiss()
} catch {
proxyOperationAlertTitle = "证书重置失败"
proxyOperationError = error.localizedDescription
}
}
+29 -3
View File
@@ -7,6 +7,7 @@ final class SetupCoordinator: ObservableObject {
@Published var message = ""
@Published var isBrowsingWithoutTrust = false
@Published var testLog = ""
@Published private(set) var lastVerificationResult: VerificationResult?
// false
@Published var needsSetup = false
@Published private(set) var setupStep: SetupStep = .proxy
@@ -27,17 +28,22 @@ final class SetupCoordinator: ObservableObject {
/// Local services are prepared before the setup UI so the environment test
/// can distinguish Wi-Fi proxy configuration from CA trust failures.
func prepareLocalServices() async {
@discardableResult
func prepareLocalServices() async -> Bool {
do {
_ = try certificateStore.ensure()
if !proxy.isRunning { try await proxy.start() }
message = ""
return true
} catch {
message = "本地代理初始化失败:\(error.localizedDescription)"
RuntimeLogger.error("APP", "Startup", "本地服务初始化失败", error: error)
return false
}
}
func applyVerificationResult(_ result: VerificationResult) {
lastVerificationResult = result
switch result {
case .success:
trustState = .trusted
@@ -48,6 +54,8 @@ final class SetupCoordinator: ObservableObject {
setupStep = .cert
needsSetup = true
message = "CA 证书未安装或未信任"
case .verificationInProgress, .verificationSuperseded:
break
default:
trustState = .unavailable
setupStep = .proxy
@@ -60,14 +68,32 @@ final class SetupCoordinator: ObservableObject {
func browseMapWithoutSetup() { isBrowsingWithoutTrust = true; needsSetup = false }
func completeSetup() { needsSetup = false }
func requestModeSelection() {
lastVerificationResult = nil
message = ""
setupStep = .mode
needsSetup = true
}
func requestThirdPartySetup() {
func requestThirdPartyOnboarding() {
lastVerificationResult = nil
message = ""
setupStep = .thirdPartyClient
needsSetup = true
}
func requestSetup() {
func requestThirdPartySetup(message: String) {
lastVerificationResult = nil
self.message = message
setupStep = .thirdPartyImport
needsSetup = true
}
func requestCertificateSetup() {
lastVerificationResult = nil
message = ""
setupStep = .cert
needsSetup = true
}
func requestSetup(message: String = "") {
lastVerificationResult = nil
self.message = message
setupStep = .proxy
needsSetup = true
}
+13 -66
View File
@@ -4,8 +4,6 @@ enum TipKind: String, Identifiable {
case activation = "生效说明"
case deactivation = "失效说明"
case removeProxy = "关闭 WiFi 代理"
case proxySetup = "配置代理"
case rewriteFailed = "改写失败"
var id: String { rawValue }
}
@@ -22,8 +20,6 @@ struct TipSheetView: View {
case .activation: ActivationTipContent(runtimeMode: runtimeMode, dismiss: { dismiss() })
case .deactivation: DeactivationTipContent(runtimeMode: runtimeMode, dismiss: { dismiss() })
case .removeProxy: RemoveProxyTipContent(dismiss: { dismiss() })
case .proxySetup: ProxySetupTipContent(dismiss: { dismiss() })
case .rewriteFailed: RewriteFailedTipContent(dismiss: { dismiss() })
}
}.padding(16)
}
@@ -52,17 +48,6 @@ private func openSettings(_ destination: SystemSettingsDestination) {
}
}
// MARK: -
private struct TipCloseButton: View {
let action: () -> Void
var body: some View {
Button(action: action) {
Text("知道了").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12)
}.buttonStyle(.borderedProminent).tint(.blue)
}
}
// MARK: -
struct ActivationTipContent: View {
@@ -84,9 +69,13 @@ struct ActivationTipContent: View {
}.padding(.vertical, 4)
}
GroupBox(label: Label("如果还是不行", systemImage: "exclamationmark.triangle")) {
GroupBox(label: Label("还是无法生效?", systemImage: "exclamationmark.triangle")) {
Text("操作到第 3 步时关机重启,开机后从第 4 步继续。这样能彻底清除系统缓存的定位数据。")
.font(.caption).foregroundStyle(.secondary).padding(.vertical, 4)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
}
}
@@ -141,8 +130,13 @@ struct DeactivationTipContent: View {
}.padding(.vertical, 4)
}
GroupBox(label: Label("如果还是不行", systemImage: "exclamationmark.triangle")) {
Text("操作到第 3 步时关机重启,开机后从第 4 步继续。").font(.caption).foregroundStyle(.secondary).padding(.vertical, 4)
GroupBox(label: Label("还是无法取消?", systemImage: "exclamationmark.triangle")) {
Text("操作到第 3 步时关机重启,开机后从第 4 步继续。")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
}
}
@@ -192,50 +186,3 @@ struct RemoveProxyTipContent: View {
}
}
}
// MARK: - WiFi
struct ProxySetupTipContent: View {
@State private var copied = false
let dismiss: () -> Void
var body: some View {
GroupBox(label: Label("WiFi 代理未配置", systemImage: "wifi")) {
VStack(alignment: .leading, spacing: 10) {
Text("系统定位请求没有经过本地代理,请按以下步骤配置:\n\n1. 打开「设置 → 无线局域网」\n2. 确认已连接到正确的 WiFi(不是蜂窝数据)\n3. 点击当前 WiFi 右侧 (i) 图标\n4. 滑到底部「HTTP 代理」→ 选择「手动」\n5. 在「服务器」填写下面的地址,端口填写 8888\n6. 点右上角「存储」")
.font(.caption).foregroundStyle(.primary)
HStack(spacing: 8) {
Text("127.0.0.1:8888").font(.caption.monospaced().bold()).foregroundStyle(.blue)
Spacer()
Button {
UIPasteboard.general.string = "127.0.0.1:8888"
copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copied = false }
} label: {
Label(copied ? "已复制" : "复制地址", systemImage: copied ? "checkmark" : "doc.on.doc").font(.caption)
}.buttonStyle(.bordered).tint(copied ? .green : .blue)
}.padding(10).background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
Button { openSettings(.wifi) } label: {
Text("去设置 WiFi 代理").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12)
}.buttonStyle(.borderedProminent).tint(.blue)
}.padding(.vertical, 4)
}
}
}
// MARK: -
struct RewriteFailedTipContent: View {
let dismiss: () -> Void
var body: some View {
GroupBox(label: Label("坐标改写失败", systemImage: "exclamationmark.triangle")) {
VStack(alignment: .leading, spacing: 8) {
Text("虚拟定位已连接,但坐标替换没有成功。可能原因:\n\n• 代理刚启动不久,数据还没开始改写(等待几秒后重试)\n• CA 证书与当前 App 版本不匹配\n• iOS 系统安全策略限制\n\n建议操作:\n1. 完全停止虚拟定位,再重新开启\n2. 删除旧证书,重新生成并安装\n3. 在诊断页查看详细日志")
.font(.caption).foregroundStyle(.primary)
}.padding(.vertical, 4)
}
}
}
+15 -1
View File
@@ -55,6 +55,11 @@ func wloccore_validateca(certData, keyData *C.char) C.int {
//export wloccore_startproxy
func wloccore_startproxy(certData, keyData *C.char, lat, lon C.double, enabled C.int, accuracy C.int) C.uintptr_t {
return wloccore_startproxyv2(certData, keyData, lat, lon, enabled, accuracy, 0)
}
//export wloccore_startproxyv2
func wloccore_startproxyv2(certData, keyData *C.char, lat, lon C.double, enabled C.int, accuracy C.int, motionEnabled C.int) C.uintptr_t {
if certData == nil || keyData == nil {
return 0
}
@@ -65,6 +70,7 @@ func wloccore_startproxy(certData, keyData *C.char, lat, lon C.double, enabled C
float64(lon),
enabled != 0,
int(accuracy),
motionEnabled != 0,
)
if err != nil {
logEvent("startproxy failed: " + err.Error())
@@ -106,13 +112,21 @@ func proxyForHandle(h C.uintptr_t) (server *http.Server, handle cgo.Handle, ok b
//export wloccore_setcoords
func wloccore_setcoords(lat, lon C.double, enabled C.int, accuracy C.int) {
wloccore_setpatchconfig(lat, lon, enabled, accuracy, 0)
}
//export wloccore_setpatchconfig
func wloccore_setpatchconfig(lat, lon C.double, enabled C.int, accuracy C.int, motionEnabled C.int) {
stateMu.Lock()
currentLat = float64(lat)
currentLon = float64(lon)
currentEnabled = enabled != 0
currentAccuracy = int(accuracy)
currentMotionSimulationEnabled = motionEnabled != 0
stateMu.Unlock()
logEvent("setcoords enabled=" + strconv.FormatBool(enabled != 0) + " accuracy=" + strconv.Itoa(int(accuracy)))
logEvent("setpatchconfig enabled=" + strconv.FormatBool(enabled != 0) +
" accuracy=" + strconv.Itoa(int(accuracy)) +
" motion=" + strconv.FormatBool(motionEnabled != 0))
}
//export wloccore_getcoords
+2 -2
View File
@@ -31,8 +31,8 @@ func generateCA() (certPEM, keyPEM []byte, err error) {
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"WLOC"},
CommonName: "WLOC CA " + time.Now().In(time.FixedZone("CST", 8*3600)).Format("2006.01.02 15:04"),
Organization: []string{"Location Spoofer"},
CommonName: "Location Spoofer CA",
},
NotBefore: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
NotAfter: time.Date(2045, 1, 1, 0, 0, 0, 0, time.UTC),
+28 -11
View File
@@ -15,17 +15,16 @@ func TestGenerateCA(t *testing.T) {
if len(certPEM) == 0 || len(keyPEM) == 0 {
t.Fatal("empty CA output")
}
block, _ := pem.Decode(certPEM)
if block == nil {
t.Fatal("invalid cert PEM")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatal(err)
}
cert := parseCertificatePEM(t, certPEM)
if !cert.IsCA {
t.Fatal("certificate is not a CA")
}
if cert.Subject.CommonName != "Location Spoofer CA" {
t.Fatalf("unexpected CA common name: %q", cert.Subject.CommonName)
}
if len(cert.Subject.Organization) != 1 || cert.Subject.Organization[0] != "Location Spoofer" {
t.Fatalf("unexpected CA organization: %v", cert.Subject.Organization)
}
if time.Until(cert.NotAfter).Hours() < 360*24 {
t.Fatal("CA validity is too short")
}
@@ -34,16 +33,34 @@ func TestGenerateCA(t *testing.T) {
}
}
func TestGenerateCAUsesUniquePrivateKeys(t *testing.T) {
_, firstKey, err := generateCA()
func TestGenerateCAUsesUniquePrivateKeysAndSerialNumbers(t *testing.T) {
firstCertPEM, firstKey, err := generateCA()
if err != nil {
t.Fatal(err)
}
_, secondKey, err := generateCA()
secondCertPEM, secondKey, err := generateCA()
if err != nil {
t.Fatal(err)
}
if string(firstKey) == string(secondKey) {
t.Fatal("generated CA private keys must not be deterministic")
}
firstCert := parseCertificatePEM(t, firstCertPEM)
secondCert := parseCertificatePEM(t, secondCertPEM)
if firstCert.SerialNumber.Cmp(secondCert.SerialNumber) == 0 {
t.Fatal("generated CA serial numbers must not be deterministic")
}
}
func parseCertificatePEM(t *testing.T, certPEM []byte) *x509.Certificate {
t.Helper()
block, _ := pem.Decode(certPEM)
if block == nil {
t.Fatal("invalid cert PEM")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatal(err)
}
return cert
}
+17 -12
View File
@@ -22,13 +22,14 @@ import (
const proxyPort = 8888
var (
stateMu sync.Mutex
currentLat float64
currentLon float64
currentEnabled bool
currentAccuracy int
globalCACert *tls.Certificate
verifyToken string
stateMu sync.Mutex
currentLat float64
currentLon float64
currentEnabled bool
currentAccuracy int
currentMotionSimulationEnabled bool
globalCACert *tls.Certificate
verifyToken string
logMu sync.Mutex
logEntries []string
@@ -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))
+136 -3
View File
@@ -20,11 +20,17 @@ const (
)
type wlocCoords struct {
Latitude float64
Longitude float64
Accuracy int
Latitude float64
Longitude float64
Accuracy int
MotionSimulationEnabled bool
}
const (
motionActivityType = 63
motionActivityConfidence = 467
)
type patchStats struct {
WiFi int
Cell int
@@ -41,6 +47,8 @@ type wireField struct {
var macPattern = regexp.MustCompile(`^[0-9a-fA-F]{1,2}(:[0-9a-fA-F]{1,2}){5}$`)
var wlocMarker = []byte{0, 0, 0, 1, 0, 0}
func minInt(a, b int) int {
if a < b {
return a
@@ -177,6 +185,7 @@ func patchLocation(loc []byte, c wlocCoords) ([]byte, bool, error) {
lon := int64(math.Round(c.Longitude * 1e8))
var out []byte
changed := false
hasMotionType, hasMotionConfidence := false, false
for _, f := range fields {
switch {
case f.num == 1 && f.wireType == wireVarint:
@@ -197,10 +206,34 @@ func patchLocation(loc []byte, c wlocCoords) ([]byte, bool, error) {
changed = true
}
out = append(out, raw...)
case c.MotionSimulationEnabled && f.num == 11 && f.wireType == wireVarint:
hasMotionType = true
raw := append(writeTag(11, wireVarint), writeVarint(motionActivityType)...)
if !bytes.Equal(raw, f.raw) {
changed = true
}
out = append(out, raw...)
case c.MotionSimulationEnabled && f.num == 12 && f.wireType == wireVarint:
hasMotionConfidence = true
raw := append(writeTag(12, wireVarint), writeVarint(motionActivityConfidence)...)
if !bytes.Equal(raw, f.raw) {
changed = true
}
out = append(out, raw...)
default:
out = append(out, f.raw...)
}
}
if c.MotionSimulationEnabled && !hasMotionType {
out = append(out, writeTag(11, wireVarint)...)
out = append(out, writeVarint(motionActivityType)...)
changed = true
}
if c.MotionSimulationEnabled && !hasMotionConfidence {
out = append(out, writeTag(12, wireVarint)...)
out = append(out, writeVarint(motionActivityConfidence)...)
changed = true
}
return out, changed, nil
}
@@ -314,6 +347,99 @@ func patchWlocPayload(payload []byte, c wlocCoords, st *patchStats) ([]byte, boo
return out, changed, nil
}
func parseARPCPayloadBounds(body []byte) (lengthOffset, payloadOffset, payloadEnd int, err error) {
if len(body) < 2 {
return 0, 0, 0, errors.New("ARPC body too short")
}
offset := 2 // version
for range 3 {
if offset+2 > len(body) {
return 0, 0, 0, errors.New("truncated ARPC string length")
}
length := int(binary.BigEndian.Uint16(body[offset : offset+2]))
offset += 2
if length > len(body)-offset {
return 0, 0, 0, errors.New("truncated ARPC string")
}
offset += length
}
const functionAndLengthBytes = 8
if offset+functionAndLengthBytes > len(body) {
return 0, 0, 0, errors.New("truncated ARPC header")
}
lengthOffset = offset + 4
payloadOffset = lengthOffset + 4
payloadLength := uint64(binary.BigEndian.Uint32(body[lengthOffset:payloadOffset]))
if payloadLength == 0 || payloadLength > uint64(len(body)-payloadOffset) {
return 0, 0, 0, errors.New("invalid ARPC payload length")
}
return lengthOffset, payloadOffset, payloadOffset + int(payloadLength), nil
}
func patchARPCFrame(body []byte, c wlocCoords) ([]byte, patchStats, error) {
lengthOffset, payloadOffset, payloadEnd, err := parseARPCPayloadBounds(body)
if err != nil {
return nil, patchStats{}, err
}
var st patchStats
payload := body[payloadOffset:payloadEnd]
newPayload, changed, err := patchWlocPayload(payload, c, &st)
if err != nil {
return nil, patchStats{}, err
}
if !changed || bytes.Equal(newPayload, payload) {
return nil, patchStats{}, errors.New("ARPC envelope has no patchable wloc payload")
}
var lenBytes [4]byte
binary.BigEndian.PutUint32(lenBytes[:], uint32(len(newPayload)))
out := append(cloneBytes(body[:lengthOffset]), lenBytes[:]...)
out = append(out, newPayload...)
out = append(out, body[payloadEnd:]...)
return out, st, nil
}
func patchMarkerFrame(body []byte, c wlocCoords) ([]byte, patchStats, error) {
markerOffset := bytes.Index(body, wlocMarker)
if markerOffset < 0 {
return nil, patchStats{}, errors.New("wloc marker not found")
}
lengthOffset := markerOffset + len(wlocMarker)
payloadOffset := lengthOffset + 2
if payloadOffset > len(body) {
return nil, patchStats{}, errors.New("truncated marker frame")
}
payloadLength := int(binary.BigEndian.Uint16(body[lengthOffset:payloadOffset]))
if payloadLength == 0 || payloadLength > len(body)-payloadOffset {
return nil, patchStats{}, errors.New("invalid marker payload length")
}
payloadEnd := payloadOffset + payloadLength
var st patchStats
payload := body[payloadOffset:payloadEnd]
newPayload, changed, err := patchWlocPayload(payload, c, &st)
if err != nil {
return nil, patchStats{}, err
}
if !changed || bytes.Equal(newPayload, payload) {
return nil, patchStats{}, errors.New("marker frame has no patchable wloc payload")
}
if len(newPayload) > 65535 {
return nil, patchStats{}, errors.New("patched marker payload too large")
}
var lenBytes [2]byte
binary.BigEndian.PutUint16(lenBytes[:], uint16(len(newPayload)))
out := append(cloneBytes(body[:lengthOffset]), lenBytes[:]...)
out = append(out, newPayload...)
out = append(out, body[payloadEnd:]...)
return out, st, nil
}
func patchFrame(body []byte, offset int, c wlocCoords, st *patchStats) ([]byte, patchStats, error) {
if len(body) < offset+10 {
return nil, *st, fmt.Errorf("body too short: %d, base=%d", len(body), offset)
@@ -352,6 +478,13 @@ func patchFrame(body []byte, offset int, c wlocCoords, st *patchStats) ([]byte,
}
func patchWlocBody(body []byte, c wlocCoords) ([]byte, patchStats, error) {
if out, st, err := patchARPCFrame(body, c); err == nil {
return out, st, nil
}
if out, st, err := patchMarkerFrame(body, c); err == nil {
return out, st, nil
}
var st patchStats
offsets := []int{0, 2, 4, 6, 8, 10, 12, 14, 16}
seen := map[int]bool{}
+200 -7
View File
@@ -25,6 +25,15 @@ func testLocation(lat, lon int64, accuracy uint64) []byte {
return out
}
func testLocationWithMotion(lat, lon int64, accuracy, motionType, motionConfidence uint64) []byte {
out := testLocation(lat, lon, accuracy)
out = append(out, writeTag(11, wireVarint)...)
out = append(out, writeVarint(motionType)...)
out = append(out, writeTag(12, wireVarint)...)
out = append(out, writeVarint(motionConfidence)...)
return out
}
func testWifiDevice(loc []byte) []byte {
mac := []byte("aa:bb:cc:dd:ee:ff")
var out []byte
@@ -44,6 +53,29 @@ func testFrame(payload []byte) []byte {
return out
}
func testARPCFrame(payload, suffix []byte) ([]byte, int) {
var out []byte
var version [2]byte
binary.BigEndian.PutUint16(version[:], 1)
out = append(out, version[:]...)
for _, value := range [][]byte{[]byte("zh_CN"), []byte("com.apple.locationd"), []byte("20A123")} {
var length [2]byte
binary.BigEndian.PutUint16(length[:], uint16(len(value)))
out = append(out, length[:]...)
out = append(out, value...)
}
var functionID [4]byte
binary.BigEndian.PutUint32(functionID[:], 1)
out = append(out, functionID[:]...)
lengthOffset := len(out)
var payloadLength [4]byte
binary.BigEndian.PutUint32(payloadLength[:], uint32(len(payload)))
out = append(out, payloadLength[:]...)
out = append(out, payload...)
out = append(out, suffix...)
return out, lengthOffset
}
func TestPatchWifiLocation(t *testing.T) {
payload := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
body := testFrame(payload)
@@ -69,20 +101,181 @@ func TestPatchWifiLocation(t *testing.T) {
}
func TestPatchCellLocation(t *testing.T) {
cell := writeLengthDelimited(5, testLocation(300, 400, 25))
payload := writeLengthDelimited(22, cell)
body := testFrame(payload)
c := wlocCoords{Latitude: 22.544577, Longitude: 113.94114, Accuracy: 25}
for _, field := range []int{22, 24} {
t.Run(fmt.Sprintf("field_%d", field), func(t *testing.T) {
cell := writeLengthDelimited(5, testLocation(300, 400, 25))
payload := writeLengthDelimited(field, cell)
body := testFrame(payload)
c := wlocCoords{Latitude: 22.544577, Longitude: 113.94114, Accuracy: 25}
patched, stats, err := patchWlocBody(body, c)
if err != nil {
t.Fatal(err)
}
if stats.Cell != 1 || stats.Locations != 1 {
t.Fatalf("unexpected stats: %+v", stats)
}
if bytes.Equal(patched, body) {
t.Fatal("body was not patched")
}
})
}
}
func TestMotionSimulationDisabledPreservesFields(t *testing.T) {
original := testLocationWithMotion(100, 200, 25, 7, 88)
patched, changed, err := patchLocation(original, wlocCoords{
Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50,
})
if err != nil {
t.Fatal(err)
}
if !changed {
t.Fatal("coordinates were not patched")
}
fields, err := parseFields(patched)
if err != nil {
t.Fatal(err)
}
for _, field := range fields {
if (field.num == 11 || field.num == 12) && !bytes.Contains(original, field.raw) {
t.Fatalf("motion field %d changed while disabled", field.num)
}
}
}
func TestMotionSimulationEnabledReplacesFields(t *testing.T) {
original := testLocationWithMotion(100, 200, 25, 7, 88)
patched, _, err := patchLocation(original, wlocCoords{
Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50,
MotionSimulationEnabled: true,
})
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(patched, append(writeTag(11, wireVarint), writeVarint(motionActivityType)...)) {
t.Fatal("motion activity type was not replaced")
}
if !bytes.Contains(patched, append(writeTag(12, wireVarint), writeVarint(motionActivityConfidence)...)) {
t.Fatal("motion activity confidence was not replaced")
}
}
func TestMotionSimulationEnabledAddsMissingFields(t *testing.T) {
patched, _, err := patchLocation(testLocation(100, 200, 25), wlocCoords{
Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50,
MotionSimulationEnabled: true,
})
if err != nil {
t.Fatal(err)
}
fields, err := parseFields(patched)
if err != nil {
t.Fatal(err)
}
counts := map[int]int{}
for _, field := range fields {
counts[field.num]++
}
if counts[11] != 1 || counts[12] != 1 {
t.Fatalf("expected one inserted motion field each, got %+v", counts)
}
}
func TestPatchARPCFramePreservesEnvelopeAndSuffix(t *testing.T) {
payload := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
suffix := []byte{0xde, 0xad, 0xbe, 0xef}
body, lengthOffset := testARPCFrame(payload, suffix)
originalPrefix := cloneBytes(body[:lengthOffset])
c := wlocCoords{Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50}
patched, stats, err := patchWlocBody(body, c)
if err != nil {
t.Fatal(err)
}
if stats.Cell != 1 || stats.Locations != 1 {
if stats.WiFi != 1 || stats.Locations != 1 {
t.Fatalf("unexpected stats: %+v", stats)
}
if bytes.Equal(patched, body) {
t.Fatal("body was not patched")
if !bytes.Equal(patched[:lengthOffset], originalPrefix) {
t.Fatal("ARPC metadata changed")
}
newLength := int(binary.BigEndian.Uint32(patched[lengthOffset : lengthOffset+4]))
if newLength == len(payload) {
t.Fatal("ARPC payload length was not updated")
}
if !bytes.Equal(patched[lengthOffset+4+newLength:], suffix) {
t.Fatal("ARPC suffix changed")
}
}
func TestPatchARPCPayloadLargerThanUint16(t *testing.T) {
padding := writeLengthDelimited(99, bytes.Repeat([]byte{0x7f}, 70_000))
location := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
payload := append(padding, location...)
body, lengthOffset := testARPCFrame(payload, nil)
c := wlocCoords{Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50}
patched, stats, err := patchWlocBody(body, c)
if err != nil {
t.Fatal(err)
}
if stats.WiFi != 1 || stats.Locations != 1 {
t.Fatalf("unexpected stats: %+v", stats)
}
newLength := int(binary.BigEndian.Uint32(patched[lengthOffset : lengthOffset+4]))
if newLength <= 65535 {
t.Fatalf("expected 32-bit ARPC payload length, got %d", newLength)
}
if !bytes.Contains(patched[lengthOffset+4:lengthOffset+4+newLength], padding) {
t.Fatal("unknown ARPC payload field changed")
}
}
func TestPatchMarkerFramePreservesPrefixAndSuffix(t *testing.T) {
payload := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
prefix := []byte{0xaa, 0xbb, 0xcc}
suffix := []byte{0xdd, 0xee}
var length [2]byte
binary.BigEndian.PutUint16(length[:], uint16(len(payload)))
body := append(cloneBytes(prefix), wlocMarker...)
body = append(body, length[:]...)
body = append(body, payload...)
body = append(body, suffix...)
c := wlocCoords{Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50}
patched, stats, err := patchWlocBody(body, c)
if err != nil {
t.Fatal(err)
}
if stats.WiFi != 1 || stats.Locations != 1 {
t.Fatalf("unexpected stats: %+v", stats)
}
lengthOffset := len(prefix) + len(wlocMarker)
newLength := int(binary.BigEndian.Uint16(patched[lengthOffset : lengthOffset+2]))
if newLength == len(payload) {
t.Fatal("marker payload length was not updated")
}
if !bytes.Equal(patched[:len(prefix)], prefix) {
t.Fatal("marker prefix changed")
}
if !bytes.Equal(patched[lengthOffset+2+newLength:], suffix) {
t.Fatal("marker suffix changed")
}
}
func TestPatchBareWlocPayload(t *testing.T) {
body := writeLengthDelimited(2, testWifiDevice(testLocation(100, 200, 25)))
c := wlocCoords{Latitude: 31.230416, Longitude: 121.473701, Accuracy: 50}
patched, stats, err := patchWlocBody(body, c)
if err != nil {
t.Fatal(err)
}
if stats.WiFi != 1 || stats.Locations != 1 {
t.Fatalf("unexpected stats: %+v", stats)
}
if len(patched) > 0 && bytes.HasPrefix(patched, []byte{0, 1, 0, 0}) {
t.Fatal("bare payload was unexpectedly wrapped")
}
}
+82 -17
View File
@@ -12,7 +12,7 @@ responses in a controlled test environment.
[![iOS 15+](https://img.shields.io/badge/iOS-15%2B-111111?logo=apple)](project.yml)
[![Swift 5.9](https://img.shields.io/badge/Swift-5.9-F05138)](project.yml)
[![Go 1.23+](https://img.shields.io/badge/Go-1.23%2B-00ADD8?logo=go)](Core/go.mod)
[![Version](https://img.shields.io/badge/version-v1.0.2-2563EB)](docs/CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-v1.0.5-2563EB)](docs/CHANGELOG.md)
[Features](#feature-overview) ·
[How It Works](#how-it-works) ·
@@ -134,6 +134,57 @@ In this mode:
- Wi-Fi, 4G, and 5G support depends on the client;
- The configuration may remain active after Location Spoofer closes.
#### Configuration API and Client Integration
The app does not upload coordinates to a project server. It sends the following GET request, which the third-party
client must intercept locally on the device:
```text
https://gs-loc.apple.com/wloc-settings/save
```
| Action | Query parameters | Purpose |
|---|---|---|
| Query | `action=query` | Verify module connectivity and read the stored coordinate |
| Save | `lon=<WGS-84 longitude>&lat=<WGS-84 latitude>&acc=<accuracy>` | Store the selected coordinate |
| Clear | `action=clear` | Remove the stored test coordinate |
The interception script must return HTTP 200 with JSON:
```json
{
"success": true,
"longitude": 113.0,
"latitude": 22.0,
"accuracy": 25
}
```
Failures use:
```json
{
"success": false,
"error": "Error description"
}
```
When no coordinate is stored, a query may return `{"success":false,"error":"无已保存的坐标"}`. The app interprets
that response as “module connected, virtual location inactive.” A successful save response must echo the requested
WGS-84 longitude and latitude.
To integrate another third-party client:
1. Add an HTTP request script for `gs-loc.apple.com/wloc-settings/save` that parses the parameters and returns the
specified JSON;
2. Store coordinates, accuracy, and optional state in the client's persistent storage;
3. Enable HTTPS decryption for `gs-loc.apple.com` and `gs-loc-cn.apple.com`;
4. Intercept `gs-loc(-cn).apple.com/clls/wloc` responses, read the same persistent data, and modify the WLOC response;
5. Publish an importable module and verify query, save, clear, and location restoration on a real device.
The app validates only the configuration endpoint's HTTP status, JSON shape, and coordinate round trip. It does not
manage the third-party client's certificate, MITM, VPN, or proxy state.
Do not enable App Mode interception and Third-party Proxy Mode interception at the same time.
## Runtime Modes
@@ -163,14 +214,17 @@ Suitable for:
Current client status:
| Client | Status |
|---|---|
| Shadowrocket | Currently used for on-device testing |
| Surge | Configuration provided, not fully verified |
| Quantumult X | Configuration provided, not fully verified |
| Loon | Configuration provided, not fully verified |
| Stash | Configuration provided, not fully verified |
| Egern | Uses the Surge module, not fully verified |
| Client | Status | Community configuration |
|---|---|---|
| Shadowrocket | Currently used for on-device testing | Built-in App guide |
| Surge | Configuration provided, not fully verified | Contributions wanted |
| Quantumult X | Configuration provided, not fully verified | Contributions wanted |
| Loon | Configuration provided, not fully verified | Contributions wanted |
| Stash | Configuration provided, not fully verified | Contributions wanted |
| Egern | Uses the Surge module, not fully verified | Contributions wanted |
Community configurations are reviewed by client. Accepted submissions are linked in this table with attribution unless
the contributor requests anonymous inclusion.
Module snapshots and provenance:
@@ -327,21 +381,25 @@ Do not post real locations, authentication information, CA private keys, or comp
Contributions are welcome for:
- Bug reports;
- Feature requests;
- Compatibility results;
- [Bug reports](https://github.com/xweiba/location-spoofer/issues/new?template=bug-report.yml);
- [Feature requests](https://github.com/xweiba/location-spoofer/discussions/categories/%E5%8A%9F%E8%83%BD%E5%BB%BA%E8%AE%AE);
- [Usage help and compatibility results](https://github.com/xweiba/location-spoofer/discussions/categories/%E4%BD%BF%E7%94%A8%E5%B8%AE%E5%8A%A9);
- [Third-party client configurations and sanitized screenshots](https://github.com/xweiba/location-spoofer/discussions/categories/%E7%AC%AC%E4%B8%89%E6%96%B9%E9%85%8D%E7%BD%AE%E5%88%86%E4%BA%AB);
- Performance improvements;
- Documentation improvements;
- Additional tests.
When filing an issue, include:
For bugs, generate a report from **Settings → Support → Report Bug** in the App whenever possible. The report includes:
- iOS version;
- Device model;
- App version;
- Runtime mode;
- Reproduction steps;
- Selected third-party client;
- Whether the issue is reproducible;
- Problem description;
- Sanitized runtime logs;
- Whether a third-party proxy client was used.
Paste it into the **App-generated diagnostic report** field in the GitHub Issue Form.
## Documentation
@@ -385,10 +443,17 @@ guarantee for every app or release.
The core location-response handling approach, Go implementation, and third-party modules are based on:
- [Yu9191/wloc](https://github.com/Yu9191/wloc)
- [ios-location-spoofer](https://github.com/mekos2772/ios-location-spoofer)
Community link:
Thanks to the following LINUX DO users for their contributions:
- Bug fixes: [Chen Ze](https://linux.do/u/lixiaobaivv)
- Ideas and suggestions: [Alex](https://linux.do/u/_alex), [ye4241](https://linux.do/u/ye4241)
Links:
- [LINUX DO](https://linux.do/)
- [iOS-Location-Spoofer-Web](https://github.com/akudamatata/iOS-Location-Spoofer-Web)
Thanks to the open-source contributors working on iOS location-service research, network proxies, and mobile testing
tools.
+77 -16
View File
@@ -12,7 +12,7 @@
[![iOS 15+](https://img.shields.io/badge/iOS-15%2B-111111?logo=apple)](project.yml)
[![Swift 5.9](https://img.shields.io/badge/Swift-5.9-F05138)](project.yml)
[![Go 1.23+](https://img.shields.io/badge/Go-1.23%2B-00ADD8?logo=go)](Core/go.mod)
[![Version](https://img.shields.io/badge/version-v1.0.2-2563EB)](docs/CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-v1.0.5-2563EB)](docs/CHANGELOG.md)
[功能概览](#功能概览) ·
[工作原理](#工作原理) ·
@@ -130,6 +130,53 @@ WLOC 配置接口
- 是否支持 Wi-Fi、4G 或 5G 取决于客户端;
- App 关闭后,第三方客户端中的配置可能继续生效。
#### 配置接口与客户端适配
App 不会把坐标上传到项目服务器。它会发起以下 GET 请求,第三方客户端必须在设备本地拦截:
```text
https://gs-loc.apple.com/wloc-settings/save
```
| 操作 | 查询参数 | 用途 |
|---|---|---|
| 查询 | `action=query` | 检查模块是否连接,并读取当前保存的坐标 |
| 保存 | `lon=<WGS-84 经度>&lat=<WGS-84 纬度>&acc=<精度>` | 保存当前选点 |
| 清除 | `action=clear` | 删除已保存的测试坐标 |
拦截脚本必须返回 HTTP 200 和 JSON
```json
{
"success": true,
"longitude": 113.0,
"latitude": 22.0,
"accuracy": 25
}
```
失败时返回:
```json
{
"success": false,
"error": "错误说明"
}
```
查询时没有已保存坐标,可以返回 `{"success":false,"error":"无已保存的坐标"}`。App 会把它识别为
“模块已连接,但虚拟定位未开启”。保存成功时,响应中的经纬度必须与请求中的 WGS-84 坐标一致。
要适配新的第三方客户端,需要:
1.`gs-loc.apple.com/wloc-settings/save` 添加 HTTP 请求脚本,解析上述参数并返回约定 JSON;
2. 使用客户端的持久化存储保存坐标、精度和可选状态;
3.`gs-loc.apple.com``gs-loc-cn.apple.com` 配置 HTTPS 解密;
4. 拦截 `gs-loc(-cn).apple.com/clls/wloc` 响应,读取同一份持久化数据并修改 WLOC 响应;
5. 提供可订阅的模块文件,并确认查询、保存、清除和定位恢复都能在真机完成。
App 只验证配置接口的 HTTP 状态、JSON 格式和坐标回读,不管理第三方客户端的证书、MITM、VPN 或代理状态。
不要同时启用 APP 模式代理和第三方代理模式,避免两个代理链路互相干扰。
## 运行模式
@@ -159,14 +206,16 @@ WLOC 配置接口
当前客户端状态:
| 客户端 | 状态 |
|---|---|
| Shadowrocket | 当前用于真机测试 |
| Surge | 已提供配置,尚未完整验证 |
| Quantumult X | 已提供配置,尚未完整验证 |
| Loon | 已提供配置,尚未完整验证 |
| Stash | 已提供配置,尚未完整验证 |
| Egern | 使用 Surge 模块,尚未完整验证 |
| 客户端 | 状态 | 社区配置 |
|---|---|---|
| Shadowrocket | 当前用于真机测试 | App 内置教程 |
| Surge | 已提供配置,尚未完整验证 | 待征集 |
| Quantumult X | 已提供配置,尚未完整验证 | 待征集 |
| Loon | 已提供配置,尚未完整验证 | 待征集 |
| Stash | 已提供配置,尚未完整验证 | 待征集 |
| Egern | 使用 Surge 模块,尚未完整验证 | 待征集 |
社区配置按客户端分区审核;采纳后会在上表链接教程和投稿者,投稿者也可以选择匿名收录。
相关模块快照和来源记录:
@@ -320,26 +369,31 @@ dist/PaopaoLocationSpoofer-unsigned.ipa
欢迎提交:
- Bug Report
- Feature Request
- 兼容性测试结果
- [Bug Report](https://github.com/xweiba/location-spoofer/issues/new?template=bug-report.yml)
- [功能建议](https://github.com/xweiba/location-spoofer/discussions/categories/%E5%8A%9F%E8%83%BD%E5%BB%BA%E8%AE%AE)
- [使用帮助与兼容性测试](https://github.com/xweiba/location-spoofer/discussions/categories/%E4%BD%BF%E7%94%A8%E5%B8%AE%E5%8A%A9)
- [第三方客户端配置和脱敏原始截图](https://github.com/xweiba/location-spoofer/discussions/categories/%E7%AC%AC%E4%B8%89%E6%96%B9%E9%85%8D%E7%BD%AE%E5%88%86%E4%BA%AB)
- 性能改进;
- 文档改进;
- 测试补充。
提交 Issue 时建议包含:
提交 Bug 时建议优先通过 App 的“设置 → 支持 → 报告 Bug”生成报告。报告包含:
- iOS 版本;
- 设备型号
- App 版本
- 使用的运行模式;
- 复现步骤
- 当前第三方客户端
- 是否可以稳定复现;
- 问题描述;
- 脱敏后的运行日志;
- 是否使用第三方代理客户端。
GitHub Issue Form 中的“App 生成的诊断报告”字段与 App 复制内容一一对应。
## 文档
- [构建说明](docs/BUILD.md)
- [第三方模块说明](docs/THIRD_PARTY_MODULES.md)
- [社区客户端教程与截图提交](docs/COMMUNITY_TUTORIALS.md)
- [更新日志](docs/CHANGELOG.md)
- [英文文档](README.en.md)
- [GitHub Issues](https://github.com/xweiba/location-spoofer/issues)
@@ -377,9 +431,16 @@ dist/PaopaoLocationSpoofer-unsigned.ipa
核心定位响应处理思路、Go 实现和第三方模块参考自:
- [Yu9191/wloc](https://github.com/Yu9191/wloc)
- [ios-location-spoofer](https://github.com/mekos2772/ios-location-spoofer)
感谢以下 LINUX DO 用户对项目的贡献:
- 功能修复:[陈泽](https://linux.do/u/lixiaobaivv)
- 思路及建议:[Alex](https://linux.do/u/_alex)、[ye4241](https://linux.do/u/ye4241)
友链:
- [LINUX DO](https://linux.do/)
- [iOS-Location-Spoofer-Web](https://github.com/akudamatata/iOS-Location-Spoofer-Web)
感谢开源社区中参与 iOS 定位服务研究、网络代理和移动端测试工具建设的贡献者。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 113 KiB

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "app-mode-certificate-install.jpg",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "app-mode-certificate-trust.jpg",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "app-mode-wifi-proxy.jpg",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "shadowrocket-config-details.jpg",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "shadowrocket-https-ca.jpg",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "shadowrocket-https-decryption.jpg",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "shadowrocket-module-import.jpg",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+4 -4
View File
@@ -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
+4 -5
View File
@@ -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
+4 -5
View File
@@ -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
+80
View File
@@ -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
@@ -116,3 +160,39 @@ struct VirtualLocationTipPreferences {
}
}
}
/// Controls the optional prompt asking users to share a verified third-party setup.
struct ThirdPartyCommunityPromptPreferences {
static let minimumCountForSuppression = 3
private enum Key {
static let presentationCount = "thirdPartyCommunityPrompt.presentationCount"
static let suppressed = "thirdPartyCommunityPrompt.suppressed"
}
private let defaults: UserDefaults
init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
}
@discardableResult
func recordPresentation() -> Int {
let next = defaults.integer(forKey: Key.presentationCount) + 1
defaults.set(next, forKey: Key.presentationCount)
return next
}
func shouldPresent() -> Bool {
!defaults.bool(forKey: Key.suppressed)
}
func canSuppress() -> Bool {
defaults.integer(forKey: Key.presentationCount) >= Self.minimumCountForSuppression
}
func suppress() {
guard canSuppress() else { return }
defaults.set(true, forKey: Key.suppressed)
}
}
+253
View File
@@ -0,0 +1,253 @@
import Foundation
struct AppUpdatePrompt: Identifiable, Equatable {
enum Requirement: String {
case recommended
case required
}
let currentVersion: String
let latestVersion: String
let minimumSupportedVersion: String
let requirement: Requirement
let releaseNotes: String?
var id: String {
"\(requirement.rawValue)-\(latestVersion)-\(minimumSupportedVersion)"
}
}
struct AppRemoteConfiguration: Decodable, Equatable {
let latestVersion: String
let minimumSupportedVersion: String
let communityPromptClients: [String]
static let fallback = AppRemoteConfiguration(
latestVersion: "1.0.5",
minimumSupportedVersion: "1.0.0",
communityPromptClients: [
ThirdPartyProxyClient.surge.rawValue,
ThirdPartyProxyClient.quantumultX.rawValue,
ThirdPartyProxyClient.loon.rawValue,
ThirdPartyProxyClient.stash.rawValue,
ThirdPartyProxyClient.egern.rawValue
]
)
init(
latestVersion: String,
minimumSupportedVersion: String,
communityPromptClients: [String]
) {
self.latestVersion = latestVersion
self.minimumSupportedVersion = minimumSupportedVersion
self.communityPromptClients = communityPromptClients
}
static func decode(_ data: Data) throws -> AppRemoteConfiguration {
let configuration = try JSONDecoder().decode(AppRemoteConfiguration.self, from: data)
guard let latest = NumericVersion(configuration.latestVersion),
let minimum = NumericVersion(configuration.minimumSupportedVersion),
minimum <= latest else {
throw AppRemoteConfigurationError.invalidVersion
}
guard configuration.communityPromptClients.allSatisfy({
ThirdPartyProxyClient(rawValue: $0) != nil
}) else {
throw AppRemoteConfigurationError.invalidClient
}
return configuration
}
func updatePrompt(currentVersion: String, releaseNotes: String? = nil) -> AppUpdatePrompt? {
guard let current = NumericVersion(currentVersion),
let latest = NumericVersion(latestVersion),
let minimum = NumericVersion(minimumSupportedVersion) else {
return nil
}
if current < minimum {
return AppUpdatePrompt(
currentVersion: currentVersion,
latestVersion: latestVersion,
minimumSupportedVersion: minimumSupportedVersion,
requirement: .required,
releaseNotes: releaseNotes
)
}
if current < latest {
return AppUpdatePrompt(
currentVersion: currentVersion,
latestVersion: latestVersion,
minimumSupportedVersion: minimumSupportedVersion,
requirement: .recommended,
releaseNotes: releaseNotes
)
}
return nil
}
func requestsCommunityPrompt(for client: ThirdPartyProxyClient) -> Bool {
client != .shadowrocket && communityPromptClients.contains(client.rawValue)
}
}
@MainActor
final class AppRemoteConfigurationStore: ObservableObject {
static let shared = AppRemoteConfigurationStore()
@Published private(set) var configuration = AppRemoteConfiguration.fallback
func apply(_ configuration: AppRemoteConfiguration) {
self.configuration = configuration
}
func requestsCommunityPrompt(for client: ThirdPartyProxyClient) -> Bool {
configuration.requestsCommunityPrompt(for: client)
}
}
enum AppRemoteConfigurationService {
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 session = makeSession()
defer { session.finishTasksAndInvalidate() }
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 {
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
])
}
}
RuntimeLogger.warning("APP", "Update", "版本配置加载失败,继续使用内置配置")
return nil
}
static func fetchReleaseNotes(version: String) async -> String? {
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
return URLSession(configuration: sessionConfiguration)
}
private static func releaseNotesSummary(_ markdown: String) -> String? {
var items: [String] = []
var inMainUpdates = false
for rawLine in markdown.components(separatedBy: .newlines) {
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
if line == "## 主要更新" {
inMainUpdates = true
continue
}
if inMainUpdates, line.hasPrefix("## ") {
break
}
guard inMainUpdates, line.hasPrefix("- ") else { continue }
items.append("" + line.dropFirst(2))
}
guard !items.isEmpty else { return nil }
return items.joined(separator: "\n")
}
}
private enum AppRemoteConfigurationError: Error {
case invalidVersion
case invalidClient
}
private struct NumericVersion: Comparable {
let components: [Int]
init?(_ value: String) {
let parts = value.split(separator: ".", omittingEmptySubsequences: false)
guard !parts.isEmpty else { return nil }
var parsed: [Int] = []
for part in parts {
guard !part.isEmpty, let number = Int(part), number >= 0 else { return nil }
parsed.append(number)
}
components = parsed
}
static func < (lhs: NumericVersion, rhs: NumericVersion) -> Bool {
let count = max(lhs.components.count, rhs.components.count)
for index in 0..<count {
let left = index < lhs.components.count ? lhs.components[index] : 0
let right = index < rhs.components.count ? rhs.components[index] : 0
if left != right { return left < right }
}
return false
}
}
+18 -8
View File
@@ -141,6 +141,12 @@ final class CertificateAuthorityStore {
try loadValidKeychainAuthority()
}
func reset() throws {
try removeLegacyFiles()
try keychain.remove()
RuntimeLogger.info("SHARED", "Certificate.store", "已删除设备 CA,等待重新生成")
}
private func loadValidKeychainAuthority() throws -> CertificateAuthority? {
guard let authority = try keychain.load() else { return nil }
guard validator(authority) else {
@@ -163,16 +169,20 @@ final class CertificateAuthorityStore {
}
private func removeLegacyFilesBestEffort() {
do {
try removeLegacyFiles()
} catch {
RuntimeLogger.error("SHARED", "Certificate.store", "删除旧 CA 文件失败,将在下次启动重试", error: error)
}
}
private func removeLegacyFiles() throws {
let fileManager = FileManager.default
// Remove private material first. Each item is retried on later launches
// when a valid Keychain authority is available.
for url in [keyURL, certificateURL] where fileManager.fileExists(atPath: url.path) {
do {
try fileManager.removeItem(at: url)
} catch {
RuntimeLogger.error("SHARED", "Certificate.store", "删除旧 CA 文件失败,将在下次启动重试", error: error)
}
try fileManager.removeItem(at: url)
}
if fileManager.fileExists(atPath: directory.path) {
try fileManager.removeItem(at: directory)
}
try? fileManager.removeItem(at: directory)
}
}
+41
View File
@@ -0,0 +1,41 @@
import Foundation
enum GitHubSubmission {
static let communityContributionURL = URL(
string: "https://github.com/xweiba/location-spoofer/discussions/new?category=%E7%AC%AC%E4%B8%89%E6%96%B9%E9%85%8D%E7%BD%AE%E5%88%86%E4%BA%AB"
)!
static let usageHelpURL = URL(
string: "https://github.com/xweiba/location-spoofer/discussions/categories/%E4%BD%BF%E7%94%A8%E5%B8%AE%E5%8A%A9"
)!
static let featureRequestURL = URL(
string: "https://github.com/xweiba/location-spoofer/discussions/categories/%E5%8A%9F%E8%83%BD%E5%BB%BA%E8%AE%AE"
)!
static let bugReportURL = URL(
string: "https://github.com/xweiba/location-spoofer/issues/new?template=bug-report.yml"
)!
static func communityContributionTemplate(
for client: ThirdPartyProxyClient,
systemVersion: String
) -> String {
"""
## 第三方客户端
\(client.name)
## 第三方客户端版本
请填写当前使用的第三方客户端版本。
## iOS 版本
iOS \(systemVersion)
## 配置步骤
请描述模块导入、证书或解密设置、代理连接和最终验证过程。
## 截图与补充说明
请附上脱敏原图,并说明每张图片对应的步骤。
## README 收录署名
- [ ] 匿名收录,不在 README 展示投稿账号
"""
}
}
+56 -1
View File
@@ -21,17 +21,31 @@ final class ProxyRuntimeModeStore: ObservableObject {
private enum Key {
static let runtimeMode = "proxyRuntimeMode"
static let hasSelectedRuntimeMode = "hasSelectedProxyRuntimeMode"
static let localWiFiInitialized = "localWiFiRuntimeModeInitialized"
static let thirdPartyInitialized = "thirdPartyRuntimeModeInitialized"
static let initializationMigrationCompleted = "runtimeModeInitializationMigrationCompleted"
static let legacySetupCompleted = "setupCompleted"
}
@Published private(set) var mode: ProxyRuntimeMode
@Published private(set) var hasSelectedMode: Bool
@Published private(set) var localWiFiInitialized: Bool
@Published private(set) var thirdPartyInitialized: Bool
private let defaults: UserDefaults
private let legacyDefaults: UserDefaults
init(defaults: UserDefaults = AppGroup.defaults) {
init(
defaults: UserDefaults = AppGroup.defaults,
legacyDefaults: UserDefaults = .standard
) {
self.defaults = defaults
self.legacyDefaults = legacyDefaults
self.mode = defaults.string(forKey: Key.runtimeMode)
.flatMap(ProxyRuntimeMode.init(rawValue:)) ?? .localWiFi
self.hasSelectedMode = defaults.bool(forKey: Key.hasSelectedRuntimeMode)
self.localWiFiInitialized = defaults.bool(forKey: Key.localWiFiInitialized)
self.thirdPartyInitialized = defaults.bool(forKey: Key.thirdPartyInitialized)
migrateLegacyInitializationIfNeeded()
}
func setMode(_ mode: ProxyRuntimeMode) {
@@ -40,10 +54,51 @@ final class ProxyRuntimeModeStore: ObservableObject {
hasSelectedMode = true
defaults.set(mode.rawValue, forKey: Key.runtimeMode)
defaults.set(true, forKey: Key.hasSelectedRuntimeMode)
migrateLegacyInitializationIfNeeded()
if changed {
RuntimeLogger.info("APP", "Mode", "代理运行模式已切换", details: [
"模式": mode.displayName
])
}
}
func isInitialized(_ mode: ProxyRuntimeMode) -> Bool {
switch mode {
case .localWiFi: return localWiFiInitialized
case .thirdParty: return thirdPartyInitialized
}
}
func markInitialized(_ mode: ProxyRuntimeMode) {
setInitialized(true, for: mode)
}
func resetInitialization(_ mode: ProxyRuntimeMode) {
setInitialized(false, for: mode)
}
private func setInitialized(_ initialized: Bool, for mode: ProxyRuntimeMode) {
switch mode {
case .localWiFi:
localWiFiInitialized = initialized
defaults.set(initialized, forKey: Key.localWiFiInitialized)
case .thirdParty:
thirdPartyInitialized = initialized
defaults.set(initialized, forKey: Key.thirdPartyInitialized)
}
}
private func migrateLegacyInitializationIfNeeded() {
guard hasSelectedMode,
!defaults.bool(forKey: Key.initializationMigrationCompleted) else {
return
}
if legacyDefaults.bool(forKey: Key.legacySetupCompleted) {
setInitialized(true, for: mode)
RuntimeLogger.info("APP", "Mode", "已迁移旧版模式初始化状态", details: [
"模式": mode.displayName
])
}
defaults.set(true, forKey: Key.initializationMigrationCompleted)
}
}
+161 -23
View File
@@ -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,13 +79,19 @@ 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
private let endpoint = URL(string: "https://gs-loc.apple.com/wloc-settings/save")!
init(requester: (any ThirdPartyProxyRequesting)? = nil) {
if let requester {
@@ -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
}
@@ -134,17 +259,21 @@ final class ThirdPartyProxyManager: ObservableObject {
isRequesting = true
defer { isRequesting = false }
var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
var components = URLComponents(url: Self.configurationEndpoint, resolvingAgainstBaseURL: false)!
switch action {
case .query:
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
@@ -197,8 +328,8 @@ enum ThirdPartyProxyClient: String, CaseIterable, Identifiable {
}
}
var verificationText: String {
self == .shadowrocket ? "当前可测试" : "配置已提供,尚未验证"
var verificationText: String? {
self == .shadowrocket ? nil : "配置已提供,尚未验证"
}
var moduleFileName: String {
@@ -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? {
+1 -24
View File
@@ -1,6 +1,6 @@
import Foundation
/// UI
/// SetupCoordinator
enum VerificationResult: Equatable, Identifiable {
case success
case proxyNotRunning
@@ -26,27 +26,4 @@ enum VerificationResult: Equatable, Identifiable {
var isSuccess: Bool { self == .success }
///
var tipKind: TipKind? {
switch self {
case .success: return nil
case .proxyNotRunning, .verificationInProgress, .verificationSuperseded: return nil
case .certNotTrusted: return nil
case .wifiProxyNotConfigured: return .proxySetup
case .coordinateWriteFailed, .patchFailed: return .rewriteFailed
}
}
/// Wi-Fi
///
var wifiChangeReminderTipKind: TipKind? {
switch self {
case .proxyNotRunning:
return .proxySetup
case .certNotTrusted, .verificationInProgress, .verificationSuperseded:
return nil
default:
return tipKind
}
}
}
@@ -0,0 +1,84 @@
import XCTest
@testable import PaopaoLocationSpoofer
final class AppRemoteConfigurationTests: XCTestCase {
func testDecodesReadableJSONAndClientPromptSwitches() throws {
let data = """
{
"latestVersion": "1.2.0",
"minimumSupportedVersion": "1.1.0",
"communityPromptClients": ["surge", "loon"]
}
""".data(using: .utf8)!
let configuration = try AppRemoteConfiguration.decode(data)
XCTAssertEqual(configuration.latestVersion, "1.2.0")
XCTAssertTrue(configuration.requestsCommunityPrompt(for: .surge))
XCTAssertTrue(configuration.requestsCommunityPrompt(for: .loon))
XCTAssertFalse(configuration.requestsCommunityPrompt(for: .stash))
XCTAssertFalse(configuration.requestsCommunityPrompt(for: .shadowrocket))
}
func testVersionPolicyDistinguishesRequiredRecommendedAndCurrent() throws {
let configuration = AppRemoteConfiguration(
latestVersion: "1.2.0",
minimumSupportedVersion: "1.1.0",
communityPromptClients: []
)
XCTAssertEqual(
configuration.updatePrompt(currentVersion: "1.0.9")?.requirement,
.required
)
XCTAssertEqual(
configuration.updatePrompt(currentVersion: "1.1.0")?.requirement,
.recommended
)
XCTAssertNil(configuration.updatePrompt(currentVersion: "1.2.0"))
XCTAssertNil(configuration.updatePrompt(currentVersion: "1.2.0.0"))
}
func testRejectsInvalidVersionsAndUnknownClients() {
let invalidVersion = """
{
"latestVersion": "1.0.0",
"minimumSupportedVersion": "2.0.0",
"communityPromptClients": []
}
""".data(using: .utf8)!
XCTAssertThrowsError(try AppRemoteConfiguration.decode(invalidVersion))
let invalidClient = """
{
"latestVersion": "2.0.0",
"minimumSupportedVersion": "1.0.0",
"communityPromptClients": ["unknown"]
}
""".data(using: .utf8)!
XCTAssertThrowsError(try AppRemoteConfiguration.decode(invalidClient))
}
func testFallbackMatchesCurrentProjectPolicy() {
let configuration = AppRemoteConfiguration.fallback
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")
}
}
@@ -97,6 +97,28 @@ final class CertificateAuthorityStoreTests: XCTestCase {
XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("ca-key.pem").path))
}
func testResetRemovesKeychainAndLegacyAuthority() throws {
let directory = try makeLegacyDirectory(authority: validAuthority)
let keychain = InMemoryCertificateAuthorityKeychain()
keychain.stored = validAuthority
let store = makeStore(directory: directory, keychain: keychain) { self.validAuthority }
try store.reset()
XCTAssertNil(keychain.stored)
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.path))
}
func testResetFailurePreservesKeychainAuthority() {
let keychain = InMemoryCertificateAuthorityKeychain()
keychain.stored = validAuthority
keychain.shouldFailRemove = true
let store = makeStore(keychain: keychain) { self.validAuthority }
XCTAssertThrowsError(try store.reset())
XCTAssertEqual(keychain.stored, validAuthority)
}
private func makeStore(
directory: URL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString),
keychain: InMemoryCertificateAuthorityKeychain,
@@ -121,11 +143,13 @@ final class CertificateAuthorityStoreTests: XCTestCase {
private enum CertificateAuthorityStoreTestError: Error {
case saveFailed
case removeFailed
}
private final class InMemoryCertificateAuthorityKeychain: CertificateAuthorityKeychain {
var stored: CertificateAuthority?
var shouldFailSave = false
var shouldFailRemove = false
func load() throws -> CertificateAuthority? { stored }
@@ -135,6 +159,7 @@ private final class InMemoryCertificateAuthorityKeychain: CertificateAuthorityKe
}
func remove() throws {
guard !shouldFailRemove else { throw CertificateAuthorityStoreTestError.removeFailed }
stored = nil
}
}
@@ -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)
}
}
@@ -5,16 +5,52 @@ import XCTest
final class ProxyRuntimeModeTests: XCTestCase {
func testDefaultsToLocalWiFiAndPersistsThirdPartyMode() {
let suiteName = "ProxyRuntimeModeTests.\(UUID().uuidString)"
let legacySuiteName = "ProxyRuntimeModeLegacyTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
let legacyDefaults = UserDefaults(suiteName: legacySuiteName)!
defer {
defaults.removePersistentDomain(forName: suiteName)
legacyDefaults.removePersistentDomain(forName: legacySuiteName)
}
let initial = ProxyRuntimeModeStore(defaults: defaults)
let initial = ProxyRuntimeModeStore(defaults: defaults, legacyDefaults: legacyDefaults)
XCTAssertEqual(initial.mode, .localWiFi)
XCTAssertFalse(initial.hasSelectedMode)
XCTAssertFalse(initial.isInitialized(.localWiFi))
XCTAssertFalse(initial.isInitialized(.thirdParty))
initial.setMode(.thirdParty)
let restored = ProxyRuntimeModeStore(defaults: defaults)
initial.markInitialized(.thirdParty)
let restored = ProxyRuntimeModeStore(defaults: defaults, legacyDefaults: legacyDefaults)
XCTAssertEqual(restored.mode, .thirdParty)
XCTAssertTrue(restored.hasSelectedMode)
XCTAssertFalse(restored.isInitialized(.localWiFi))
XCTAssertTrue(restored.isInitialized(.thirdParty))
restored.resetInitialization(.thirdParty)
XCTAssertFalse(restored.isInitialized(.thirdParty))
}
func testMigratesOnlyCurrentLegacyCompletedMode() {
let suiteName = "ProxyRuntimeModeMigrationTests.\(UUID().uuidString)"
let legacySuiteName = "ProxyRuntimeModeMigrationLegacyTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
let legacyDefaults = UserDefaults(suiteName: legacySuiteName)!
defer {
defaults.removePersistentDomain(forName: suiteName)
legacyDefaults.removePersistentDomain(forName: legacySuiteName)
}
defaults.set(ProxyRuntimeMode.thirdParty.rawValue, forKey: "proxyRuntimeMode")
defaults.set(true, forKey: "hasSelectedProxyRuntimeMode")
legacyDefaults.set(true, forKey: "setupCompleted")
let migrated = ProxyRuntimeModeStore(defaults: defaults, legacyDefaults: legacyDefaults)
XCTAssertFalse(migrated.isInitialized(.localWiFi))
XCTAssertTrue(migrated.isInitialized(.thirdParty))
migrated.setMode(.localWiFi)
XCTAssertFalse(migrated.isInitialized(.localWiFi))
XCTAssertTrue(migrated.isInitialized(.thirdParty))
}
}
@@ -33,16 +33,50 @@ final class SetupCoordinatorTests: XCTestCase {
XCTAssertEqual(coordinator.setupStep, .proxy)
}
func testWiFiChangeMapsLocalProxyStartFailureToProxyReminder() {
XCTAssertEqual(VerificationResult.proxyNotRunning.wifiChangeReminderTipKind, .proxySetup)
func testExplicitCertificateRequestRoutesToCertificateStep() {
let coordinator = SetupCoordinator()
coordinator.requestCertificateSetup()
XCTAssertTrue(coordinator.needsSetup)
XCTAssertEqual(coordinator.setupStep, .cert)
}
func testWiFiChangeDoesNotPresentFailureForConcurrentVerification() {
XCTAssertNil(VerificationResult.verificationInProgress.wifiChangeReminderTipKind)
func testThirdPartyFailureRequestPreservesErrorAndRoutesToImportGuide() {
let coordinator = SetupCoordinator()
coordinator.requestThirdPartySetup(message: "模块未连接")
XCTAssertTrue(coordinator.needsSetup)
XCTAssertEqual(coordinator.setupStep, .thirdPartyImport)
XCTAssertEqual(coordinator.message, "模块未连接")
}
func testWiFiChangeCertificateFailureDoesNotUseGenericReminder() {
XCTAssertNil(VerificationResult.certNotTrusted.wifiChangeReminderTipKind)
XCTAssertNil(VerificationResult.certNotTrusted.tipKind)
func testThirdPartyOnboardingStartsWithClientSelection() {
let coordinator = SetupCoordinator()
coordinator.requestThirdPartyOnboarding()
XCTAssertTrue(coordinator.needsSetup)
XCTAssertEqual(coordinator.setupStep, .thirdPartyClient)
XCTAssertTrue(coordinator.message.isEmpty)
}
func testProxyFailurePreservesResultForPresentedGuide() {
let coordinator = SetupCoordinator()
coordinator.applyVerificationResult(.proxyNotRunning)
XCTAssertEqual(coordinator.lastVerificationResult, .proxyNotRunning)
XCTAssertTrue(coordinator.needsSetup)
XCTAssertEqual(coordinator.setupStep, .proxy)
}
func testConcurrentVerificationDoesNotOpenGuide() {
let coordinator = SetupCoordinator()
coordinator.applyVerificationResult(.verificationInProgress)
XCTAssertFalse(coordinator.needsSetup)
}
}
@@ -0,0 +1,50 @@
import XCTest
@testable import PaopaoLocationSpoofer
final class ThirdPartyCommunityPromptPreferencesTests: XCTestCase {
private var suites: [String] = []
override func tearDown() {
for suite in suites {
UserDefaults.standard.removePersistentDomain(forName: suite)
}
suites.removeAll()
super.tearDown()
}
func testSuppressionBecomesAvailableOnThirdPresentation() {
let preferences = ThirdPartyCommunityPromptPreferences(defaults: makeDefaults())
XCTAssertEqual(preferences.recordPresentation(), 1)
XCTAssertFalse(preferences.canSuppress())
XCTAssertEqual(preferences.recordPresentation(), 2)
XCTAssertFalse(preferences.canSuppress())
XCTAssertEqual(preferences.recordPresentation(), 3)
XCTAssertTrue(preferences.canSuppress())
}
func testEarlySuppressionIsIgnoredAndThirdPresentationCanPersistIt() {
let defaults = makeDefaults()
let preferences = ThirdPartyCommunityPromptPreferences(defaults: defaults)
preferences.recordPresentation()
preferences.suppress()
XCTAssertTrue(preferences.shouldPresent())
preferences.recordPresentation()
preferences.recordPresentation()
preferences.suppress()
XCTAssertFalse(preferences.shouldPresent())
let restored = ThirdPartyCommunityPromptPreferences(defaults: defaults)
XCTAssertFalse(restored.shouldPresent())
}
private func makeDefaults() -> UserDefaults {
let suite = "ThirdPartyCommunityPromptPreferencesTests.\(UUID().uuidString)"
suites.append(suite)
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
return defaults
}
}
@@ -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() {
XCTAssertEqual(ThirdPartyProxyClient.shadowrocket.verificationText, "当前可测试")
XCTAssertTrue(ThirdPartyProxyClient.surge.verificationText.contains("尚未验证"))
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)
}
}
+28
View File
@@ -8,6 +8,34 @@ fail() { echo "FAIL: $*" >&2; exit 1; }
test -f "$ROOT/App/ProxyManager.swift" || fail "ProxyManager must exist"
test -f "$ROOT/Shared/RuntimeLog.swift" || fail "RuntimeLog must exist"
test -f "$ROOT/App/RealtimeLocationManager.swift" || fail "RealtimeLocationManager must exist"
grep -q 'Location Spoofer CA' "$ROOT/App/FirstSetupView.swift" || fail "certificate setup must use the stable app-branded CA name"
! grep -q 'WLOC CA' "$ROOT/App/FirstSetupView.swift" || fail "certificate setup must not show the legacy dated CA name"
grep -q 'localWiFiRuntimeModeInitialized' "$ROOT/Shared/ProxyRuntimeMode.swift" || fail "APP mode initialization must persist independently"
grep -q 'thirdPartyRuntimeModeInitialized' "$ROOT/Shared/ProxyRuntimeMode.swift" || fail "third-party mode initialization must persist independently"
! grep -q 'runVerificationTest' "$ROOT/App/ContentView.swift" || fail "normal app startup must not run the environment verification test"
grep -q '重置证书' "$ROOT/App/SettingsView.swift" || fail "APP mode settings must expose certificate reset"
grep -q 'certificateStore.reset()' "$ROOT/App/SettingsView.swift" || fail "certificate reset must remove the persisted app CA"
grep -q 'requestCertificateSetup()' "$ROOT/App/SettingsView.swift" || fail "certificate reset must open the certificate setup flow"
grep -q 'requestThirdPartySetup(message:' "$ROOT/App/SettingsView.swift" || fail "third-party settings failures must open the setup guide"
grep -q 'SFSafariViewController' "$ROOT/App/SafariView.swift" || fail "certificate download must use an in-app Safari service"
grep -q 'prepareCertificateDownloadURL' "$ROOT/App/FirstSetupView.swift" || fail "certificate setup must prepare an in-app download URL"
! grep -q 'UIApplication.shared.open(url' "$ROOT/App/ProxyManager.swift" || fail "certificate download must not force an external browser"
grep -q 'AppModeWiFiProxy' "$ROOT/App/FirstSetupView.swift" || fail "APP proxy setup must show the Wi-Fi screenshot"
grep -q 'AppModeCertificateInstall' "$ROOT/App/FirstSetupView.swift" || fail "certificate install setup must show its screenshot"
grep -q 'AppModeCertificateTrust' "$ROOT/App/FirstSetupView.swift" || fail "certificate trust setup must show its screenshot"
grep -q 'UIImage(named: assetName)' "$ROOT/App/FirstSetupView.swift" || fail "missing screenshots must fall back to text without breaking setup"
for asset in AppModeWiFiProxy AppModeCertificateInstall AppModeCertificateTrust; do
test -s "$ROOT/Resources/Assets.xcassets/$asset.imageset/Contents.json" \
|| fail "missing APP onboarding image asset: $asset"
grep -q '\.jpg' "$ROOT/Resources/Assets.xcassets/$asset.imageset/Contents.json" \
|| fail "APP onboarding derivatives must use an Asset Catalog-supported JPEG: $asset"
done
test -s "$ROOT/docs/onboarding-screenshots/app-mode/app-mode-wifi-proxy.jpg" \
|| fail "unannotated APP-mode source screenshots must be retained by mode"
test -s "$ROOT/docs/app-icon-source.svg" || fail "the optimized app icon must retain an editable vector source"
test -s "$ROOT/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png" \
|| fail "the optimized 1024px app icon is missing"
# VPNManager and Tunnel must NOT exist
test ! -f "$ROOT/App/VPNManager.swift" || fail "VPNManager must be removed"
+13 -3
View File
@@ -4,6 +4,12 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
fail() { echo "FAIL: $*" >&2; exit 1; }
TIP_VIEWS="$ROOT/App/TipViews.swift"
grep -Fq 'Label("还是无法生效?"' "$TIP_VIEWS" || fail "activation help must use the requested retry heading"
grep -Fq 'Label("还是无法取消?"' "$TIP_VIEWS" || fail "deactivation help must use a mode-specific retry heading"
grep -q 'frame(maxWidth: .infinity, alignment: .leading)' "$TIP_VIEWS" \
|| fail "tip detail copy must align to the leading edge"
MAP_HOME="$ROOT/App/MapHomeView.swift"
MAP_STATE="$ROOT/App/MapLocationState.swift"
MAP_BRIDGE="$ROOT/App/MapViewRepresentable.swift"
@@ -91,11 +97,15 @@ grep -q '地图创建前请求实时定位' "$CONTENT" || fail "fresh realtime p
grep -q 'minimumCountForSuppression = 3' Shared/AppGroup.swift || fail "automatic tip suppression must require three successful operations"
grep -q 'activeTip = .deactivation' "$MAP_HOME" || fail "manual deactivation help must use the non-suppressible generic tip sheet"
grep -q 'stabilizationNanoseconds: UInt64 = 3_000_000_000' "$MAP_HOME" || fail "Wi-Fi changes must wait three seconds before environment verification"
grep -q 'result.wifiChangeReminderTipKind' "$MAP_HOME" || fail "Wi-Fi proxy failures must use the background reminder mapping"
grep -q 'if result == .certNotTrusted' "$MAP_HOME" || fail "Wi-Fi certificate failures must enter certificate setup"
grep -q 'setup.applyVerificationResult(result)' "$MAP_HOME" || fail "Wi-Fi certificate failures must use the setup routing reducer"
! grep -q 'wifiChangeReminderTipKind' "$MAP_HOME" || fail "Wi-Fi failures must not use a duplicate reminder mapping"
grep -q 'setup.requestSetup(message:' "$MAP_HOME" || fail "missing Wi-Fi must enter the reusable proxy setup guide"
test "$(grep -c 'setup.applyVerificationResult(result)' "$MAP_HOME")" -ge 2 \
|| fail "activation and Wi-Fi-change verification failures must use the shared setup reducer"
! grep -q 'activeTip = \.proxySetup' "$MAP_HOME" || fail "proxy failures must not use a duplicate tip sheet"
! grep -q 'case certificate' "$ROOT/App/TipViews.swift" || fail "generic certificate tip must not coexist with certificate setup"
! grep -q 'CertificateTipContent' "$ROOT/App/TipViews.swift" || fail "certificate failures must use the complete setup flow"
! grep -q 'case proxySetup' "$ROOT/App/TipViews.swift" || fail "proxy failures must use the complete setup flow"
! grep -q 'case rewriteFailed' "$ROOT/App/TipViews.swift" || fail "rewrite failures must use the complete setup flow"
! grep -q 'onChange(of: net.isAirplaneMode)' "$MAP_HOME" || fail "airplane recovery must not race the Wi-Fi change verifier"
grep -q 'hasReceivedInitialPath' "$NETWORK_MONITOR" || fail "initial network path must not be reported as a Wi-Fi switch"
grep -q 'lastKnownSSID' "$NETWORK_MONITOR" || fail "SSID polling must preserve a baseline across temporary nil readings"
+16
View File
@@ -52,6 +52,22 @@ test "$(grep -c '^## ' "$ZH")" -eq "$(grep -c '^## ' "$EN")" \
! grep -Eq '^## (许可证|License)$' "$ZH" "$EN" || fail "README must not claim a repository license"
grep -q '当前项目不支持在 Windows 上直接构建 iOS 应用' "$ZH" || fail "Chinese README must reject Windows source builds"
grep -q 'Building the iOS app directly on Windows is not supported' "$EN" || fail "English README must reject Windows source builds"
grep -q 'docs/COMMUNITY_TUTORIALS.md' "$ZH" || fail "Chinese README must link the community tutorial submission guide"
grep -q '^#### 配置接口与客户端适配$' "$ZH" \
|| fail "Chinese README must document the third-party integration contract"
grep -q '^#### Configuration API and Client Integration$' "$EN" \
|| fail "English README must document the third-party integration contract"
for contract in 'action=query' 'action=clear' 'lon=<WGS-84'; do
grep -q "$contract" "$ZH" || fail "Chinese README is missing third-party contract: $contract"
grep -q "$contract" "$EN" || fail "English README is missing third-party contract: $contract"
done
grep -q '除敏感信息遮挡外,不要自行添加箭头、编号、边框、说明文字或其他标注' \
"$ROOT/docs/COMMUNITY_TUTORIALS.md" \
|| fail "tutorial submissions must keep source screenshots free of non-privacy annotations"
grep -q '同一张截图可以对应多个步骤' "$ROOT/docs/COMMUNITY_TUTORIALS.md" \
|| fail "tutorial submissions must explain multi-step screenshot handling"
grep -q '不得覆盖上述原图' "$ROOT/docs/COMMUNITY_TUTORIALS.md" \
|| fail "annotated app assets must not replace categorized source screenshots"
if grep -Rnw --include='*.md' --include='*.sh' \
"$ROOT/build.sh" "$ROOT/README.md" "$ROOT/README.en.md" "$ROOT/docs" "$ROOT/Scripts" \
+117 -5
View File
@@ -18,16 +18,32 @@ grep -q 'guard runtimeMode.hasSelectedMode else' "$CONTENT" || fail "mode select
grep -q 'phase = .setup' "$CONTENT" || fail "first launch must enter setup before map construction"
grep -q 'case thirdPartyClient' "$SETUP" || fail "third-party client selection step is missing"
grep -q 'case thirdPartyImport' "$SETUP" || fail "third-party import step is missing"
grep -q 'case thirdPartyTest' "$SETUP" || fail "third-party connection test step is missing"
! grep -q 'case thirdPartyTest' "$SETUP" || fail "third-party connection test must be part of the import page"
! grep -q '生成并导入配置文件' "$SETUP" || fail "setup must not offer file generation/import"
grep -q '复制订阅地址' "$SETUP" || fail "subscription URL copy action is missing"
grep -q '复制模块订阅地址' "$SETUP" || fail "module subscription URL copy action is missing"
grep -Fq 'Label("打开 \(client.name)"' "$SETUP" || fail "setup must expose a client launch action"
grep -Fq 'Label("打开 \(client.name)"' "$SETUP" \
|| fail "the import page must expose the selected client launch action"
grep -Fq 'Label("打开 \(thirdPartyClient.selectedClient.name)"' "$SETTINGS" || fail "Settings must expose a client launch action"
! grep -q '在浏览器打开模块文件' "$SETTINGS" || fail "Settings must not open the module URL as the primary client action"
grep -q 'requestThirdPartySetup' "$SETTINGS" || fail "Settings must reopen third-party setup"
grep -q 'static let configurationEndpoint' "$MANAGER" \
|| fail "the third-party configuration endpoint must have one shared owner"
grep -q 'DisclosureGroup("第三方客户端适配说明")' "$SETUP" \
|| fail "client selection must expose the third-party integration contract"
grep -Fq '查询:GET ?action=query' "$SETUP" \
|| fail "client integration guidance must document the query action"
grep -Fq '保存:GET ?lon=<经度>&lat=<纬度>&acc=<精度>' "$SETUP" \
|| fail "client integration guidance must document WGS-84 coordinate saving"
grep -Fq '清除:GET ?action=clear' "$SETUP" \
|| fail "client integration guidance must document the clear action"
for file in wloc.module wloc.sgmodule wloc.conf wloc.lpx wloc.stoverride; do
test -s "$MODULES/$file" || fail "missing bundled module: $file"
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"
@@ -36,14 +52,110 @@ 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"
! grep -q '当前可测试' "$SETTINGS" || fail "Settings must not show the obsolete current-test label"
grep -q 'thirdPartyTestFailure = ThirdPartyConnectionTestFailure' "$SETUP" \
|| fail "third-party connection failures must render inline on the import page"
grep -q 'testResultView(' "$SETUP" || fail "APP and third-party tests must share the same result component"
grep -q 'showsVerificationResult = false' "$SETUP" \
|| fail "switching setup pages must hide the shared APP verification result"
grep -q 'showsThirdPartyFailureLog = false' "$SETUP" \
|| fail "switching setup pages must hide the shared third-party test log"
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.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" \
|| fail "third-party runtime failures must route directly to the import guide"
grep -q 'setup.requestThirdPartySetup(message: error.localizedDescription)' "$ROOT/App/MapHomeView.swift" \
|| fail "third-party coordinate sync failures must open the import guide"
grep -q '检测到第三方代理连接异常,请检查模块、MITM 和代理连接后重新检测' "$SETUP" \
|| fail "runtime repair must explain why the import guide opened"
test "$(grep -c 'title: \"接口连接失败\"' "$SETUP")" -eq 1 \
|| fail "third-party failure details must render in one shared result area"
grep -Fq '当前客户端:\(client.name)' "$SETUP" \
|| fail "third-party failure logs must identify the selected client"
grep -q 'HStack(spacing: 12)' "$SETUP" || fail "setup footer actions must share one horizontal row"
grep -q 'Spacer(minLength: 12)' "$SETUP" || fail "setup footer actions must stay at opposite edges"
grep -q 'ScrollViewReader' "$SETUP" || fail "setup must keep failure results reachable after insertion"
grep -q 'scrollProxy.scrollTo("thirdPartyFailureLog"' "$SETUP" \
|| fail "third-party failure must scroll to the detailed log"
grep -q '======== 第三方代理连接检测 ========' "$SETUP" \
|| fail "third-party inline diagnostics must include a structured test log"
grep -Fq 'Label("第 2 步:完成 \(client.name) 配置"' "$SETUP" \
|| fail "unverified clients must use a two-step import and configuration guide"
grep -Fq 'Text("请在 \(client.name) 中完成相应配置。")' "$SETUP" \
|| fail "unverified client configuration guidance must avoid unverified menu details"
! grep -q '进入模块、重写或覆写订阅入口' "$SETUP" \
|| fail "unverified clients must not claim untested menu locations"
grep -q '"当前客户端": client.name' "$SETUP" \
|| fail "third-party runtime diagnostics must identify the selected client"
for asset in ShadowrocketModuleImport ShadowrocketConfigDetails ShadowrocketHTTPSDecryption ShadowrocketHTTPSCA; do
test -s "$ROOT/Resources/Assets.xcassets/$asset.imageset/Contents.json" \
|| fail "missing Shadowrocket onboarding image asset: $asset"
grep -q '\.jpg' "$ROOT/Resources/Assets.xcassets/$asset.imageset/Contents.json" \
|| fail "Shadowrocket onboarding derivatives must use an Asset Catalog-supported JPEG: $asset"
grep -q "$asset" "$SETUP" || fail "setup does not reference onboarding image asset: $asset"
done
config_line="$(grep -n 'assetName: "ShadowrocketConfigDetails"' "$SETUP" | head -n 1 | cut -d: -f1)"
module_line="$(grep -n 'assetName: "ShadowrocketModuleImport"' "$SETUP" | head -n 1 | cut -d: -f1)"
test "$config_line" -lt "$module_line" || fail "Shadowrocket setup must show the configuration page before the module page"
! grep -q 'GeometryReader' "$SETUP" || fail "onboarding image markers must be baked into assets, not positioned at runtime"
test -s "$ROOT/docs/onboarding-screenshots/shadowrocket/shadowrocket-module-import.jpg" \
|| fail "unannotated Shadowrocket source screenshots must be retained by client"
! grep -q 'ToolbarItem(placement: .navigationBarLeading)' "$SETUP" || fail "setup must not show a top-left navigation action"
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.2"' "$ROOT/project.yml" || fail "marketing version must be 1.0.2"
grep -q 'CURRENT_PROJECT_VERSION: "3"' "$ROOT/project.yml" || fail "build version must be 3"
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"
@@ -0,0 +1,169 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONFIG="$ROOT/Shared/AppRemoteConfiguration.swift"
CONTENT="$ROOT/App/ContentView.swift"
MAP="$ROOT/App/MapHomeView.swift"
SETUP="$ROOT/App/FirstSetupView.swift"
SETTINGS="$ROOT/App/SettingsView.swift"
BUG_REPORT="$ROOT/App/BugReportView.swift"
GITHUB_SUBMISSION="$ROOT/Shared/GitHubSubmission.swift"
DISCUSSION_FORM="$ROOT/.github/DISCUSSION_TEMPLATE/第三方配置分享.yml"
ISSUE_FORM="$ROOT/.github/ISSUE_TEMPLATE/bug-report.yml"
ISSUE_CONFIG="$ROOT/.github/ISSUE_TEMPLATE/config.yml"
fail() {
echo "FAIL: $1" >&2
exit 1
}
python3 - "$ROOT/version.txt" <<'PY' || exit 1
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
config = json.load(handle)
assert config["latestVersion"] == "1.0.5"
assert config["minimumSupportedVersion"] == "1.0.0"
assert "shadowrocket" not in config["communityPromptClients"]
assert set(config["communityPromptClients"]) == {
"surge", "quantumultX", "loon", "stash", "egern"
}
PY
grep -q 'static let fallback = AppRemoteConfiguration' "$CONFIG" \
|| fail "the app must ship a built-in remote-configuration fallback"
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" \
|| fail "update notes must come from the archived release document"
grep -q '/releases/latest' "$CONFIG" \
|| fail "missing release notes must fall back to the latest Release page"
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"
grep -q 'Button("去提交")' "$MAP" \
|| fail "community contribution prompt must expose the submit action"
grep -q 'Button("复制模板")' "$MAP" \
|| fail "community contribution prompt must expose an explicit copy action"
grep -q 'Button("取消", role: .cancel)' "$MAP" \
|| fail "the first community prompts must expose cancel"
grep -q 'Button("不再提示", role: .cancel)' "$MAP" \
|| fail "the third community prompt must allow permanent suppression"
grep -q '匿名收录,不在 README 展示投稿账号' "$GITHUB_SUBMISSION" \
|| fail "the contribution template must offer anonymous README attribution"
grep -Fq 'https://github.com/xweiba/location-spoofer/discussions/new?category=%E7%AC%AC%E4%B8%89%E6%96%B9%E9%85%8D%E7%BD%AE%E5%88%86%E4%BA%AB' "$GITHUB_SUBMISSION" \
|| fail "community submission must use one fixed Discussions address"
grep -q 'UIPasteboard.general.string = GitHubSubmission.communityContributionTemplate' "$MAP" \
|| fail "the explicit copy action must copy the contribution template"
grep -A8 'Button("去提交")' "$MAP" | grep -q 'UIPasteboard.general.string = GitHubSubmission.communityContributionTemplate' \
|| fail "the submit action must copy the template before opening GitHub"
grep -A8 'Button("去提交")' "$MAP" | grep -q 'openCommunityContributionPage()' \
|| fail "the submit action must open the fixed Discussions page after copying"
! grep -A12 'private func openCommunityContributionPage' "$MAP" | grep -q 'URLComponents' \
|| fail "the fixed community address must not be rebuilt from dynamic query items"
grep -A4 'private func openCommunityContributionPage' "$MAP" | grep -q 'SafariDestination' \
|| fail "community submission must open with the shared in-App Safari destination"
! grep -A4 'private func openCommunityContributionPage' "$MAP" | grep -q 'UIApplication.shared.open' \
|| fail "community submission must not be intercepted by an external GitHub client"
test -s "$DISCUSSION_FORM" \
|| fail "the fixed Discussions category must provide a maintained submission form"
grep -q 'label: 第三方客户端' "$DISCUSSION_FORM" \
|| fail "the Discussions form must classify submissions by client"
grep -q '匿名收录,不在 README 展示投稿账号' "$DISCUSSION_FORM" \
|| fail "the Discussions form must retain anonymous README attribution"
grep -q '请先移除账号、订阅、密码、设备标识、真实位置等敏感信息' "$DISCUSSION_FORM" \
|| fail "the Discussions form must remind users to remove sensitive information"
grep -q '截图除敏感信息遮挡外不要添加箭头、编号或说明文字' "$DISCUSSION_FORM" \
|| fail "the Discussions form must explain the screenshot annotation rule"
! grep -q 'label: 隐私确认' "$DISCUSSION_FORM" \
|| fail "the Discussions form must not require a separate privacy confirmation"
for field in \
'## 第三方客户端' \
'## 第三方客户端版本' \
'## iOS 版本' \
'## 配置步骤' \
'## 截图与补充说明' \
'## README 收录署名'; do
grep -Fq "$field" "$GITHUB_SUBMISSION" \
|| fail "the App contribution template must match Discussions field: $field"
done
grep -Fq 'label: 第三方客户端版本' "$DISCUSSION_FORM" \
|| fail "the Discussions form must clearly request the third-party client version"
grep -Fq 'https://github.com/xweiba/location-spoofer/issues/new?template=bug-report.yml' "$GITHUB_SUBMISSION" \
|| fail "the App bug report must open the dedicated Issue form"
grep -Fq 'GitHubSubmission.bugReportURL' "$BUG_REPORT" \
|| fail "the App bug report must use the shared GitHub destination"
grep -Fq 'SafariView(url: destination.url)' "$BUG_REPORT" \
|| fail "the App bug report must open in the shared in-App Safari view"
! grep -q 'UIApplication.shared.open' "$BUG_REPORT" \
|| fail "the App bug report must not be handed to an external GitHub client"
grep -Fq 'App 生成的诊断报告' "$BUG_REPORT" \
|| fail "the App must tell users where to paste the generated report"
grep -Fq '第三方客户端:' "$BUG_REPORT" \
|| fail "the generated report must identify the selected third-party client"
grep -Fq 'Label("报告 Bug"' "$SETTINGS" \
|| fail "Settings must identify the support action as a bug report"
for action in '使用帮助' '功能建议' '分享第三方配置'; do
grep -Fq "Label(\"$action\"" "$SETTINGS" \
|| fail "Settings support must expose: $action"
done
grep -A8 'Label("分享第三方配置"' "$SETTINGS" >/dev/null \
|| fail "Settings must retain the manual third-party sharing action"
grep -q 'GitHubSubmission.communityContributionTemplate' "$SETTINGS" \
|| fail "Settings manual sharing must copy the shared contribution template"
grep -q 'SafariView(url: destination.url)' "$SETTINGS" \
|| fail "Settings GitHub support actions must use the in-App Safari view"
test -s "$ISSUE_FORM" \
|| fail "the repository must provide a bug report Issue form"
grep -Fq 'label: App 生成的诊断报告' "$ISSUE_FORM" \
|| fail "the Issue form must expose the field named by the App"
grep -Fq '设置 → 支持 → 报告 Bug' "$ISSUE_FORM" \
|| fail "the Issue form must point users to the matching App workflow"
grep -Fq 'label: 我已移除真实位置、认证信息、订阅地址、证书私钥和其他敏感信息' "$ISSUE_FORM" \
|| fail "the Issue form must require privacy confirmation"
grep -Fq 'blank_issues_enabled: false' "$ISSUE_CONFIG" \
|| fail "blank Issues must be disabled so reports use the maintained form"
for destination in '使用帮助' '功能建议' '第三方配置分享'; do
grep -Fq "name: $destination" "$ISSUE_CONFIG" \
|| fail "Issue chooser must route non-bug request to $destination"
done
for obsolete in \
'我已配置,开始检测' \
'确认完成,重新检测' \
'下一步:导入配置' \
'我已导入,检测接口连接'; do
! grep -q "$obsolete" "$SETUP" \
|| fail "setup footer must not retain dynamic label: $obsolete"
done
test "$(grep -c 'actionLabel("完成")' "$SETUP")" -eq 4 \
|| fail "all setup footer primary actions must use the fixed 完成 label"
application_line="$(grep -n 'Section("应用")' "$SETTINGS" | head -n 1 | cut -d: -f1)"
certificate_line="$(grep -n 'Section("证书")' "$SETTINGS" | head -n 1 | cut -d: -f1)"
test "$application_line" -lt "$certificate_line" \
|| fail "the APP certificate reset section must appear below the application section"
echo "PASS: update and community contribution contract"
+6
View File
@@ -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.
+19
View File
@@ -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"
});
}
+1
View File
@@ -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"})}}})();
File diff suppressed because one or more lines are too long
+11
View File
@@ -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
View File
@@ -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
+12
View File
@@ -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
+12
View File
@@ -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
+31
View File
@@ -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
View File
@@ -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)"
}
}
}
+16
View File
@@ -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"
}
}
+263
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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]
);
});
+3
View File
@@ -4,6 +4,9 @@
## 已发布
- [v1.0.5](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.5) — 2026-08-10
- [v1.0.4](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.4) — 2026-08-09
- [v1.0.3](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.3) — 2026-08-09
- [v1.0.2](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.2) — 2026-08-07
- [v1.0.1](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.1) — 2026-08-06
- [v1.0.0](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.0) — 2026-08-05
+84
View File
@@ -0,0 +1,84 @@
# 社区客户端教程与截图提交
我们公开征集 Shadowrocket、Surge、Quantumult X、Loon、Stash 和 Egern 的配置教程与真机截图。
提交内容会经过复核、统一裁剪和标注后再进入 App 或项目文档。
## 客户端分区
投稿统一进入 GitHub Discussions 的“第三方配置分享”固定入口。表单通过“第三方客户端”下拉框分类:
| 客户端 | 表单选项 |
|---|---|
| Shadowrocket | `Shadowrocket` |
| Surge | `Surge` |
| Quantumult X | `Quantumult X` |
| Loon | `Loon` |
| Stash | `Stash` |
| Egern | `Egern` |
App 始终打开同一个投稿地址,仓库中的 Discussion 表单负责维护客户端、版本、步骤、截图和署名字段。
“去提交”会先复制带当前客户端名称的投稿模板,再通过 App 内 Safari 打开固定页面,避免被不支持
Discussions 的 GitHub 客户端拦截。用户也可以从 Safari 视图的分享菜单改用其他浏览器。“复制模板”按钮仍可
单独使用,设置页的“支持 → 分享第三方配置”也提供相同入口。采纳后的配置在 README 客户端状态表中按客户端
收录,并链接到教程或原始投稿。投稿者可以选择匿名收录;未选择匿名时可在 README 中显示 GitHub 账号。
## 提交内容
请提供:
1. 客户端名称和版本;
2. iOS 版本;
3. 从导入模块到启用代理的完整操作步骤;
4. 每一步对应的原始截图文件名;
5. 需要点击、开启或填写的位置说明;
6. 最终是否验证通过,以及失败时看到的错误。
推荐按下面的格式描述步骤:
```text
客户端:Shadowrocket
版本:
iOS 版本:
步骤 1
- 截图:01-module-list.jpg
- 操作:点击右上角“+”
步骤 2
- 截图:02-https-decryption.jpg
- 操作:开启 HTTPS 解密,并填写 gs-loc.apple.com、gs-loc-cn.apple.com
验证结果:
```
## 截图要求
- 提交原始、清晰、未压缩过度的截图。
- 除敏感信息遮挡外,不要自行添加箭头、编号、边框、说明文字或其他标注。
- 不要为了突出某一步自行裁剪。请在步骤说明中写清需要点击或填写的位置,由维护者统一裁剪和编号。
- 同一张截图可以对应多个步骤,请分别说明每个步骤的位置和顺序。
- 可以遮挡账号、订阅地址、节点、密码、设备标识、开发者账号、真实位置和通知内容。
- 必须完整遮挡证书私钥、认证 Token、Cookie、完整订阅链接以及其他可用于访问账户或服务的信息。
- 遮挡后请再次检查状态栏、列表副标题、输入框、二维码和弹窗,避免遗漏。
- 建议使用稳定、描述性的英文文件名,例如 `shadowrocket-https-decryption.jpg`
维护者会保留未标注的脱敏原图,并在 App 中使用统一的编号圆点和步骤文字叠加标注。这样同一张图可以复用
多个步骤,客户端界面变化时也能单独替换截图而不重做整套教程。
原图按客户端或运行模式分类保存:
```text
docs/onboarding-screenshots/
├── app-mode/
└── shadowrocket/
```
App 使用的裁剪、压缩和编号版本保存在 `Resources/Assets.xcassets/`,不得覆盖上述原图。新增客户端时创建
独立的小写英文目录,例如 `surge/``quantumult-x/`
## 审核边界
- 教程必须来自实际客户端操作,不能把未验证配置描述为已支持。
- 截图和步骤不得包含付费节点、共享账号、破解内容或绕过服务限制的方法。
- 项目只审核教程是否清晰、是否脱敏以及是否能复现,不为第三方客户端、订阅服务或网络环境提供担保。
- 后续客户端版本变化导致界面或步骤失效时,教程可能被标记为待复核或替换。
+44 -17
View File
@@ -1,15 +1,22 @@
# Third-party proxy module snapshots
# Third-party proxy modules
The files under `Resources/ThirdPartyProxyModules/` are bundled configuration
snapshots from [Yu9191/wloc](https://github.com/Yu9191/wloc). They are retained
in the App bundle for release provenance and offline inspection. The setup UI
copies the official subscription URL instead of exporting these files.
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.
+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<rect width="1024" height="1024" fill="#087FF5"/>
<path
d="M168 736 C292 736 284 590 420 590 C520 590 514 458 606 458"
fill="none"
stroke="#FFFFFF"
stroke-width="42"
stroke-linecap="round"
opacity="0.42"
/>
<circle cx="168" cy="736" r="50" fill="#35D6C1" stroke="#FFFFFF" stroke-width="24"/>
<path
d="M656 156 C488 156 370 276 370 434 C370 638 656 878 656 878 C656 878 942 638 942 434 C942 276 824 156 656 156 Z"
fill="#FFFFFF"
/>
<circle cx="656" cy="420" r="126" fill="#FF453A"/>
<circle cx="656" cy="420" r="48" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 681 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

+32
View File
@@ -0,0 +1,32 @@
# v1.0.3
发布日期:2026-08-09
## 主要更新
- 增加 APP 模式与第三方代理模式的独立初始化状态和切换引导。
- 调整环境检测时机,启动时不再阻塞首屏,开启虚拟定位时再执行检测。
- 完善证书重置、下载、安装、信任和异常修复流程。
- 完善第三方代理导入、连接检测、诊断日志和客户端跳转。
## 第三方代理与社区
- 增加 Shadowrocket 图文引导并修正配置步骤顺序。
- 增加社区配置分享、Bug 报告和 GitHub Discussions 工作流。
- 补充第三方 WLOC 配置接口、JSON 格式及客户端适配说明。
- 支持通过远程配置控制社区投稿提示。
## 更新与兼容性
- 增加异步版本检测,支持强制更新和推荐更新策略。
- App 内置远程配置兜底,不因 GitHub 访问超时阻塞首屏。
- 新生成的根证书固定显示为 `Location Spoofer CA`;已有有效证书继续复用。
## 自签安装
- 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.2..v1.0.3 -->
+29
View File
@@ -0,0 +1,29 @@
# v1.0.4
发布日期:2026-08-09
## 主要更新
- 增加结构化 ARPC 响应解析,修改定位数据后会正确更新 32 位 payload 长度。
- 完善 marker、synthetic 和 bare protobuf 响应兼容,保留原始封装前后缀及未知字段。
- 补充 CellTower 字段 22/24、ARPC 大 payload 和多响应格式回归测试。
## 文档与致谢
- 整理项目参考、社区贡献和友链结构。
- 补充相关定位服务研究项目致谢。
## 兼容性说明
- WiFi 和 CellTower 坐标替换逻辑保持不变。
- 无法识别的新响应封装会回退原有检测路径,不会截断响应。
- 本版本未写入语义尚未确认的运动状态字段。
## 自签安装
- Release 附件为未签名 IPA,安装前需要自行签名。
- 可使用免费 Apple ID 和 Impactor 完成签名安装,无需付费开发者账号。
- 签名时请保留 Bundle ID `com.paopaolabs.location-spoofer`、App Group `group.com.paopaolabs.location-spoofer` 及原有 entitlements。
- 免费 Apple ID 签名通常只有 7 天有效期,到期后需要重新签名安装。
<!-- commit-range: v1.0.3..v1.0.4 -->
+36
View File
@@ -0,0 +1,36 @@
# v1.0.5
发布日期:2026-08-10
## 主要更新
- APP 模式与第三方代理模式统一支持 Wi-Fi、CellTower 字段 22/24,以及 ARPC、marker、synthetic、bare 等 WLOC 响应格式。
- 新增默认关闭的运动状态模拟,可在定位响应中同步模拟运动状态。
- 第三方模式改用项目维护的固定版本脚本,支持 Shadowrocket、Surge、Quantumult X、Loon、Stash 和 Egern。
- 设置页新增手动检查更新入口;版本配置和更新说明优先使用国内镜像,并保留 GitHub 官方源兜底。
## 第三方模式
- 基础坐标同步继续兼容旧模块,不再因缺少版本接口阻断导入或开启虚拟定位。
- 运动状态模拟属于高级功能;开启前必须通过 `/wloc-settings/version` 协议与能力检测,检测失败时保持关闭并提示更新模块。
- 完善模块缓存更新、连接诊断和失败引导,保留当前第三方客户端及详细错误信息。
- Shadowrocket 和其他第三方客户端引导统一补充 `gs-loc.apple.com``gs-loc-cn.apple.com` 两个 HTTPS 解密域名及复制入口。
## 引导与资源
- 更新 Shadowrocket HTTPS 解密截图,直接标注开关、双域名和证书入口步骤。
- 公共教程同步双域名配置要求,同时保留未经标注的原始截图。
## 兼容性说明
- 运动状态模拟默认关闭,升级后不会改变已有定位行为。
- 旧版第三方模块仍可使用基础坐标功能;需要运动状态模拟时请重新导入最新模块。
## 自签安装
- Release 附件为未签名 IPA,安装前需要自行签名。
- 可使用免费 Apple ID 和 Impactor 完成签名安装,无需付费开发者账号。
- 签名时请保留 Bundle ID `com.paopaolabs.location-spoofer`、App Group `group.com.paopaolabs.location-spoofer` 及原有 entitlements。
- 免费 Apple ID 签名通常只有 7 天有效期,到期后需要重新签名安装。
<!-- commit-range: v1.0.4..v1.0.5 -->
+2 -2
View File
@@ -8,8 +8,8 @@ options:
settings:
base:
SWIFT_VERSION: "5.9"
MARKETING_VERSION: "1.0.2"
CURRENT_PROJECT_VERSION: "3"
MARKETING_VERSION: "1.0.5"
CURRENT_PROJECT_VERSION: "6"
CODE_SIGN_STYLE: Manual
CODE_SIGNING_ALLOWED: "NO"
CODE_SIGNING_REQUIRED: "NO"
+5
View File
@@ -0,0 +1,5 @@
{
"latestVersion": "1.0.5",
"minimumSupportedVersion": "1.0.0",
"communityPromptClients": ["surge", "quantumultX", "loon", "stash", "egern"]
}