mirror of
https://github.com/xweiba/location-spoofer.git
synced 2026-09-21 22:30:46 +08:00
chore: prepare v1.0.5 release
This commit is contained in:
+38
-22
@@ -52,6 +52,7 @@ struct FirstSetupView: View {
|
||||
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
|
||||
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
|
||||
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
|
||||
@ObservedObject private var motionSimulation = MotionSimulationStore.shared
|
||||
@State private var copiedSubscriptionURL = false
|
||||
@State private var copiedMITMHostname = false
|
||||
@State private var screenshotPreview: SetupScreenshotPreview?
|
||||
@@ -533,10 +534,17 @@ struct FirstSetupView: View {
|
||||
shadowrocketHTTPSDecryptionGuide
|
||||
} else {
|
||||
GroupBox(label: Label("第 2 步:完成 \(client.name) 配置", systemImage: "slider.horizontal.3")) {
|
||||
Text("请在 \(client.name) 中完成相应配置。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("请在 \(client.name) 中完成相应配置。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text("配置时请使用 gs-loc.apple.com 和 gs-loc-cn.apple.com 两个域名。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
mitmHostnameCopyButton
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,21 +564,14 @@ struct FirstSetupView: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
instructionRow(1, "进入“配置 → 本地文件”,找到带黄点的配置,点击右侧 i 图标。")
|
||||
instructionRow(2, "进入“HTTPS 解密”,开启解密开关。")
|
||||
instructionRow(3, "在域名列表中添加 gs-loc.apple.com。")
|
||||
instructionRow(3, "在域名列表中添加 gs-loc.apple.com 和 gs-loc-cn.apple.com。")
|
||||
setupScreenshot(
|
||||
assetName: "ShadowrocketHTTPSDecryption",
|
||||
title: "配置 HTTPS 解密",
|
||||
caption: "1 开启 HTTPS 解密,2 添加 gs-loc.apple.com,3 打开证书设置。"
|
||||
caption: "1 开启 HTTPS 解密,2 添加 gs-loc.apple.com 和 gs-loc-cn.apple.com,3 打开证书设置。"
|
||||
)
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = ThirdPartyProxyManager.interceptionHostname
|
||||
copiedMITMHostname = true
|
||||
} label: {
|
||||
Label(copiedMITMHostname ? "已复制 gs-loc.apple.com" : "复制解密域名", systemImage: "doc.on.doc")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
mitmHostnameCopyButton
|
||||
|
||||
instructionRow(4, "按 Shadowrocket 提示生成并完成证书授权。")
|
||||
setupScreenshot(
|
||||
@@ -595,6 +596,20 @@ struct FirstSetupView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var mitmHostnameCopyButton: some View {
|
||||
Button {
|
||||
UIPasteboard.general.string = ThirdPartyProxyManager.interceptionHostnamesText
|
||||
copiedMITMHostname = true
|
||||
} label: {
|
||||
Label(
|
||||
copiedMITMHostname ? "已复制两个解密域名" : "复制两个解密域名",
|
||||
systemImage: "doc.on.doc"
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
|
||||
private func instructionRow(_ number: Int, _ text: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("\(number)")
|
||||
@@ -818,16 +833,17 @@ struct FirstSetupView: View {
|
||||
Task { @MainActor in
|
||||
defer { isVerifying = false }
|
||||
do {
|
||||
let version = try await thirdPartyProxy.validateVersion()
|
||||
let response = try await thirdPartyProxy.query()
|
||||
let response = try await thirdPartyProxy.validateConnection()
|
||||
let advancedFeaturesAvailable = await thirdPartyProxy.refreshAdvancedFeatureAvailability()
|
||||
if !advancedFeaturesAvailable {
|
||||
motionSimulation.setEnabled(false)
|
||||
}
|
||||
let elapsedMilliseconds = Int(Date().timeIntervalSince(startedAt) * 1_000)
|
||||
RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方代理连接检测通过", details: [
|
||||
"当前客户端": client.name,
|
||||
"模块版本": version.moduleVersion,
|
||||
"协议版本": String(version.protocolVersion),
|
||||
"能力": version.capabilities.sorted().joined(separator: ","),
|
||||
"请求动作": "WLOC query",
|
||||
"连接状态": response.latitude == nil || response.longitude == nil ? "已连接,无保存坐标" : "已连接,有保存坐标",
|
||||
"运动状态模拟": advancedFeaturesAvailable ? "支持" : "不支持,已关闭",
|
||||
"耗时毫秒": String(elapsedMilliseconds)
|
||||
])
|
||||
onComplete()
|
||||
@@ -846,14 +862,14 @@ struct FirstSetupView: View {
|
||||
"连接状态": connectionState,
|
||||
"耗时毫秒": String(elapsedMilliseconds),
|
||||
"错误类型": errorType,
|
||||
"处理建议": "检查模块、MITM、证书和代理/VPN连接"
|
||||
"处理建议": ThirdPartyProxyError.recoverySuggestion(for: error)
|
||||
]
|
||||
)
|
||||
thirdPartyTestFailure = ThirdPartyConnectionTestFailure(
|
||||
message: """
|
||||
======== 第三方代理连接检测 ========
|
||||
当前客户端:\(client.name)
|
||||
版本接口:/wloc-settings/version
|
||||
配置接口:/wloc-settings/save
|
||||
请求动作:WLOC query
|
||||
检查范围:模块拦截、MITM、证书、代理/VPN 连接
|
||||
连接状态:\(connectionState)
|
||||
@@ -861,7 +877,7 @@ struct FirstSetupView: View {
|
||||
耗时:\(elapsedMilliseconds) ms
|
||||
错误类型:\(errorType)
|
||||
错误详情:\(error.localizedDescription)
|
||||
处理建议:确认模块已启用,并检查 MITM、证书和代理/VPN 连接后重试。
|
||||
处理建议:\(ThirdPartyProxyError.recoverySuggestion(for: error))。
|
||||
"""
|
||||
)
|
||||
showsThirdPartyFailureLog = true
|
||||
|
||||
@@ -662,7 +662,7 @@ struct MapHomeView: View {
|
||||
"当前客户端": thirdPartyClient.selectedClient.name,
|
||||
"请求动作": "WLOC save",
|
||||
"恢复状态": wasActive ? "保留原第三方坐标" : "保持未启用",
|
||||
"处理建议": "检查模块、MITM、证书和代理/VPN连接"
|
||||
"处理建议": ThirdPartyProxyError.recoverySuggestion(for: error)
|
||||
]
|
||||
)
|
||||
setup.requestThirdPartySetup(message: error.localizedDescription)
|
||||
@@ -744,7 +744,7 @@ struct MapHomeView: View {
|
||||
"当前客户端": thirdPartyClient.selectedClient.name,
|
||||
"请求动作": "WLOC clear",
|
||||
"恢复状态": "保留已启用状态",
|
||||
"处理建议": "检查模块、MITM、证书和代理/VPN连接"
|
||||
"处理建议": ThirdPartyProxyError.recoverySuggestion(for: error)
|
||||
]
|
||||
)
|
||||
setup.requestThirdPartySetup(message: error.localizedDescription)
|
||||
|
||||
+173
-10
@@ -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
|
||||
@@ -15,8 +32,11 @@ struct SettingsView: View {
|
||||
@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 {
|
||||
@@ -66,7 +86,11 @@ struct SettingsView: View {
|
||||
|
||||
Section("定位模拟") {
|
||||
Toggle("运动状态模拟", isOn: motionSimulationBinding)
|
||||
.disabled(modeOperationRunning || actions.state.isBusy || thirdPartyProxy.isRequesting)
|
||||
.disabled(
|
||||
modeOperationRunning ||
|
||||
actions.state.isBusy ||
|
||||
thirdPartyProxy.isRequesting
|
||||
)
|
||||
Text("实验性功能,默认关闭。开启后会同时模拟定位响应中的运动状态。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -109,6 +133,19 @@ struct SettingsView: View {
|
||||
Label("进入引导页", systemImage: "arrow.clockwise.circle")
|
||||
}
|
||||
}
|
||||
Button {
|
||||
checkForUpdates()
|
||||
} label: {
|
||||
if isCheckingForUpdates {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("正在检查…")
|
||||
}
|
||||
} else {
|
||||
Label("检查更新", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
}
|
||||
.disabled(isCheckingForUpdates)
|
||||
valueRow("版本", value: versionText)
|
||||
}
|
||||
|
||||
@@ -205,6 +242,9 @@ struct SettingsView: View {
|
||||
} message: {
|
||||
Text(proxyOperationError)
|
||||
}
|
||||
.alert(item: $updateCheckResult) { result in
|
||||
updateCheckAlert(for: result)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"重置证书?",
|
||||
isPresented: $showCertificateResetConfirmation,
|
||||
@@ -217,6 +257,16 @@ struct SettingsView: View {
|
||||
} message: {
|
||||
Text("当前虚拟定位和本地代理将停止。App 会删除钥匙串中的设备 CA、立即生成新证书,并打开安装与信任引导。你还需要前往 iOS「设置 → 通用 → VPN 与设备管理」手动删除旧证书,然后重新下载安装并完全信任新证书。")
|
||||
}
|
||||
.task(id: runtimeMode.mode) {
|
||||
guard runtimeMode.mode == .thirdParty, !modeOperationRunning else { return }
|
||||
if !(await thirdPartyProxy.refreshAdvancedFeatureAvailability()) {
|
||||
disableUnsupportedThirdPartyMotionSimulation()
|
||||
}
|
||||
}
|
||||
.onChange(of: thirdPartyProxy.moduleUpdateRecommended) { updateRecommended in
|
||||
guard updateRecommended else { return }
|
||||
disableUnsupportedThirdPartyMotionSimulation()
|
||||
}
|
||||
}
|
||||
|
||||
private func valueRow(_ title: String, value: String) -> some View {
|
||||
@@ -229,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 {
|
||||
@@ -267,7 +382,19 @@ struct SettingsView: View {
|
||||
return
|
||||
}
|
||||
guard thirdPartyProxy.activeSettings?.success == true else {
|
||||
motionSimulation.setEnabled(enabled)
|
||||
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
|
||||
@@ -283,7 +410,11 @@ struct SettingsView: View {
|
||||
error: error,
|
||||
details: ["当前客户端": thirdPartyClient.selectedClient.name]
|
||||
)
|
||||
setup.requestThirdPartySetup(message: error.localizedDescription)
|
||||
if error as? ThirdPartyProxyError == .moduleOutdated {
|
||||
presentMotionSimulationModuleUpdateAlert()
|
||||
} else {
|
||||
setup.requestThirdPartySetup(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
modeOperationRunning = false
|
||||
}
|
||||
@@ -311,6 +442,12 @@ struct SettingsView: View {
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if thirdPartyProxy.moduleUpdateRecommended {
|
||||
Text("当前模块版本较旧,基础坐标功能仍可继续使用。重新导入最新模块后可使用版本检测和运动状态模拟。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
if let verificationText = thirdPartyClient.selectedClient.verificationText {
|
||||
HStack {
|
||||
Text("验证状态")
|
||||
@@ -328,6 +465,13 @@ struct SettingsView: View {
|
||||
Label(copiedClient == thirdPartyClient.selectedClient ? "已复制模块订阅地址" : "复制模块订阅地址", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = ThirdPartyProxyManager.interceptionHostnamesText
|
||||
copiedMITMHostnames = true
|
||||
} label: {
|
||||
Label(copiedMITMHostnames ? "已复制两个解密域名" : "复制两个解密域名", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
Button {
|
||||
openThirdPartyClient(thirdPartyClient.selectedClient)
|
||||
} label: {
|
||||
@@ -349,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)
|
||||
}
|
||||
}
|
||||
@@ -387,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 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -404,8 +548,8 @@ struct SettingsView: View {
|
||||
runtimeMode.setMode(.thirdParty)
|
||||
if runtimeMode.isInitialized(.thirdParty) {
|
||||
do {
|
||||
_ = try await thirdPartyProxy.validateVersion()
|
||||
_ = try await thirdPartyProxy.query()
|
||||
_ = try await thirdPartyProxy.validateConnection()
|
||||
refreshThirdPartyAdvancedFeatures()
|
||||
proxyOperationAlertTitle = "模式已切换"
|
||||
proxyOperationError = "第三方代理模式检测通过。请关闭 Wi-Fi 中的 127.0.0.1:8888 手动代理,避免双重拦截。"
|
||||
} catch {
|
||||
@@ -447,8 +591,8 @@ struct SettingsView: View {
|
||||
let startedAt = Date()
|
||||
Task { @MainActor in
|
||||
do {
|
||||
_ = try await thirdPartyProxy.validateVersion()
|
||||
_ = try await thirdPartyProxy.query()
|
||||
_ = try await thirdPartyProxy.validateConnection()
|
||||
refreshThirdPartyAdvancedFeatures()
|
||||
RuntimeLogger.info("APP", "ThirdPartyProxy", "设置页第三方连接检测通过", details: [
|
||||
"当前客户端": client.name,
|
||||
"请求动作": "WLOC query",
|
||||
@@ -466,7 +610,7 @@ struct SettingsView: View {
|
||||
"请求动作": "WLOC query",
|
||||
"连接状态": String(describing: thirdPartyProxy.connectionState),
|
||||
"耗时毫秒": String(Int(Date().timeIntervalSince(startedAt) * 1_000)),
|
||||
"处理建议": "检查模块、MITM、证书和代理/VPN连接"
|
||||
"处理建议": ThirdPartyProxyError.recoverySuggestion(for: error)
|
||||
]
|
||||
)
|
||||
openThirdPartySetup(for: error)
|
||||
@@ -474,6 +618,25 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshThirdPartyAdvancedFeatures() {
|
||||
Task { @MainActor in
|
||||
if !(await thirdPartyProxy.refreshAdvancedFeatureAvailability()) {
|
||||
disableUnsupportedThirdPartyMotionSimulation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func presentMotionSimulationModuleUpdateAlert() {
|
||||
disableUnsupportedThirdPartyMotionSimulation()
|
||||
proxyOperationAlertTitle = "无法开启运动状态模拟"
|
||||
proxyOperationError = "当前模块脚本不支持运动状态模拟,请重新导入最新模块脚本后再开启。基础坐标功能仍可继续使用。"
|
||||
}
|
||||
|
||||
private func disableUnsupportedThirdPartyMotionSimulation() {
|
||||
guard runtimeMode.mode == .thirdParty else { return }
|
||||
motionSimulation.setEnabled(false)
|
||||
}
|
||||
|
||||
private func openThirdPartySetup(for error: Error) {
|
||||
setup.requestThirdPartySetup(message: error.localizedDescription)
|
||||
dismiss()
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ responses in a controlled test environment.
|
||||
[](project.yml)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](docs/CHANGELOG.md)
|
||||
|
||||
[Features](#feature-overview) ·
|
||||
[How It Works](#how-it-works) ·
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
[](project.yml)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](docs/CHANGELOG.md)
|
||||
|
||||
[功能概览](#功能概览) ·
|
||||
[工作原理](#工作原理) ·
|
||||
|
||||
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 67 KiB |
@@ -23,7 +23,7 @@ struct AppRemoteConfiguration: Decodable, Equatable {
|
||||
let communityPromptClients: [String]
|
||||
|
||||
static let fallback = AppRemoteConfiguration(
|
||||
latestVersion: "1.0.4",
|
||||
latestVersion: "1.0.5",
|
||||
minimumSupportedVersion: "1.0.0",
|
||||
communityPromptClients: [
|
||||
ThirdPartyProxyClient.surge.rawValue,
|
||||
@@ -107,87 +107,99 @@ final class AppRemoteConfigurationStore: ObservableObject {
|
||||
}
|
||||
|
||||
enum AppRemoteConfigurationService {
|
||||
static let configurationURL = URL(
|
||||
string: "https://raw.githubusercontent.com/xweiba/location-spoofer/main/version.txt"
|
||||
)!
|
||||
static let configurationURLs = [
|
||||
URL(
|
||||
string: "https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/version.txt"
|
||||
)!,
|
||||
URL(
|
||||
string: "https://raw.githubusercontent.com/xweiba/location-spoofer/main/version.txt"
|
||||
)!
|
||||
]
|
||||
static let releasesURL = URL(
|
||||
string: "https://github.com/xweiba/location-spoofer/releases/latest"
|
||||
)!
|
||||
|
||||
static func fetch() async -> AppRemoteConfiguration? {
|
||||
let sessionConfiguration = URLSessionConfiguration.ephemeral
|
||||
sessionConfiguration.timeoutIntervalForRequest = 1.5
|
||||
sessionConfiguration.timeoutIntervalForResource = 2
|
||||
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
sessionConfiguration.waitsForConnectivity = false
|
||||
let session = URLSession(configuration: sessionConfiguration)
|
||||
let session = makeSession()
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
var request = URLRequest(url: configurationURL)
|
||||
request.timeoutInterval = 1.5
|
||||
for url in configurationURLs {
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 1.5
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
RuntimeLogger.warning("APP", "Update", "版本配置请求返回非 200,继续使用内置配置")
|
||||
return nil
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
continue
|
||||
}
|
||||
let configuration = try AppRemoteConfiguration.decode(data)
|
||||
RuntimeLogger.info("APP", "Update", "远程版本配置加载成功", details: [
|
||||
"来源": url.host ?? "未知",
|
||||
"最新版本": configuration.latestVersion,
|
||||
"最低版本": configuration.minimumSupportedVersion,
|
||||
"社区征集客户端数": String(configuration.communityPromptClients.count)
|
||||
])
|
||||
return configuration
|
||||
} catch {
|
||||
RuntimeLogger.info("APP", "Update", "版本配置源不可用,尝试下一地址", details: [
|
||||
"来源": url.host ?? "未知",
|
||||
"错误": error.localizedDescription
|
||||
])
|
||||
}
|
||||
let configuration = try AppRemoteConfiguration.decode(data)
|
||||
RuntimeLogger.info("APP", "Update", "远程版本配置加载成功", details: [
|
||||
"最新版本": configuration.latestVersion,
|
||||
"最低版本": configuration.minimumSupportedVersion,
|
||||
"社区征集客户端数": String(configuration.communityPromptClients.count)
|
||||
])
|
||||
return configuration
|
||||
} catch {
|
||||
RuntimeLogger.warning("APP", "Update", "版本配置加载失败,继续使用内置配置", details: [
|
||||
"错误": error.localizedDescription
|
||||
])
|
||||
return nil
|
||||
}
|
||||
|
||||
RuntimeLogger.warning("APP", "Update", "版本配置加载失败,继续使用内置配置")
|
||||
return nil
|
||||
}
|
||||
|
||||
static func fetchReleaseNotes(version: String) async -> String? {
|
||||
guard let url = URL(
|
||||
string: "https://raw.githubusercontent.com/xweiba/location-spoofer/main/docs/releases/v\(version).md"
|
||||
) else {
|
||||
return nil
|
||||
let session = makeSession()
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
for url in releaseNotesURLs(version: version) {
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 1.5
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200,
|
||||
let markdown = String(data: data, encoding: .utf8),
|
||||
let summary = releaseNotesSummary(markdown) else {
|
||||
continue
|
||||
}
|
||||
return summary
|
||||
} catch {
|
||||
RuntimeLogger.info("APP", "Update", "版本说明源不可用,尝试下一地址", details: [
|
||||
"来源": url.host ?? "未知",
|
||||
"版本": version,
|
||||
"错误": error.localizedDescription
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
RuntimeLogger.info("APP", "Update", "版本说明加载失败,将使用最新 Release 页面", details: [
|
||||
"版本": version
|
||||
])
|
||||
return nil
|
||||
}
|
||||
|
||||
static func releaseNotesURLs(version: String) -> [URL] {
|
||||
let path = "https://raw.githubusercontent.com/xweiba/location-spoofer/main/docs/releases/v\(version).md"
|
||||
return [
|
||||
URL(string: "https://gh-proxy.org/\(path)")!,
|
||||
URL(string: path)!
|
||||
]
|
||||
}
|
||||
|
||||
private static func makeSession() -> URLSession {
|
||||
let sessionConfiguration = URLSessionConfiguration.ephemeral
|
||||
sessionConfiguration.timeoutIntervalForRequest = 1.5
|
||||
sessionConfiguration.timeoutIntervalForResource = 2
|
||||
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
sessionConfiguration.waitsForConnectivity = false
|
||||
let session = URLSession(configuration: sessionConfiguration)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 1.5
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
RuntimeLogger.info("APP", "Update", "版本说明不存在,将使用最新 Release 页面", details: [
|
||||
"版本": version
|
||||
])
|
||||
return nil
|
||||
}
|
||||
guard let markdown = String(data: data, encoding: .utf8) else {
|
||||
RuntimeLogger.info("APP", "Update", "版本说明编码无效,将使用最新 Release 页面", details: [
|
||||
"版本": version
|
||||
])
|
||||
return nil
|
||||
}
|
||||
return releaseNotesSummary(markdown)
|
||||
} catch {
|
||||
RuntimeLogger.info("APP", "Update", "版本说明加载失败,将使用最新 Release 页面", details: [
|
||||
"版本": version,
|
||||
"错误": error.localizedDescription
|
||||
])
|
||||
return nil
|
||||
}
|
||||
return URLSession(configuration: sessionConfiguration)
|
||||
}
|
||||
|
||||
private static func releaseNotesSummary(_ markdown: String) -> String? {
|
||||
|
||||
@@ -54,6 +54,20 @@ enum ThirdPartyProxyError: LocalizedError, Equatable {
|
||||
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 {
|
||||
@@ -65,12 +79,17 @@ extension URLSession: ThirdPartyProxyRequesting {}
|
||||
@MainActor
|
||||
final class ThirdPartyProxyManager: ObservableObject {
|
||||
static let shared = ThirdPartyProxyManager()
|
||||
static let interceptionHostname = "gs-loc.apple.com"
|
||||
static let interceptionHostnames = [
|
||||
"gs-loc.apple.com",
|
||||
"gs-loc-cn.apple.com"
|
||||
]
|
||||
static let interceptionHostnamesText = interceptionHostnames.joined(separator: ", ")
|
||||
static let configurationEndpoint = URL(string: "https://gs-loc.apple.com/wloc-settings/save")!
|
||||
static let versionEndpoint = URL(string: "https://gs-loc.apple.com/wloc-settings/version")!
|
||||
|
||||
@Published private(set) var connectionState: ThirdPartyProxyConnectionState = .unknown
|
||||
@Published private(set) var activeSettings: ThirdPartyProxySettingsResponse?
|
||||
@Published private(set) var moduleUpdateRecommended = false
|
||||
@Published private(set) var isRequesting = false
|
||||
private let requester: any ThirdPartyProxyRequesting
|
||||
|
||||
@@ -89,24 +108,18 @@ 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
|
||||
}
|
||||
|
||||
func save(_ favorite: FavoriteLocation) async throws -> ThirdPartyProxySettingsResponse {
|
||||
_ = try await validateVersion()
|
||||
let wgs84 = favorite.coordinatePair.wgs84
|
||||
let response = try await perform(action: .save(
|
||||
latitude: wgs84.latitude,
|
||||
@@ -139,7 +152,9 @@ final class ThirdPartyProxyManager: ObservableObject {
|
||||
let longitude = current.longitude else {
|
||||
throw ThirdPartyProxyError.rejected("第三方虚拟定位尚未开启")
|
||||
}
|
||||
_ = try await validateVersion()
|
||||
guard await refreshAdvancedFeatureAvailability() else {
|
||||
throw ThirdPartyProxyError.moduleOutdated
|
||||
}
|
||||
let response = try await perform(action: .save(
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
@@ -153,6 +168,10 @@ final class ThirdPartyProxyManager: ObservableObject {
|
||||
return response
|
||||
}
|
||||
|
||||
func validateConnection() async throws -> ThirdPartyProxySettingsResponse {
|
||||
try await query()
|
||||
}
|
||||
|
||||
func validateVersion() async throws -> ThirdPartyProxyVersionResponse {
|
||||
guard !isRequesting else {
|
||||
throw ThirdPartyProxyError.rejected("已有第三方代理请求正在执行")
|
||||
@@ -184,6 +203,27 @@ final class ThirdPartyProxyManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
@@ -194,6 +234,18 @@ 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, motionEnabled: Bool)
|
||||
|
||||
@@ -62,11 +62,23 @@ final class AppRemoteConfigurationTests: XCTestCase {
|
||||
func testFallbackMatchesCurrentProjectPolicy() {
|
||||
let configuration = AppRemoteConfiguration.fallback
|
||||
|
||||
XCTAssertEqual(configuration.latestVersion, "1.0.4")
|
||||
XCTAssertEqual(configuration.latestVersion, "1.0.5")
|
||||
XCTAssertEqual(configuration.minimumSupportedVersion, "1.0.0")
|
||||
XCTAssertFalse(configuration.requestsCommunityPrompt(for: .shadowrocket))
|
||||
for client in ThirdPartyProxyClient.allCases where client != .shadowrocket {
|
||||
XCTAssertTrue(configuration.requestsCommunityPrompt(for: client))
|
||||
}
|
||||
}
|
||||
|
||||
func testUpdateResourcesPreferDomesticMirrorAndRetainOfficialFallback() {
|
||||
XCTAssertEqual(AppRemoteConfigurationService.configurationURLs.first?.host, "gh-proxy.org")
|
||||
XCTAssertEqual(
|
||||
AppRemoteConfigurationService.configurationURLs.last?.host,
|
||||
"raw.githubusercontent.com"
|
||||
)
|
||||
|
||||
let releaseNotesURLs = AppRemoteConfigurationService.releaseNotesURLs(version: "1.0.5")
|
||||
XCTAssertEqual(releaseNotesURLs.first?.host, "gh-proxy.org")
|
||||
XCTAssertEqual(releaseNotesURLs.last?.host, "raw.githubusercontent.com")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,73 @@ final class ThirdPartyProxyManagerTests: XCTestCase {
|
||||
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)
|
||||
@@ -78,6 +145,10 @@ final class ThirdPartyProxyManagerTests: XCTestCase {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -121,14 +192,19 @@ 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(#"{"success":true,"moduleVersion":"1.0.0","protocolVersion":1,"capabilities":["wifi","cellTower","arpc","marker","synthetic","bare","motionSimulation"]}"#.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,
|
||||
|
||||
@@ -40,6 +40,10 @@ grep -Fq '清除:GET ?action=clear' "$SETUP" \
|
||||
|
||||
for file in wloc.module wloc.sgmodule wloc.conf wloc.lpx wloc.stoverride; do
|
||||
test -s "$MODULES/$file" || fail "missing bundled module: $file"
|
||||
grep -q 'gs-loc.apple.com' "$MODULES/$file" \
|
||||
|| fail "$file must include gs-loc.apple.com in its MITM hostnames"
|
||||
grep -q 'gs-loc-cn.apple.com' "$MODULES/$file" \
|
||||
|| fail "$file must include gs-loc-cn.apple.com in its MITM hostnames"
|
||||
done
|
||||
|
||||
grep -q 'wloc.sgmodule' "$MANAGER" || fail "Surge/Egern module mapping is missing"
|
||||
@@ -48,7 +52,13 @@ grep -q 'shadowrocket://' "$MANAGER" || fail "Shadowrocket launch URL is missing
|
||||
for scheme in surge quantumult-x loon stash egern; do
|
||||
grep -q "${scheme}://" "$MANAGER" || fail "$scheme launch URL is missing"
|
||||
done
|
||||
grep -q '复制解密域名' "$SETUP" || fail "Shadowrocket MITM hostname copy action is missing"
|
||||
grep -q '复制两个解密域名' "$SETUP" || fail "all clients must expose both MITM hostnames"
|
||||
grep -q 'gs-loc.apple.com 和 gs-loc-cn.apple.com' "$SETUP" \
|
||||
|| fail "setup guidance must name both Apple location hostnames"
|
||||
grep -q 'ThirdPartyProxyManager.interceptionHostnamesText' "$SETUP" \
|
||||
|| fail "setup hostname copy actions must use the shared two-host value"
|
||||
grep -q 'ThirdPartyProxyManager.interceptionHostnamesText' "$SETTINGS" \
|
||||
|| fail "Settings must expose the shared two-host copy action"
|
||||
grep -q '配置 → 模块' "$SETUP" || fail "Shadowrocket module import guidance is missing"
|
||||
grep -q 'HTTPS 解密' "$SETUP" || fail "Shadowrocket HTTPS decryption guidance is missing"
|
||||
! grep -q '当前可测试' "$SETUP" || fail "Shadowrocket must not show the obsolete current-test label"
|
||||
@@ -64,10 +74,43 @@ grep -q 'Label("查看诊断日志"' "$SETUP" \
|
||||
|| fail "third-party connection failure must expose the diagnostics action"
|
||||
! grep -q 'setupActionError = error.localizedDescription' "$SETUP" \
|
||||
|| fail "third-party connection failure must not use the generic failure alert"
|
||||
grep -q 'let response = try await thirdPartyProxy.query()' "$SETUP" \
|
||||
|| fail "the import page must query the third-party module"
|
||||
grep -A15 'let response = try await thirdPartyProxy.query()' "$SETUP" | grep -q 'onComplete()' \
|
||||
grep -q 'let response = try await thirdPartyProxy.validateConnection()' "$SETUP" \
|
||||
|| fail "the import page must validate the legacy save/query endpoint"
|
||||
grep -A20 'let response = try await thirdPartyProxy.validateConnection()' "$SETUP" \
|
||||
| grep -q 'refreshAdvancedFeatureAvailability()' \
|
||||
|| fail "the import page must also probe advanced module capabilities"
|
||||
grep -A20 'let response = try await thirdPartyProxy.validateConnection()' "$SETUP" \
|
||||
| grep -q 'motionSimulation.setEnabled(false)' \
|
||||
|| fail "an incompatible module must turn motion simulation off without failing basic setup"
|
||||
grep -A20 'let response = try await thirdPartyProxy.validateConnection()' "$SETUP" | grep -q 'onComplete()' \
|
||||
|| fail "a successful third-party connection test must close setup immediately"
|
||||
grep -q 'components.queryItems = \[URLQueryItem(name: "action", value: "query")\]' "$MANAGER" \
|
||||
|| fail "the connection test must preserve the established save?action=query contract"
|
||||
grep -q 'thirdPartyProxy.moduleUpdateRecommended' "$SETTINGS" \
|
||||
|| fail "Settings must react to legacy module compatibility mode"
|
||||
grep -q '当前模块版本较旧,基础坐标功能仍可继续使用' "$SETTINGS" \
|
||||
|| fail "Settings must explain that legacy modules retain basic coordinate support"
|
||||
! grep -A8 'Toggle("运动状态模拟"' "$SETTINGS" | grep -q 'thirdPartyProxy.moduleUpdateRecommended' \
|
||||
|| fail "legacy module compatibility must turn motion simulation off without disabling its toggle"
|
||||
grep -q 'refreshAdvancedFeatureAvailability' "$SETTINGS" \
|
||||
|| fail "Settings must probe advanced module availability independently"
|
||||
grep -A18 'guard thirdPartyProxy.activeSettings?.success == true else' "$SETTINGS" \
|
||||
| grep -q 'refreshAdvancedFeatureAvailability()' \
|
||||
|| fail "enabling inactive third-party motion simulation must validate the version endpoint first"
|
||||
grep -A18 'guard thirdPartyProxy.activeSettings?.success == true else' "$SETTINGS" \
|
||||
| grep -q 'motionSimulation.setEnabled(true)' \
|
||||
|| fail "inactive third-party motion simulation may enable only after version validation succeeds"
|
||||
grep -q 'proxyOperationAlertTitle = "无法开启运动状态模拟"' "$SETTINGS" \
|
||||
|| fail "motion simulation must show a dedicated failure alert when version validation fails"
|
||||
grep -q '请重新导入最新模块脚本后再开启' "$SETTINGS" \
|
||||
|| fail "motion simulation failure must tell the user to update the module script"
|
||||
test "$(grep -c 'presentMotionSimulationModuleUpdateAlert()' "$SETTINGS")" -ge 3 \
|
||||
|| fail "inactive and active motion simulation paths must share the module-update alert"
|
||||
grep -q 'onChange(of: thirdPartyProxy.moduleUpdateRecommended)' "$SETTINGS" \
|
||||
|| fail "Settings must react when an installed module becomes incompatible"
|
||||
grep -A5 'private func disableUnsupportedThirdPartyMotionSimulation()' "$SETTINGS" \
|
||||
| grep -q 'motionSimulation.setEnabled(false)' \
|
||||
|| fail "unsupported third-party modules must force motion simulation off before disabling the toggle"
|
||||
! grep -q 'thirdPartyTestResult?.success' "$SETUP" \
|
||||
|| fail "a successful third-party test must not leave a separate completion state"
|
||||
grep -q 'setupStep = \.thirdPartyImport' "$ROOT/App/SetupCoordinator.swift" \
|
||||
@@ -112,7 +155,7 @@ test -s "$ROOT/docs/onboarding-screenshots/shadowrocket/shadowrocket-module-impo
|
||||
grep -q 'presentSuccessfulOperationTip(.activation)' "$ROOT/App/MapHomeView.swift" || fail "third-party save must present the activation tip"
|
||||
grep -q 'presentSuccessfulOperationTip(.deactivation)' "$ROOT/App/MapHomeView.swift" || fail "third-party clear must present the deactivation tip"
|
||||
grep -q 'if spoofState == .active' "$ROOT/App/MapHomeView.swift" || fail "manual help must follow the shared spoof state"
|
||||
grep -q 'MARKETING_VERSION: "1.0.4"' "$ROOT/project.yml" || fail "marketing version must be 1.0.4"
|
||||
grep -q 'CURRENT_PROJECT_VERSION: "5"' "$ROOT/project.yml" || fail "build version must be 5"
|
||||
grep -q 'MARKETING_VERSION: "1.0.5"' "$ROOT/project.yml" || fail "marketing version must be 1.0.5"
|
||||
grep -q 'CURRENT_PROJECT_VERSION: "6"' "$ROOT/project.yml" || fail "build version must be 6"
|
||||
|
||||
echo "PASS: third-party proxy mode contract"
|
||||
|
||||
@@ -25,7 +25,7 @@ import sys
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
config = json.load(handle)
|
||||
|
||||
assert config["latestVersion"] == "1.0.4"
|
||||
assert config["latestVersion"] == "1.0.5"
|
||||
assert config["minimumSupportedVersion"] == "1.0.0"
|
||||
assert "shadowrocket" not in config["communityPromptClients"]
|
||||
assert set(config["communityPromptClients"]) == {
|
||||
@@ -39,6 +39,10 @@ grep -q 'timeoutIntervalForRequest = 1.5' "$CONFIG" \
|
||||
|| fail "remote configuration requests must use a short timeout"
|
||||
grep -q 'timeoutIntervalForResource = 2' "$CONFIG" \
|
||||
|| fail "remote configuration resource loading must use a short timeout"
|
||||
grep -q 'gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/version.txt' "$CONFIG" \
|
||||
|| fail "update detection must prefer the domestic GitHub Raw mirror"
|
||||
grep -q 'static let configurationURLs = \[' "$CONFIG" \
|
||||
|| fail "update detection must retain multiple configuration sources"
|
||||
! grep -q 'data.count' "$CONFIG" \
|
||||
|| fail "the client must not impose a remote configuration file-size limit"
|
||||
grep -q 'docs/releases/v\\(version).md' "$CONFIG" \
|
||||
@@ -49,6 +53,12 @@ grep -q '.task { await checkForUpdates() }' "$CONTENT" \
|
||||
|| fail "update detection must run asynchronously outside bootstrap"
|
||||
! grep -A90 'private func bootstrap() async' "$CONTENT" | grep -q 'fetch()' \
|
||||
|| fail "remote configuration must not block the startup gate"
|
||||
grep -Fq 'Label("检查更新"' "$SETTINGS" \
|
||||
|| fail "Settings must expose a manual update check"
|
||||
grep -Fq 'title: Text("已是最新版本")' "$SETTINGS" \
|
||||
|| fail "manual update checks must report the current-version result"
|
||||
grep -Fq 'title: Text("检查更新失败")' "$SETTINGS" \
|
||||
|| fail "manual update checks must report network failures"
|
||||
|
||||
grep -q '社区分享成功配置?' "$MAP" \
|
||||
|| fail "non-Shadowrocket success must offer community contribution"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
## 已发布
|
||||
|
||||
- [v1.0.5](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.5) — 2026-08-10
|
||||
- [v1.0.4](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.4) — 2026-08-09
|
||||
- [v1.0.3](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.3) — 2026-08-09
|
||||
- [v1.0.2](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.2) — 2026-08-07
|
||||
|
||||
@@ -46,7 +46,7 @@ iOS 版本:
|
||||
|
||||
步骤 2:
|
||||
- 截图:02-https-decryption.jpg
|
||||
- 操作:开启 HTTPS 解密,并填写 gs-loc.apple.com
|
||||
- 操作:开启 HTTPS 解密,并填写 gs-loc.apple.com、gs-loc-cn.apple.com
|
||||
|
||||
验证结果:
|
||||
```
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 153 KiB |
@@ -0,0 +1,36 @@
|
||||
# v1.0.5
|
||||
|
||||
发布日期:2026-08-10
|
||||
|
||||
## 主要更新
|
||||
|
||||
- APP 模式与第三方代理模式统一支持 Wi-Fi、CellTower 字段 22/24,以及 ARPC、marker、synthetic、bare 等 WLOC 响应格式。
|
||||
- 新增默认关闭的运动状态模拟,可在定位响应中同步模拟运动状态。
|
||||
- 第三方模式改用项目维护的固定版本脚本,支持 Shadowrocket、Surge、Quantumult X、Loon、Stash 和 Egern。
|
||||
- 设置页新增手动检查更新入口;版本配置和更新说明优先使用国内镜像,并保留 GitHub 官方源兜底。
|
||||
|
||||
## 第三方模式
|
||||
|
||||
- 基础坐标同步继续兼容旧模块,不再因缺少版本接口阻断导入或开启虚拟定位。
|
||||
- 运动状态模拟属于高级功能;开启前必须通过 `/wloc-settings/version` 协议与能力检测,检测失败时保持关闭并提示更新模块。
|
||||
- 完善模块缓存更新、连接诊断和失败引导,保留当前第三方客户端及详细错误信息。
|
||||
- Shadowrocket 和其他第三方客户端引导统一补充 `gs-loc.apple.com`、`gs-loc-cn.apple.com` 两个 HTTPS 解密域名及复制入口。
|
||||
|
||||
## 引导与资源
|
||||
|
||||
- 更新 Shadowrocket HTTPS 解密截图,直接标注开关、双域名和证书入口步骤。
|
||||
- 公共教程同步双域名配置要求,同时保留未经标注的原始截图。
|
||||
|
||||
## 兼容性说明
|
||||
|
||||
- 运动状态模拟默认关闭,升级后不会改变已有定位行为。
|
||||
- 旧版第三方模块仍可使用基础坐标功能;需要运动状态模拟时请重新导入最新模块。
|
||||
|
||||
## 自签安装
|
||||
|
||||
- Release 附件为未签名 IPA,安装前需要自行签名。
|
||||
- 可使用免费 Apple ID 和 Impactor 完成签名安装,无需付费开发者账号。
|
||||
- 签名时请保留 Bundle ID `com.paopaolabs.location-spoofer`、App Group `group.com.paopaolabs.location-spoofer` 及原有 entitlements。
|
||||
- 免费 Apple ID 签名通常只有 7 天有效期,到期后需要重新签名安装。
|
||||
|
||||
<!-- commit-range: v1.0.4..v1.0.5 -->
|
||||
+2
-2
@@ -8,8 +8,8 @@ options:
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.9"
|
||||
MARKETING_VERSION: "1.0.4"
|
||||
CURRENT_PROJECT_VERSION: "5"
|
||||
MARKETING_VERSION: "1.0.5"
|
||||
CURRENT_PROJECT_VERSION: "6"
|
||||
CODE_SIGN_STYLE: Manual
|
||||
CODE_SIGNING_ALLOWED: "NO"
|
||||
CODE_SIGNING_REQUIRED: "NO"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"latestVersion": "1.0.4",
|
||||
"latestVersion": "1.0.5",
|
||||
"minimumSupportedVersion": "1.0.0",
|
||||
"communityPromptClients": ["surge", "quantumultX", "loon", "stash", "egern"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user