diff --git a/App/FirstSetupView.swift b/App/FirstSetupView.swift index 76a50a3..bc8dec6 100644 --- a/App/FirstSetupView.swift +++ b/App/FirstSetupView.swift @@ -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 diff --git a/App/MapHomeView.swift b/App/MapHomeView.swift index 3571a33..737fb86 100644 --- a/App/MapHomeView.swift +++ b/App/MapHomeView.swift @@ -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) diff --git a/App/SettingsView.swift b/App/SettingsView.swift index be6d930..a2b20de 100644 --- a/App/SettingsView.swift +++ b/App/SettingsView.swift @@ -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 { 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() diff --git a/README.en.md b/README.en.md index a202ea1..6e75822 100644 --- a/README.en.md +++ b/README.en.md @@ -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.4-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) · diff --git a/README.md b/README.md index 8a82a6a..0b3573d 100644 --- a/README.md +++ b/README.md @@ -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.4-2563EB)](docs/CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-v1.0.5-2563EB)](docs/CHANGELOG.md) [功能概览](#功能概览) · [工作原理](#工作原理) · diff --git a/Resources/Assets.xcassets/ShadowrocketHTTPSDecryption.imageset/shadowrocket-https-decryption.jpg b/Resources/Assets.xcassets/ShadowrocketHTTPSDecryption.imageset/shadowrocket-https-decryption.jpg index 4cdebf5..2857dfe 100644 Binary files a/Resources/Assets.xcassets/ShadowrocketHTTPSDecryption.imageset/shadowrocket-https-decryption.jpg and b/Resources/Assets.xcassets/ShadowrocketHTTPSDecryption.imageset/shadowrocket-https-decryption.jpg differ diff --git a/Shared/AppRemoteConfiguration.swift b/Shared/AppRemoteConfiguration.swift index 476aef6..bc9b422 100644 --- a/Shared/AppRemoteConfiguration.swift +++ b/Shared/AppRemoteConfiguration.swift @@ -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? { diff --git a/Shared/ThirdPartyProxyManager.swift b/Shared/ThirdPartyProxyManager.swift index afdcc4a..20e1a9b 100644 --- a/Shared/ThirdPartyProxyManager.swift +++ b/Shared/ThirdPartyProxyManager.swift @@ -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) diff --git a/Tests/PaopaoLocationSpooferTests/AppRemoteConfigurationTests.swift b/Tests/PaopaoLocationSpooferTests/AppRemoteConfigurationTests.swift index 16b2e64..00fd303 100644 --- a/Tests/PaopaoLocationSpooferTests/AppRemoteConfigurationTests.swift +++ b/Tests/PaopaoLocationSpooferTests/AppRemoteConfigurationTests.swift @@ -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") + } } diff --git a/Tests/PaopaoLocationSpooferTests/ThirdPartyProxyManagerTests.swift b/Tests/PaopaoLocationSpooferTests/ThirdPartyProxyManagerTests.swift index 870da83..ef70854 100644 --- a/Tests/PaopaoLocationSpooferTests/ThirdPartyProxyManagerTests.swift +++ b/Tests/PaopaoLocationSpooferTests/ThirdPartyProxyManagerTests.swift @@ -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, diff --git a/Tests/third_party_mode_contract_test.sh b/Tests/third_party_mode_contract_test.sh index 077357e..96bb04f 100755 --- a/Tests/third_party_mode_contract_test.sh +++ b/Tests/third_party_mode_contract_test.sh @@ -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" diff --git a/Tests/update_and_contribution_contract_test.sh b/Tests/update_and_contribution_contract_test.sh index 48f2b32..61ade10 100644 --- a/Tests/update_and_contribution_contract_test.sh +++ b/Tests/update_and_contribution_contract_test.sh @@ -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" diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b0562a9..8ef3d1d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/COMMUNITY_TUTORIALS.md b/docs/COMMUNITY_TUTORIALS.md index 1de80ed..81a7304 100644 --- a/docs/COMMUNITY_TUTORIALS.md +++ b/docs/COMMUNITY_TUTORIALS.md @@ -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 验证结果: ``` diff --git a/docs/onboarding-screenshots/shadowrocket/shadowrocket-https-decryption.jpg b/docs/onboarding-screenshots/shadowrocket/shadowrocket-https-decryption.jpg index b52646f..bf6ab6f 100644 Binary files a/docs/onboarding-screenshots/shadowrocket/shadowrocket-https-decryption.jpg and b/docs/onboarding-screenshots/shadowrocket/shadowrocket-https-decryption.jpg differ diff --git a/docs/releases/v1.0.5.md b/docs/releases/v1.0.5.md new file mode 100644 index 0000000..12259e7 --- /dev/null +++ b/docs/releases/v1.0.5.md @@ -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 天有效期,到期后需要重新签名安装。 + + diff --git a/project.yml b/project.yml index 68693a1..9627568 100644 --- a/project.yml +++ b/project.yml @@ -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" diff --git a/version.txt b/version.txt index 4c7b86d..e3da699 100644 --- a/version.txt +++ b/version.txt @@ -1,5 +1,5 @@ { - "latestVersion": "1.0.4", + "latestVersion": "1.0.5", "minimumSupportedVersion": "1.0.0", "communityPromptClients": ["surge", "quantumultX", "loon", "stash", "egern"] }