Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4712f7fb2 | ||
|
|
393c7cc215 | ||
|
|
46c623ad81 | ||
|
|
68cd5fd31c | ||
|
|
27f6029f9e | ||
|
|
05949703af | ||
|
|
908ac8f734 | ||
|
|
eadeca636d | ||
|
|
b530d52add | ||
|
|
135e26ea7a | ||
|
|
d35d5c788a | ||
|
|
cce2aef58f | ||
|
|
9cea68392e | ||
|
|
fb0ca961a4 | ||
|
|
a666f177e6 | ||
|
|
a6b9e8056b | ||
|
|
9510d9aa42 | ||
|
|
41dfe7a8ed | ||
|
|
27af50375d | ||
|
|
a2f46dd166 | ||
|
|
b0d2f0e8e4 | ||
|
|
7cf38363cf | ||
|
|
a6688b4a49 | ||
|
|
893f674004 | ||
|
|
8cc65172b2 | ||
|
|
b07fa61195 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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: 分享已验证的第三方客户端模块、配置步骤和脱敏截图
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Contract Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
contract-tests:
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run contract tests
|
||||
run: ./Tests/run_all_contract_tests.sh
|
||||
@@ -28,6 +28,9 @@ jobs:
|
||||
- name: Install XcodeGen
|
||||
run: brew install xcodegen
|
||||
|
||||
- name: Run contract tests
|
||||
run: ./Tests/run_all_contract_tests.sh
|
||||
|
||||
- name: Build unsigned IPA
|
||||
run: ./build.sh
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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 报告")
|
||||
(isRunning ? Text("正在生成报告…") : Text("生成 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,9 +95,10 @@ struct BugReportView: View {
|
||||
do {
|
||||
let response = try await thirdPartyProxy.query()
|
||||
let active = response.success && response.latitude != nil && response.longitude != nil
|
||||
testLog = "第三方代理测试模式:模块连接成功;已保存坐标=\(active ? "是" : "否")"
|
||||
let savedCoordinate = active ? String(localized: "是") : String(localized: "否")
|
||||
testLog = String(localized: "第三方代理测试模式:模块连接成功;已保存坐标=\(savedCoordinate)")
|
||||
} catch {
|
||||
testLog = "第三方代理测试模式:模块连接失败;\(error.localizedDescription)"
|
||||
testLog = String(localized: "第三方代理测试模式:模块连接失败;\(error.localizedDescription)")
|
||||
}
|
||||
} else {
|
||||
_ = await setup.runVerificationTest()
|
||||
@@ -110,21 +114,11 @@ struct BugReportView: View {
|
||||
let systemVersion = UIDevice.current.systemVersion
|
||||
|
||||
// 拼接报告
|
||||
let report = """
|
||||
### 环境信息
|
||||
App 版本: \(appVersion)
|
||||
系统版本: iOS \(systemVersion)
|
||||
运行模式: \(runtimeMode.mode.displayName)
|
||||
可复现环境: \(isReproducible ? "是" : "否")
|
||||
|
||||
### 问题描述
|
||||
\(description.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
|
||||
### 诊断日志
|
||||
```
|
||||
\(testLog.isEmpty ? "(无诊断数据)" : testLog)
|
||||
```
|
||||
"""
|
||||
let report = bugReport(
|
||||
appVersion: appVersion,
|
||||
systemVersion: systemVersion,
|
||||
testLog: testLog
|
||||
)
|
||||
|
||||
// 复制到剪切板
|
||||
UIPasteboard.general.string = report
|
||||
@@ -134,4 +128,28 @@ struct BugReportView: View {
|
||||
showCopiedAlert = true
|
||||
}
|
||||
}
|
||||
|
||||
private func bugReport(appVersion: String, systemVersion: String, testLog: String) -> String {
|
||||
let client = runtimeMode.mode == .thirdParty
|
||||
? thirdPartyClient.selectedClient.name
|
||||
: String(localized: "不适用")
|
||||
let reproducible = isReproducible ? String(localized: "是") : String(localized: "否")
|
||||
let diagnostics = testLog.isEmpty ? String(localized: "(无诊断数据)") : testLog
|
||||
return """
|
||||
### \(String(localized: "环境信息"))
|
||||
\(String(localized: "App 版本")): \(appVersion)
|
||||
\(String(localized: "系统版本")): iOS \(systemVersion)
|
||||
\(String(localized: "运行模式")): \(runtimeMode.mode.displayName)
|
||||
\(String(localized: "第三方客户端")): \(client)
|
||||
\(String(localized: "可复现环境")): \(reproducible)
|
||||
|
||||
### \(String(localized: "问题描述"))
|
||||
\(description.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
|
||||
### \(String(localized: "诊断日志"))
|
||||
```
|
||||
\(diagnostics)
|
||||
```
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -17,9 +20,9 @@ struct ContentView: View {
|
||||
Image(systemName: "location.fill")
|
||||
.font(.system(size: 48)).foregroundStyle(.blue)
|
||||
ProgressView()
|
||||
Text(runtimeMode.hasSelectedMode && runtimeMode.mode == .localWiFi
|
||||
? "正在初始化地图与本地代理…"
|
||||
: "正在初始化地图…")
|
||||
(runtimeMode.hasSelectedMode && runtimeMode.mode == .localWiFi
|
||||
? Text("正在初始化地图与本地代理…")
|
||||
: Text("正在初始化地图…"))
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
case .setup:
|
||||
@@ -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
|
||||
?? String(localized: "更新说明暂时无法加载,请前往最新 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
|
||||
?? String(localized: "更新说明暂时无法加载,请前往最新 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ struct RuntimeLogsView: View {
|
||||
@State private var entries: [RuntimeLogEntry] = []
|
||||
@State private var isTesting = false
|
||||
@State private var testResult = ""
|
||||
@State private var testSucceeded = false
|
||||
@State private var testMessage = ""
|
||||
@State private var showClearConfirm = false
|
||||
@State private var copiedEntryID: UUID?
|
||||
@@ -22,7 +23,10 @@ struct RuntimeLogsView: View {
|
||||
private var filteredEntries: [RuntimeLogEntry] {
|
||||
let q = logFilter.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !q.isEmpty else { return entries }
|
||||
return entries.filter { $0.message.localizedCaseInsensitiveContains(q) }
|
||||
return entries.filter {
|
||||
$0.localizedMessage.localizedCaseInsensitiveContains(q)
|
||||
|| $0.localizedCategory.localizedCaseInsensitiveContains(q)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -42,7 +46,7 @@ struct RuntimeLogsView: View {
|
||||
if filteredEntries.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "doc.text.magnifyingglass").font(.largeTitle).foregroundStyle(.secondary)
|
||||
Text(entries.isEmpty ? "暂无运行日志" : "无匹配日志").foregroundStyle(.secondary)
|
||||
(entries.isEmpty ? Text("暂无运行日志") : Text("无匹配日志")).foregroundStyle(.secondary)
|
||||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
@@ -103,8 +107,11 @@ struct RuntimeLogsView: View {
|
||||
await runThirdPartyConnectionTest()
|
||||
} else {
|
||||
let result = await setup.runVerificationTest()
|
||||
testResult = result.isSuccess ? "环境检测通过" : "环境检测失败: \(result.id)"
|
||||
if !result.isSuccess { testResult += ",查看下方日志" }
|
||||
testSucceeded = result.isSuccess
|
||||
testResult = result.isSuccess
|
||||
? String(localized: "环境检测通过")
|
||||
: String(localized: "环境检测失败: \(result.localizedTitle)")
|
||||
if !result.isSuccess { testResult += String(localized: ",查看下方日志") }
|
||||
testMessage = setup.testLog
|
||||
}
|
||||
isTesting = false; refresh()
|
||||
@@ -116,7 +123,7 @@ struct RuntimeLogsView: View {
|
||||
} else {
|
||||
Image(systemName: "play.fill").font(.system(size: 13, weight: .bold))
|
||||
}
|
||||
Text(isTesting ? "正在检测…" : "环境检测").font(.subheadline.weight(.semibold))
|
||||
(isTesting ? Text("正在检测…") : Text("环境检测")).font(.subheadline.weight(.semibold))
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold)).opacity(0.5)
|
||||
}
|
||||
@@ -131,9 +138,9 @@ struct RuntimeLogsView: View {
|
||||
.fill(isTesting ? Color.gray : Color.blue)
|
||||
)
|
||||
.disabled(isTesting || actions.state.isBusy)
|
||||
Text(runtimeMode.mode == .thirdParty
|
||||
? "检查第三方模块能否拦截并响应 query 请求;不会写入测试坐标。"
|
||||
: "依次检查:本地代理 → CA 证书信任 → Wi-Fi 代理链路。")
|
||||
(runtimeMode.mode == .thirdParty
|
||||
? Text("检查第三方模块能否拦截并响应 query 请求;不会写入测试坐标。")
|
||||
: Text("依次检查:本地代理 → CA 证书信任 → Wi-Fi 代理链路。"))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if !testMessage.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -180,13 +187,13 @@ struct RuntimeLogsView: View {
|
||||
}
|
||||
if runtimeMode.mode == .localWiFi {
|
||||
HStack(spacing: 14) {
|
||||
Label(proxy.isRunning ? "代理运行中" : "代理未运行", systemImage: proxy.isRunning ? "play.circle" : "stop.circle")
|
||||
Label(setup.canModify ? "可修改" : "不可修改", systemImage: setup.canModify ? "checkmark.shield.fill" : "xmark.shield")
|
||||
(proxy.isRunning ? Label("代理运行中", systemImage: "play.circle") : Label("代理未运行", systemImage: "stop.circle"))
|
||||
(setup.canModify ? Label("可修改", systemImage: "checkmark.shield.fill") : Label("不可修改", systemImage: "xmark.shield"))
|
||||
}.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
if !testResult.isEmpty {
|
||||
Text(testResult).font(.footnote.weight(.medium))
|
||||
.foregroundStyle(testResult.contains("通过") ? .green : .red)
|
||||
.foregroundStyle(testSucceeded ? .green : .red)
|
||||
}
|
||||
}.padding(14).background(Color(.secondarySystemBackground))
|
||||
}
|
||||
@@ -197,7 +204,7 @@ struct RuntimeLogsView: View {
|
||||
.foregroundStyle(entry.level == .error ? .red : entry.level == .warning ? .orange : .blue).frame(width: 18)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("\(entry.source) \(entry.category)").font(.caption.weight(.semibold))
|
||||
Text("\(entry.source) \(entry.localizedCategory)").font(.caption.weight(.semibold))
|
||||
Spacer()
|
||||
Button {
|
||||
UIPasteboard.general.string = entry.renderedText
|
||||
@@ -221,9 +228,9 @@ struct RuntimeLogsView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Text(entry.message).font(.caption.monospaced()).textSelection(.enabled)
|
||||
Text(entry.localizedMessage).font(.caption.monospaced()).textSelection(.enabled)
|
||||
if !entry.details.isEmpty {
|
||||
Text(entry.details.sorted(by: { $0.key < $1.key }).map { "\($0.key): \($0.value)" }.joined(separator: "\n"))
|
||||
Text(entry.localizedDetailsText)
|
||||
.font(.caption2.monospaced()).foregroundStyle(.secondary).textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
@@ -240,22 +247,28 @@ struct RuntimeLogsView: View {
|
||||
do {
|
||||
let response = try await thirdPartyProxy.query()
|
||||
let active = response.success && response.latitude != nil && response.longitude != nil
|
||||
testResult = active ? "第三方模块连接通过,已有坐标" : "第三方模块连接通过,暂无坐标"
|
||||
testMessage = """
|
||||
======== 第三方代理连接检测 ========
|
||||
模式: 测试模式
|
||||
请求: wloc-settings/save?action=query
|
||||
拦截响应: 有效 JSON
|
||||
已保存坐标: \(active ? "是" : "否")
|
||||
"""
|
||||
testSucceeded = true
|
||||
testResult = active ? String(localized: "第三方模块连接通过,已有坐标") : String(localized: "第三方模块连接通过,暂无坐标")
|
||||
testMessage = thirdPartyTestLog(active: active)
|
||||
} catch {
|
||||
testResult = "第三方模块连接失败"
|
||||
testMessage = """
|
||||
======== 第三方代理连接检测 ========
|
||||
模式: 测试模式
|
||||
请求: wloc-settings/save?action=query
|
||||
结果: \(error.localizedDescription)
|
||||
"""
|
||||
testSucceeded = false
|
||||
testResult = String(localized: "第三方模块连接失败")
|
||||
testMessage = thirdPartyTestLog(error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func thirdPartyTestLog(active: Bool? = nil, error: Error? = nil) -> String {
|
||||
var lines = [
|
||||
String(localized: "======== 第三方代理连接检测 ========"),
|
||||
String(localized: "模式: 测试模式"),
|
||||
String(localized: "请求: wloc-settings/save?action=query")
|
||||
]
|
||||
if let active {
|
||||
lines.append(String(localized: "拦截响应: 有效 JSON"))
|
||||
lines.append(String(localized: "已保存坐标: \(active ? String(localized: "是") : String(localized: "否"))"))
|
||||
} else if let error {
|
||||
lines.append(String(localized: "结果: \(error.localizedDescription)"))
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 "导入并检测"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,12 +53,28 @@ struct FirstSetupView: View {
|
||||
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
|
||||
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
|
||||
@State private var copiedSubscriptionURL = false
|
||||
@State private var copiedMITMHostname = false
|
||||
@State private var copiedMITMHostname: String?
|
||||
@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 +86,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 +141,28 @@ struct FirstSetupView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(item: $screenshotPreview) { preview in
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
Image(uiImage: preview.image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle(LocalizedStringKey(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 = "" } }
|
||||
@@ -117,7 +188,7 @@ struct FirstSetupView: View {
|
||||
Circle()
|
||||
.fill(value.rawValue <= step.rawValue ? Color.blue : Color.gray.opacity(0.3))
|
||||
.frame(width: 10, height: 10)
|
||||
Text(value.title).font(.caption).foregroundStyle(.secondary)
|
||||
Text(LocalizedStringKey(value.title)).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
if value != visibleSteps.last {
|
||||
Rectangle().fill(Color.gray.opacity(0.3)).frame(width: 28, height: 2)
|
||||
@@ -133,11 +204,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 [
|
||||
String(localized: "======== 第三方代理运行检测 ========"),
|
||||
String(localized: "当前客户端:\(thirdPartyClient.selectedClient.name)"),
|
||||
String(localized: "触发来源:地图或设置中的第三方代理操作"),
|
||||
String(localized: "请求动作:WLOC 配置接口"),
|
||||
String(localized: "检测结果:失败"),
|
||||
String(localized: "错误详情:\(setup.message)"),
|
||||
String(localized: "处理建议:确认模块已启用,并检查 MITM、证书和代理/VPN 连接。")
|
||||
].joined(separator: "\n")
|
||||
}
|
||||
|
||||
private var modeStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("选择运行模式")
|
||||
@@ -181,10 +274,10 @@ struct FirstSetupView: View {
|
||||
}
|
||||
|
||||
private func modeCard(
|
||||
title: String,
|
||||
title: LocalizedStringKey,
|
||||
icon: String,
|
||||
badges: [String],
|
||||
description: String,
|
||||
description: LocalizedStringKey,
|
||||
tint: Color,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
@@ -195,7 +288,7 @@ struct FirstSetupView: View {
|
||||
.foregroundStyle(tint)
|
||||
HStack(spacing: 6) {
|
||||
ForEach(badges, id: \.self) { badge in
|
||||
Text(badge)
|
||||
Text(LocalizedStringKey(badge))
|
||||
.font(.caption2.weight(.semibold))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
@@ -217,12 +310,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.1,3 填写端口 8888。"
|
||||
)
|
||||
HStack(spacing: 12) {
|
||||
Button { UIPasteboard.general.string = "127.0.0.1:8888" } label: {
|
||||
Label("复制地址", systemImage: "doc.on.doc").frame(maxWidth: .infinity)
|
||||
@@ -241,15 +345,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 ?? String(localized: "无法准备证书下载页面,请查看诊断日志")
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -258,28 +363,57 @@ 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 thirdPartyMITMWarning: some View {
|
||||
Label {
|
||||
Text("iOS 27 beta 6 起,系统已禁止对 gs-loc.apple.com 进行 MITM 拦截。该版本及之后的 beta 版本暂时无法使用本项目,等待后续适配方案。")
|
||||
.font(.footnote)
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
}
|
||||
.foregroundStyle(.orange)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private var thirdPartyClientStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
thirdPartyMITMWarning
|
||||
if !setup.message.isEmpty {
|
||||
Label(setup.message, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
GroupBox(label: Label("选择第三方代理客户端", systemImage: "app.badge.checkmark")) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(ThirdPartyProxyClient.allCases) { client in
|
||||
@@ -289,9 +423,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 +441,80 @@ 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(String(localized: """
|
||||
成功:{"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 响应脚本,以及 Apple 定位域名(gs-loc.apple.com、gsp-ssl.ls.apple.com、bluedot.is.autonavi.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) {
|
||||
thirdPartyMITMWarning
|
||||
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")
|
||||
(copiedSubscriptionURL ? Label("已复制模块订阅地址", systemImage: "doc.on.doc") : Label("复制模块订阅地址", systemImage: "doc.on.doc"))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
instructionRow(2, client == .shadowrocket
|
||||
? "打开 Shadowrocket,进入“配置 → 模块”。"
|
||||
: "打开 \(client.name)。")
|
||||
Button {
|
||||
openThirdPartyClient(client)
|
||||
} label: {
|
||||
@@ -336,15 +522,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("配置时请复制下方全部解密域名(含 gsp-ssl.ls.apple.com、bluedot.is.autonavi.com)。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
mitmHostnameList(for: client)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let thirdPartyFailureLog {
|
||||
testResultView(
|
||||
success: false,
|
||||
title: String(localized: "接口连接失败"),
|
||||
log: thirdPartyFailureLog
|
||||
)
|
||||
.id("thirdPartyFailureLog")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -354,28 +578,23 @@ struct FirstSetupView: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
instructionRow(1, "进入“配置 → 本地文件”,找到带黄点的配置,点击右侧 i 图标。")
|
||||
instructionRow(2, "进入“HTTPS 解密”,开启解密开关。")
|
||||
instructionRow(3, "在域名列表中添加 gs-loc.apple.com。")
|
||||
instructionRow(3, "在域名列表中添加下方复制的全部解密域名。")
|
||||
setupScreenshot(
|
||||
assetName: "ShadowrocketHTTPSDecryption",
|
||||
title: "配置 HTTPS 解密",
|
||||
caption: "1 开启 HTTPS 解密,2 添加复制的全部解密域名,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)
|
||||
mitmHostnameList(for: .shadowrocket)
|
||||
|
||||
instructionRow(4, "按 Shadowrocket 提示生成并完成证书授权。")
|
||||
setupScreenshot(
|
||||
assetName: "ShadowrocketHTTPSCA",
|
||||
title: "授权 Shadowrocket 证书",
|
||||
caption: "1 打开 Shadowrocket 证书项并按提示安装、授权。"
|
||||
)
|
||||
instructionRow(5, "返回 HTTPS 解密页面,点击右上角勾号保存,然后开启代理。")
|
||||
|
||||
Button {
|
||||
openThirdPartyClient(.shadowrocket)
|
||||
} label: {
|
||||
Label("打开 Shadowrocket 继续配置", systemImage: "arrow.up.forward.app")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Text("App 只能唤起 Shadowrocket,无法通过公开接口直接跳转到“模块”或“HTTPS 解密”页面。")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -383,7 +602,40 @@ struct FirstSetupView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func instructionRow(_ number: Int, _ text: String) -> some View {
|
||||
private func mitmHostnameList(for client: ThirdPartyProxyClient) -> some View {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(ThirdPartyProxyManager.interceptionHostnames, id: \.self) { hostname in
|
||||
HStack(spacing: 8) {
|
||||
Text(hostname)
|
||||
.font(.caption.monospaced())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Button {
|
||||
UIPasteboard.general.string = hostname
|
||||
copiedMITMHostname = hostname
|
||||
} label: {
|
||||
Image(systemName: copiedMITMHostname == hostname ? "checkmark" : "doc.on.doc")
|
||||
.frame(width: 28, height: 28)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityLabel(
|
||||
copiedMITMHostname == hostname
|
||||
? Text("已复制 \(hostname)")
|
||||
: Text("复制 \(hostname)")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
openThirdPartyClient(client)
|
||||
} label: {
|
||||
Label("打开 \(client.name)", systemImage: "arrow.up.forward.app")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
private func instructionRow(_ number: Int, _ text: LocalizedStringKey) -> some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("\(number)")
|
||||
.font(.caption2.bold())
|
||||
@@ -397,11 +649,48 @@ 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(LocalizedStringKey(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(
|
||||
Text(LocalizedStringKey(title))
|
||||
+ Text(": ")
|
||||
+ Text(LocalizedStringKey(caption))
|
||||
)
|
||||
.accessibilityHint("轻点查看大图")
|
||||
}
|
||||
return "先复制订阅地址,然后打开 \(client.name),在模块、重写或覆写订阅入口中粘贴并导入。配置由 \(client.name) 保存,不依赖本 App 持续运行。"
|
||||
}
|
||||
|
||||
private func openThirdPartyClient(_ client: ThirdPartyProxyClient) {
|
||||
@@ -409,19 +698,7 @@ struct FirstSetupView: View {
|
||||
UIApplication.shared.open(url, options: [:]) { opened in
|
||||
guard !opened else { return }
|
||||
Task { @MainActor in
|
||||
manualHint = "无法打开 \(client.name),请确认客户端已安装后手动打开。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
manualHint = String(localized: "无法打开 \(client.name),请确认客户端已安装后手动打开。")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -437,17 +714,16 @@ struct FirstSetupView: View {
|
||||
case .cert:
|
||||
step = .proxy
|
||||
case .thirdPartyImport:
|
||||
thirdPartyTestFailure = nil
|
||||
step = .thirdPartyClient
|
||||
case .thirdPartyTest:
|
||||
step = .thirdPartyImport
|
||||
}
|
||||
}
|
||||
|
||||
private func certificateCard(
|
||||
title: String,
|
||||
title: LocalizedStringKey,
|
||||
icon: String,
|
||||
description: String,
|
||||
actionTitle: String,
|
||||
description: LocalizedStringKey,
|
||||
actionTitle: LocalizedStringKey,
|
||||
actionIcon: String,
|
||||
complete: Bool,
|
||||
action: @escaping () -> Void,
|
||||
@@ -467,10 +743,9 @@ struct FirstSetupView: View {
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.blue)
|
||||
Button(action: markComplete) {
|
||||
Label(
|
||||
complete ? "已完成 ✓" : "已完成",
|
||||
systemImage: complete ? "checkmark.circle.fill" : "circle"
|
||||
)
|
||||
(complete
|
||||
? Label("已完成 ✓", systemImage: "checkmark.circle.fill")
|
||||
: Label("已完成", systemImage: "circle"))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
@@ -483,12 +758,20 @@ struct FirstSetupView: View {
|
||||
@ViewBuilder
|
||||
private func resultView(_ result: VerificationResult) -> some View {
|
||||
let success = result.isSuccess
|
||||
testResultView(
|
||||
success: success,
|
||||
title: success ? String(localized: "环境检测通过") : 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 +795,7 @@ struct FirstSetupView: View {
|
||||
Button {
|
||||
verifyAfterProxyConfirmation()
|
||||
} label: {
|
||||
actionLabel("我已配置,开始检测")
|
||||
actionLabel("完成")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(isVerifying)
|
||||
@@ -520,23 +803,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 +846,83 @@ 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.query()
|
||||
let elapsedMilliseconds = Int(Date().timeIntervalSince(startedAt) * 1_000)
|
||||
RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方代理连接检测通过", details: [
|
||||
"当前客户端": client.name,
|
||||
"请求动作": "WLOC query",
|
||||
"连接状态": response.latitude == nil || response.longitude == nil ? "已连接,无保存坐标" : "已连接,有保存坐标",
|
||||
"耗时毫秒": 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: [
|
||||
String(localized: "======== 第三方代理连接检测 ========"),
|
||||
String(localized: "当前客户端:\(client.name)"),
|
||||
String(localized: "配置接口:/wloc-settings/save"),
|
||||
String(localized: "请求动作:WLOC query"),
|
||||
String(localized: "检查范围:模块拦截、MITM、证书、代理/VPN 连接"),
|
||||
String(localized: "连接状态:\(connectionState)"),
|
||||
String(localized: "检测结果:失败"),
|
||||
String(localized: "耗时:\(elapsedMilliseconds) ms"),
|
||||
String(localized: "错误类型:\(errorType)"),
|
||||
String(localized: "错误详情:\(error.localizedDescription)"),
|
||||
String(localized: "处理建议:\(ThirdPartyProxyError.recoverySuggestion(for: error))。")
|
||||
].joined(separator: "\n"))
|
||||
showsThirdPartyFailureLog = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func actionLabel(_ title: String) -> some View {
|
||||
private var thirdPartyConnectionStateDescription: String {
|
||||
switch thirdPartyProxy.connectionState {
|
||||
case .unknown:
|
||||
return String(localized: "未检测")
|
||||
case .connected(let active):
|
||||
return active ? String(localized: "已连接,有保存坐标") : String(localized: "已连接,无保存坐标")
|
||||
case .failed(let message):
|
||||
return String(localized: "连接失败(\(message))")
|
||||
}
|
||||
}
|
||||
|
||||
private func actionLabel(_ title: LocalizedStringKey) -> some View {
|
||||
HStack {
|
||||
if isVerifying { ProgressView().tint(.white).controlSize(.small) }
|
||||
Text(title).frame(maxWidth: .infinity)
|
||||
Text(title)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,11 +952,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)
|
||||
}
|
||||
@@ -622,14 +966,14 @@ struct FirstSetupView: View {
|
||||
|
||||
private func failureSummary(_ result: VerificationResult) -> String {
|
||||
switch result {
|
||||
case .certNotTrusted: return "证书尚未安装或信任"
|
||||
case .wifiProxyNotConfigured: return "Wi-Fi 代理未正确设置"
|
||||
case .proxyNotRunning: return "本地代理未能启动"
|
||||
case .verificationInProgress: return "检测仍在进行"
|
||||
case .verificationSuperseded: return "检测结果已过期"
|
||||
case .coordinateWriteFailed: return "坐标写入失败"
|
||||
case .patchFailed: return "定位改写检测失败"
|
||||
case .success: return "环境检测通过"
|
||||
case .certNotTrusted: return String(localized: "证书尚未安装或信任")
|
||||
case .wifiProxyNotConfigured: return String(localized: "Wi-Fi 代理未正确设置")
|
||||
case .proxyNotRunning: return String(localized: "本地代理未能启动")
|
||||
case .verificationInProgress: return String(localized: "检测仍在进行")
|
||||
case .verificationSuperseded: return String(localized: "检测结果已过期")
|
||||
case .coordinateWriteFailed: return String(localized: "坐标写入失败")
|
||||
case .patchFailed: return String(localized: "定位改写检测失败")
|
||||
case .success: return String(localized: "环境检测通过")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?? String(localized: "第三方客户端"))。点击“去提交”会先复制投稿模板,并在 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
|
||||
@@ -430,7 +483,7 @@ struct MapHomeView: View {
|
||||
// 当前选点
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(mapState.displayName ?? "当前选点").font(.subheadline.weight(.semibold)).lineLimit(1)
|
||||
Text(mapState.displayName ?? String(localized: "当前选点")).font(.subheadline.weight(.semibold)).lineLimit(1)
|
||||
coordinateRow(label: "GCJ-02(国内)", system: .gcj02)
|
||||
coordinateRow(label: "WGS-84(国际)", system: .wgs84)
|
||||
}
|
||||
@@ -443,7 +496,7 @@ struct MapHomeView: View {
|
||||
activeTip = .deactivation
|
||||
}
|
||||
} label: {
|
||||
Text(spoofState == .active ? "无法生效?" : "无法取消?")
|
||||
(spoofState == .active ? Text("无法生效?") : Text("无法取消?"))
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
@@ -467,7 +520,7 @@ struct MapHomeView: View {
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(favorites.selectedFavoriteID != nil ? .orange : .gray)
|
||||
.disabled(favoriteSaveTask != nil)
|
||||
.accessibilityLabel(favorites.selectedFavoriteID != nil ? "已收藏,点击取消收藏" : "收藏当前选点")
|
||||
.accessibilityLabel(favorites.selectedFavoriteID != nil ? Text("已收藏,点击取消收藏") : Text("收藏当前选点"))
|
||||
}
|
||||
// 收藏
|
||||
if favorites.favorites.isEmpty {
|
||||
@@ -484,7 +537,7 @@ struct MapHomeView: View {
|
||||
if spoofState == .verifying {
|
||||
ProgressView().tint(.white)
|
||||
}
|
||||
Text(spoofState == .active && needsSwitchButton ? "关闭" : buttonTitle)
|
||||
(spoofState == .active && needsSwitchButton ? Text("关闭") : Text(buttonTitle))
|
||||
.font(.headline).lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: needsSwitchButton ? nil : .infinity)
|
||||
@@ -532,15 +585,15 @@ struct MapHomeView: View {
|
||||
private var buttonTitle: String {
|
||||
if runtimeMode.mode == .thirdParty {
|
||||
switch spoofState {
|
||||
case .idle: return "同步到第三方代理"
|
||||
case .verifying: return "检测并同步中…"
|
||||
case .active: return "停止第三方虚拟定位"
|
||||
case .idle: return String(localized: "同步到第三方代理")
|
||||
case .verifying: return String(localized: "检测并同步中…")
|
||||
case .active: return String(localized: "停止第三方虚拟定位")
|
||||
}
|
||||
}
|
||||
switch spoofState {
|
||||
case .idle: return "开始虚拟定位"
|
||||
case .verifying: return "验证环境中…"
|
||||
case .active: return "停止虚拟定位"
|
||||
case .idle: return String(localized: "开始虚拟定位")
|
||||
case .verifying: return String(localized: "验证环境中…")
|
||||
case .active: return String(localized: "停止虚拟定位")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -686,7 +766,8 @@ struct MapHomeView: View {
|
||||
private func presentSuccessfulOperationTip(_ kind: VirtualLocationTipKind) {
|
||||
let count = tipPreferences.recordSuccessfulOperation(kind)
|
||||
let operationName = kind == .activation ? "开启" : "关闭"
|
||||
RuntimeLogger.info("APP", "提醒", "累计\(operationName)虚拟定位次数", details: [
|
||||
RuntimeLogger.info("APP", "提醒", "累计虚拟定位操作次数", details: [
|
||||
"操作": operationName,
|
||||
"次数": String(count),
|
||||
"运行模式": runtimeMode.mode.displayName,
|
||||
"可显示不再提醒": String(tipPreferences.canSuppress(kind))
|
||||
@@ -700,6 +781,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) {
|
||||
@@ -759,7 +857,7 @@ struct MapHomeView: View {
|
||||
}
|
||||
|
||||
private func coordinateRow(
|
||||
label: String,
|
||||
label: LocalizedStringKey,
|
||||
system: CoordinateConverter.MapCoordinateSystem
|
||||
) -> some View {
|
||||
let coordinate = currentSelectionPair.coordinate(for: system)
|
||||
@@ -990,14 +1088,21 @@ struct MapHomeView: View {
|
||||
} else {
|
||||
spoofState = .idle
|
||||
RuntimeLogger.warning("APP", "ThirdPartyProxy", "第三方代理查询返回失败", details: [
|
||||
"当前客户端": thirdPartyClient.selectedClient.name,
|
||||
"请求动作": "WLOC query",
|
||||
"错误": response.error ?? "未知错误"
|
||||
])
|
||||
setup.requestThirdPartySetup(message: response.error ?? String(localized: "第三方代理查询失败"))
|
||||
}
|
||||
} 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 +1144,8 @@ struct MapHomeView: View {
|
||||
"网络可用": String(net.isSatisfied),
|
||||
"Wi-Fi接口": String(net.isWiFiEnabled)
|
||||
])
|
||||
activeTip = .proxySetup
|
||||
activeTip = nil
|
||||
setup.requestSetup(message: String(localized: "当前未连接可用的 Wi-Fi,请连接 Wi-Fi 后配置 127.0.0.1:8888 手动代理。"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1066,21 +1172,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", "环境检测连续被占用,本次不重复弹窗")
|
||||
}
|
||||
@@ -1236,7 +1336,9 @@ struct MapHomeView: View {
|
||||
context.showFailureAlert,
|
||||
!Task.isCancelled,
|
||||
mapState.selection.revision == context.intent.selectionRevision else { return }
|
||||
RuntimeLogger.info("APP", "地图", "定位失败 status=\(realtime.authorizationStatus.rawValue)")
|
||||
RuntimeLogger.info("APP", "地图", "定位失败", details: [
|
||||
"授权状态rawValue": String(realtime.authorizationStatus.rawValue)
|
||||
])
|
||||
showLocationAlert = true
|
||||
return
|
||||
}
|
||||
@@ -1418,7 +1520,7 @@ struct MapHomeView: View {
|
||||
}
|
||||
searchResults = (response?.mapItems ?? []).prefix(6).map { item in
|
||||
let r = SearchLocationResult(
|
||||
name: item.name ?? "未命名",
|
||||
name: item.name ?? String(localized: "未命名"),
|
||||
subtitle: [item.placemark.locality, item.placemark.subLocality, item.placemark.thoroughfare]
|
||||
.compactMap { $0 }
|
||||
.filter { !$0.isEmpty }
|
||||
@@ -1430,7 +1532,7 @@ struct MapHomeView: View {
|
||||
])
|
||||
return r
|
||||
}
|
||||
if searchResults.isEmpty { searchError = "没有找到相关地点" }
|
||||
if searchResults.isEmpty { searchError = String(localized: "没有找到相关地点") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
error = String(localized: "证书下载地址无效")
|
||||
return nil
|
||||
}
|
||||
error = nil
|
||||
return true
|
||||
return url
|
||||
} catch {
|
||||
self.error = "启动代理失败: \(error.localizedDescription)"
|
||||
RuntimeLogger.error("APP", "Certificate", "打开证书下载失败", error: error)
|
||||
return false
|
||||
self.error = String(localized: "启动代理失败: \(error.localizedDescription)")
|
||||
RuntimeLogger.error("APP", "Certificate", "准备证书下载失败", error: error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,5 +182,5 @@ struct ProxyCoordinateSnapshot: Equatable {
|
||||
|
||||
enum ProxyError: LocalizedError {
|
||||
case startFailed
|
||||
var errorDescription: String? { "Go proxy 启动失败" }
|
||||
var errorDescription: String? { String(localized: "Go proxy 启动失败") }
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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,20 @@ struct SettingsView: View {
|
||||
@ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared
|
||||
@ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared
|
||||
@ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared
|
||||
@ObservedObject private var motionSimulation = MotionSimulationStore.shared
|
||||
@ObservedObject private var randomRadius = RandomRadiusStore.shared
|
||||
@ObservedObject private var moduleSource = ThirdPartyModuleSourceStore.shared
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var activeTip: TipKind?
|
||||
@State private var proxyOperationError = ""
|
||||
@State private var proxyOperationAlertTitle = "代理操作失败"
|
||||
@State private var proxyOperationAlertTitle = String(localized: "代理操作失败")
|
||||
@State private var modeOperationRunning = false
|
||||
@State private var copiedClient: ThirdPartyProxyClient?
|
||||
@State private var copiedMITMHostname: String?
|
||||
@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 +50,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 +85,47 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("定位模拟") {
|
||||
if runtimeMode.mode == .localWiFi {
|
||||
Toggle("运动状态模拟", isOn: motionSimulationBinding)
|
||||
.disabled(
|
||||
modeOperationRunning ||
|
||||
actions.state.isBusy ||
|
||||
thirdPartyProxy.isRequesting
|
||||
)
|
||||
Text("实验性功能,默认关闭。开启后会同时模拟定位响应中的运动状态。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if runtimeMode.mode == .thirdParty {
|
||||
Toggle("随机扰动", isOn: randomRadiusBinding)
|
||||
.disabled(modeOperationRunning || actions.state.isBusy)
|
||||
Text("开启后,下次同步坐标时会给目标点添加随机偏移(默认半径 50 米),避免位置固定在同一点。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if runtimeMode.mode == .thirdParty {
|
||||
thirdPartyConfigurationSection
|
||||
} else {
|
||||
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 +142,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,11 +222,11 @@ struct SettingsView: View {
|
||||
|
||||
Section("致谢") {
|
||||
Button {
|
||||
if let url = URL(string: "https://github.com/Yu9191/wloc") {
|
||||
if let url = URL(string: "https://github.com/xweiba/location-spoofer/blob/main/docs/THIRD_PARTY_MODULES.md") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
Label("核心定位改写逻辑移植自 Yu9191/wloc", systemImage: "heart.fill")
|
||||
Label("定位改写脚本源自 Yu9191/wloc,现由本项目维护", systemImage: "heart.fill")
|
||||
.foregroundStyle(.pink)
|
||||
}
|
||||
}
|
||||
@@ -144,6 +239,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,9 +251,24 @@ 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 与设备管理」手动删除旧证书,然后重新下载安装并完全信任新证书。")
|
||||
}
|
||||
}
|
||||
|
||||
private func valueRow(_ title: String, value: String) -> some View {
|
||||
private func valueRow(_ title: LocalizedStringKey, value: String) -> some View {
|
||||
HStack { Text(title); Spacer(); Text(value).font(.footnote.monospaced()).foregroundStyle(.secondary) }
|
||||
}
|
||||
|
||||
@@ -164,6 +278,71 @@ struct SettingsView: View {
|
||||
return "\(v) (\(b))"
|
||||
}
|
||||
|
||||
private func checkForUpdates() {
|
||||
guard !isCheckingForUpdates else { return }
|
||||
isCheckingForUpdates = true
|
||||
Task { @MainActor in
|
||||
defer { isCheckingForUpdates = false }
|
||||
guard let configuration = await AppRemoteConfigurationService.fetch() else {
|
||||
updateCheckResult = .failed
|
||||
return
|
||||
}
|
||||
AppRemoteConfigurationStore.shared.apply(configuration)
|
||||
let currentVersion = Bundle.main.object(
|
||||
forInfoDictionaryKey: "CFBundleShortVersionString"
|
||||
) as? String ?? AppRemoteConfiguration.fallback.latestVersion
|
||||
guard let pendingPrompt = configuration.updatePrompt(currentVersion: currentVersion) else {
|
||||
updateCheckResult = .current(
|
||||
currentVersion: currentVersion,
|
||||
latestVersion: configuration.latestVersion
|
||||
)
|
||||
return
|
||||
}
|
||||
let releaseNotes = await AppRemoteConfigurationService.fetchReleaseNotes(
|
||||
version: pendingPrompt.latestVersion
|
||||
)
|
||||
let prompt = configuration.updatePrompt(
|
||||
currentVersion: currentVersion,
|
||||
releaseNotes: releaseNotes
|
||||
) ?? pendingPrompt
|
||||
updateCheckResult = .available(prompt)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCheckAlert(for result: UpdateCheckResult) -> Alert {
|
||||
switch result {
|
||||
case .current(let currentVersion, let latestVersion):
|
||||
return Alert(
|
||||
title: Text("已是最新版本"),
|
||||
message: Text("当前版本 \(currentVersion),远程最新版本 \(latestVersion)。"),
|
||||
dismissButton: .default(Text("知道了"))
|
||||
)
|
||||
case .available(let prompt):
|
||||
let details = prompt.releaseNotes
|
||||
?? String(localized: "更新说明暂时无法加载,请前往最新 Release 页面查看。")
|
||||
let message: String
|
||||
if prompt.requirement == .required {
|
||||
message = String(localized: "当前版本 \(prompt.currentVersion) 已停止支持,请更新到 \(prompt.latestVersion) 后继续使用。\n\n\(details)")
|
||||
} else {
|
||||
message = String(localized: "当前版本 \(prompt.currentVersion),最新版本 \(prompt.latestVersion)。\n\n\(details)")
|
||||
}
|
||||
return Alert(
|
||||
title: prompt.requirement == .required ? Text("需要更新") : Text("发现新版本"),
|
||||
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 {
|
||||
@@ -172,7 +351,7 @@ struct SettingsView: View {
|
||||
try await proxy.start()
|
||||
} catch {
|
||||
proxy.error = error.localizedDescription
|
||||
proxyOperationAlertTitle = "代理操作失败"
|
||||
proxyOperationAlertTitle = String(localized: "代理操作失败")
|
||||
proxyOperationError = error.localizedDescription
|
||||
}
|
||||
} else {
|
||||
@@ -193,6 +372,22 @@ struct SettingsView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var motionSimulationBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { motionSimulation.isEnabled },
|
||||
set: { enabled in
|
||||
proxy.applyMotionSimulation(enabled)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var randomRadiusBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { randomRadius.isEnabled },
|
||||
set: { randomRadius.setEnabled($0) }
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var thirdPartyConfigurationSection: some View {
|
||||
Section("第三方代理配置") {
|
||||
@@ -205,19 +400,50 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("验证状态")
|
||||
Spacer()
|
||||
Text(thirdPartyClient.selectedClient.verificationText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(thirdPartyClient.selectedClient == .shadowrocket ? .green : .orange)
|
||||
Toggle("使用国内镜像下载模块", isOn: Binding(
|
||||
get: { moduleSource.useMirror },
|
||||
set: { moduleSource.setUseMirror($0) }
|
||||
))
|
||||
Text("仅影响之后复制和重新导入的模块地址;已安装模块需要重新导入后切换来源。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
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")
|
||||
(copiedClient == thirdPartyClient.selectedClient ? Label("已复制模块订阅地址", systemImage: "doc.on.doc") : Label("复制模块订阅地址", systemImage: "doc.on.doc"))
|
||||
}
|
||||
|
||||
ForEach(ThirdPartyProxyManager.interceptionHostnames, id: \.self) { hostname in
|
||||
HStack(spacing: 8) {
|
||||
Text(hostname)
|
||||
.font(.caption.monospaced())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Button {
|
||||
UIPasteboard.general.string = hostname
|
||||
copiedMITMHostname = hostname
|
||||
} label: {
|
||||
Image(systemName: copiedMITMHostname == hostname ? "checkmark" : "doc.on.doc")
|
||||
.frame(width: 28, height: 28)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.accessibilityLabel(
|
||||
copiedMITMHostname == hostname
|
||||
? Text("已复制 \(hostname)")
|
||||
: Text("复制 \(hostname)")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
@@ -227,7 +453,7 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
Button {
|
||||
setup.requestThirdPartySetup()
|
||||
setup.requestThirdPartyOnboarding()
|
||||
dismiss()
|
||||
} label: {
|
||||
Label("重新打开配置引导", systemImage: "arrow.clockwise.circle")
|
||||
@@ -241,7 +467,7 @@ struct SettingsView: View {
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text("复制链接后,在对应代理客户端中添加模块/重写订阅,并启用 MITM。第三方客户端保存坐标后,即使关闭本 App,坐标仍由代理客户端持久化并继续生效。")
|
||||
Text("复制模块订阅地址后,在对应代理客户端中添加模块/重写订阅,并为复制的全部域名(含 gsp-ssl.ls.apple.com、bluedot.is.autonavi.com)启用 MITM。第三方客户端保存坐标后,即使关闭本 App,坐标仍由代理客户端持久化并继续生效。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -256,31 +482,31 @@ struct SettingsView: View {
|
||||
|
||||
private var thirdPartyStatusText: String {
|
||||
switch thirdPartyProxy.connectionState {
|
||||
case .unknown: return "未检测"
|
||||
case .connected(let active): return active ? "已连接,有坐标" : "已连接,无坐标"
|
||||
case .failed: return "连接失败"
|
||||
case .unknown: return String(localized: "未检测")
|
||||
case .connected(let active): return active ? String(localized: "已连接,有坐标") : String(localized: "已连接,无坐标")
|
||||
case .failed: return String(localized: "连接失败")
|
||||
}
|
||||
}
|
||||
|
||||
private var virtualLocationStatusText: String {
|
||||
if runtimeMode.mode == .localWiFi {
|
||||
return actions.virtualLocationEnabled ? "已开启" : "已关闭"
|
||||
return actions.virtualLocationEnabled ? String(localized: "已开启") : String(localized: "已关闭")
|
||||
}
|
||||
if case .connected(let active) = thirdPartyProxy.connectionState {
|
||||
return active ? "第三方已保存" : "未保存"
|
||||
return active ? String(localized: "第三方已保存") : String(localized: "未保存")
|
||||
}
|
||||
return "未知"
|
||||
return String(localized: "未知")
|
||||
}
|
||||
|
||||
private var workflowDescription: String {
|
||||
if runtimeMode.mode == .thirdParty {
|
||||
return "App 只负责地图选点、收藏和发送 WGS-84 坐标。第三方代理客户端通过模块拦截 Apple WLOC 请求并持久化当前坐标;本模式不启动本机代理,不使用 App 的 CA,也不需要配置 127.0.0.1:8888。"
|
||||
return String(localized: "App 只负责地图选点、收藏和发送 WGS-84 坐标。第三方代理客户端通过模块拦截 Apple WLOC 请求并持久化当前坐标;本模式不启动本机代理,不使用 App 的 CA,也不需要配置 127.0.0.1:8888。")
|
||||
}
|
||||
return """
|
||||
return String(localized: """
|
||||
App 在设备本地运行一个代理服务器(127.0.0.1:8888)。
|
||||
|
||||
通过 WiFi 手动代理配置,让系统的定位请求(gs-loc.apple.com/clls/wloc)经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。
|
||||
"""
|
||||
通过 WiFi 手动代理配置,让系统发往 Apple 定位域名(gs-loc.apple.com、gsp-ssl.ls.apple.com、bluedot.is.autonavi.com 等)的定位请求经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。
|
||||
""")
|
||||
}
|
||||
|
||||
private func switchRuntimeMode(to newMode: ProxyRuntimeMode) {
|
||||
@@ -294,9 +520,18 @@ 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.query()
|
||||
proxyOperationAlertTitle = String(localized: "模式已切换")
|
||||
proxyOperationError = String(localized: "第三方代理模式检测通过。请关闭 Wi-Fi 中的 127.0.0.1:8888 手动代理,避免双重拦截。")
|
||||
} catch {
|
||||
openThirdPartySetup(for: error)
|
||||
}
|
||||
} else {
|
||||
setup.requestThirdPartyOnboarding()
|
||||
dismiss()
|
||||
}
|
||||
case .localWiFi:
|
||||
do {
|
||||
try await thirdPartyProxy.clear()
|
||||
@@ -307,23 +542,80 @@ 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 = String(localized: "模式已切换")
|
||||
proxyOperationError = String(localized: "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.query()
|
||||
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 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 = String(localized: "证书重置失败")
|
||||
proxyOperationError = setup.message
|
||||
return
|
||||
}
|
||||
setup.requestCertificateSetup()
|
||||
dismiss()
|
||||
} catch {
|
||||
proxyOperationAlertTitle = String(localized: "证书重置失败")
|
||||
proxyOperationError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
@@ -334,8 +626,8 @@ struct SettingsView: View {
|
||||
UIApplication.shared.open(url, options: [:]) { opened in
|
||||
guard !opened else { return }
|
||||
Task { @MainActor in
|
||||
proxyOperationAlertTitle = "无法打开客户端"
|
||||
proxyOperationError = "无法打开 \(client.name),请确认客户端已安装后手动打开。"
|
||||
proxyOperationAlertTitle = String(localized: "无法打开客户端")
|
||||
proxyOperationError = String(localized: "无法打开 \(client.name),请确认客户端已安装后手动打开。")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,32 +28,39 @@ 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)"
|
||||
message = String(localized: "本地代理初始化失败:\(error.localizedDescription)")
|
||||
RuntimeLogger.error("APP", "Startup", "本地服务初始化失败", error: error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func applyVerificationResult(_ result: VerificationResult) {
|
||||
lastVerificationResult = result
|
||||
switch result {
|
||||
case .success:
|
||||
trustState = .trusted
|
||||
needsSetup = false
|
||||
message = "✓ 定位环境正常"
|
||||
message = String(localized: "✓ 定位环境正常")
|
||||
case .certNotTrusted:
|
||||
trustState = .unavailable
|
||||
setupStep = .cert
|
||||
needsSetup = true
|
||||
message = "CA 证书未安装或未信任"
|
||||
message = String(localized: "CA 证书未安装或未信任")
|
||||
case .verificationInProgress, .verificationSuperseded:
|
||||
break
|
||||
default:
|
||||
trustState = .unavailable
|
||||
setupStep = .proxy
|
||||
needsSetup = true
|
||||
message = "Wi-Fi 代理未正确设置,请检查 127.0.0.1:8888"
|
||||
message = String(localized: "Wi-Fi 代理未正确设置,请检查 127.0.0.1:8888")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -82,36 +108,36 @@ final class SetupCoordinator: ObservableObject {
|
||||
testLog = ""
|
||||
let log = { (msg: String) in self.testLog += msg + "\n" }
|
||||
|
||||
log("======== 代理验证测试 ========")
|
||||
log("App 版本: \(appVersion)")
|
||||
log("系统版本: iOS \(UIDevice.current.systemVersion)")
|
||||
log(String(localized: "======== 代理验证测试 ========"))
|
||||
log(String(localized: "App 版本: \(appVersion)"))
|
||||
log(String(localized: "系统版本: iOS \(UIDevice.current.systemVersion)"))
|
||||
log("")
|
||||
|
||||
// Step A: Proxy running
|
||||
log("[步骤 A] 检查代理是否运行…")
|
||||
log(" 端口: 127.0.0.1:8888")
|
||||
log(String(localized: "[步骤 A] 检查代理是否运行…"))
|
||||
log(String(localized: " 端口: 127.0.0.1:8888"))
|
||||
let stepAStart = Date()
|
||||
if !proxy.isRunning {
|
||||
log(" ⚠ 代理未运行,尝试启动…")
|
||||
log(String(localized: " ⚠ 代理未运行,尝试启动…"))
|
||||
do { try await proxy.start() } catch {
|
||||
log(" ✗ 启动失败: \(error.localizedDescription)")
|
||||
log(String(localized: " ✗ 启动失败: \(error.localizedDescription)"))
|
||||
return .proxyNotRunning
|
||||
}
|
||||
log(" ✓ 代理启动成功")
|
||||
log(String(localized: " ✓ 代理启动成功"))
|
||||
} else {
|
||||
log(" ✓ 代理已在运行中")
|
||||
log(String(localized: " ✓ 代理已在运行中"))
|
||||
}
|
||||
collectProxyLogs(since: stepAStart, to: log)
|
||||
|
||||
// Step B: Combined CA + WiFi proxy check (single request)
|
||||
log("")
|
||||
log("[步骤 B] 检测证书与 WiFi 代理…")
|
||||
log(" 方式: 请求 baidu.com/paopao-verify-<token>")
|
||||
log(" 结果判定: TLS 错误=证书问题 / 响应不匹配=代理未配置 / 匹配=通过")
|
||||
log(String(localized: "[步骤 B] 检测证书与 WiFi 代理…"))
|
||||
log(String(localized: " 方式: 请求 baidu.com/paopao-verify-<token>"))
|
||||
log(String(localized: " 结果判定: TLS 错误=证书问题 / 响应不匹配=代理未配置 / 匹配=通过"))
|
||||
let stepBStart = Date()
|
||||
let verifyToken = CoreBridge.refreshVerifyToken()
|
||||
guard !verifyToken.isEmpty else {
|
||||
log(" ✗ 无法生成验证 token")
|
||||
log(String(localized: " ✗ 无法生成验证 token"))
|
||||
return .certNotTrusted
|
||||
}
|
||||
do {
|
||||
@@ -124,17 +150,17 @@ final class SetupCoordinator: ObservableObject {
|
||||
let statusCode = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
if body == verifyToken {
|
||||
log(" ✓ 证书已信任,WiFi 代理已配置")
|
||||
log(String(localized: " ✓ 证书已信任,WiFi 代理已配置"))
|
||||
} else {
|
||||
log(" ✗ 响应不匹配: HTTP \(statusCode), \(data.count) bytes,WiFi 代理未配置")
|
||||
log(String(localized: " ✗ 响应不匹配: HTTP \(statusCode), \(data.count) bytes,WiFi 代理未配置"))
|
||||
return .wifiProxyNotConfigured
|
||||
}
|
||||
} catch {
|
||||
let ns = error as NSError
|
||||
let msg = error.localizedDescription
|
||||
log(" ✗ 请求失败 [\(ns.domain) code=\(ns.code)]: \(msg)")
|
||||
log(String(localized: " ✗ 请求失败 [\(ns.domain) code=\(ns.code)]: \(msg)"))
|
||||
if isCertificateTrustError(nsError: ns, message: msg) {
|
||||
log(" TLS/证书校验失败,CA 证书未信任")
|
||||
log(String(localized: " TLS/证书校验失败,CA 证书未信任"))
|
||||
return .certNotTrusted
|
||||
}
|
||||
return .wifiProxyNotConfigured
|
||||
@@ -142,7 +168,7 @@ final class SetupCoordinator: ObservableObject {
|
||||
collectProxyLogs(since: stepBStart, to: log)
|
||||
|
||||
log("")
|
||||
log("======== 环境检测通过 ✓ ========")
|
||||
log(String(localized: "======== 环境检测通过 ✓ ========"))
|
||||
return .success
|
||||
}
|
||||
|
||||
@@ -176,9 +202,9 @@ final class SetupCoordinator: ObservableObject {
|
||||
$0.source == "CORE" && $0.category == "Proxy" && $0.timestamp >= date
|
||||
}
|
||||
guard !proxyEntries.isEmpty else { return }
|
||||
log(" --- 代理日志 ---")
|
||||
log(String(localized: " --- 代理日志 ---"))
|
||||
for e in proxyEntries {
|
||||
log(" " + e.message)
|
||||
log(" " + e.localizedMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,13 +25,13 @@ enum SystemSettingsDestination {
|
||||
var manualPath: String {
|
||||
switch self {
|
||||
case .appPermissions:
|
||||
return "请手动打开「设置」,找到本 App 后检查定位权限。"
|
||||
return String(localized: "请手动打开「设置」,找到本 App 后检查定位权限。")
|
||||
case .general:
|
||||
return "请手动打开「设置 → 通用」。"
|
||||
return String(localized: "请手动打开「设置 → 通用」。")
|
||||
case .wifi:
|
||||
return "请手动打开「设置 → 无线局域网」,进入当前 Wi-Fi 的详情页。"
|
||||
return String(localized: "请手动打开「设置 → 无线局域网」,进入当前 Wi-Fi 的详情页。")
|
||||
case .locationServices:
|
||||
return "请手动打开「设置 → 隐私与安全性 → 定位服务」。"
|
||||
return String(localized: "请手动打开「设置 → 隐私与安全性 → 定位服务」。")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ enum TipKind: String, Identifiable {
|
||||
case activation = "生效说明"
|
||||
case deactivation = "失效说明"
|
||||
case removeProxy = "关闭 WiFi 代理"
|
||||
case proxySetup = "配置代理"
|
||||
case rewriteFailed = "改写失败"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
@@ -22,12 +20,10 @@ 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)
|
||||
}
|
||||
.navigationTitle(kind.rawValue).navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle(LocalizedStringKey(kind.rawValue)).navigationBarTitleDisplayMode(.inline)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
Button { dismiss() } label: {
|
||||
Text("知道了").font(.body.weight(.medium)).frame(maxWidth: .infinity).padding(.vertical, 12)
|
||||
@@ -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,14 +69,18 @@ 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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func step(_ n: Int, _ title: String, _ detail: String) -> some View {
|
||||
private func step(_ n: Int, _ title: LocalizedStringKey, _ detail: LocalizedStringKey) -> some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("\(n)").font(.caption2.bold())
|
||||
.frame(width: 20, height: 20)
|
||||
@@ -103,7 +92,7 @@ struct ActivationTipContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func systemStep(_ n: Int, _ title: String, _ detail: String) -> some View {
|
||||
private func systemStep(_ n: Int, _ title: LocalizedStringKey, _ detail: LocalizedStringKey) -> some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("\(n)").font(.caption2.bold())
|
||||
.frame(width: 20, height: 20)
|
||||
@@ -141,13 +130,18 @@ 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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func step(_ n: Int, _ title: String, _ detail: String) -> some View {
|
||||
private func step(_ n: Int, _ title: LocalizedStringKey, _ detail: LocalizedStringKey) -> some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("\(n)").font(.caption2.bold())
|
||||
.frame(width: 20, height: 20)
|
||||
@@ -159,7 +153,7 @@ struct DeactivationTipContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func systemStep(_ n: Int, _ title: String, _ detail: String) -> some View {
|
||||
private func systemStep(_ n: Int, _ title: LocalizedStringKey, _ detail: LocalizedStringKey) -> some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("\(n)").font(.caption2.bold())
|
||||
.frame(width: 20, height: 20)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -22,13 +22,14 @@ import (
|
||||
const proxyPort = 8888
|
||||
|
||||
var (
|
||||
stateMu sync.Mutex
|
||||
currentLat float64
|
||||
currentLon float64
|
||||
currentEnabled bool
|
||||
currentAccuracy int
|
||||
globalCACert *tls.Certificate
|
||||
verifyToken string
|
||||
stateMu sync.Mutex
|
||||
currentLat float64
|
||||
currentLon float64
|
||||
currentEnabled bool
|
||||
currentAccuracy int
|
||||
currentMotionSimulationEnabled bool
|
||||
globalCACert *tls.Certificate
|
||||
verifyToken string
|
||||
|
||||
logMu sync.Mutex
|
||||
logEntries []string
|
||||
@@ -63,7 +64,13 @@ func isWlocHost(host string) bool {
|
||||
host = h
|
||||
}
|
||||
}
|
||||
return host == "gs-loc.apple.com" || host == "gs-loc-cn.apple.com"
|
||||
switch host {
|
||||
case "gs-loc.apple.com", "gs-loc-cn.apple.com",
|
||||
"gsp-ssl.ls.apple.com", "bluedot.is.autonavi.com",
|
||||
"bluedot.is.autonavi.com.gds.alibabadns.com":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer {
|
||||
@@ -94,10 +101,10 @@ func newProxy(cert *tls.Certificate) *goproxy.ProxyHttpServer {
|
||||
}
|
||||
if r.URL.Path == "/coords" {
|
||||
stateMu.Lock()
|
||||
enabled, lat, lon, accuracy := currentEnabled, currentLat, currentLon, currentAccuracy
|
||||
enabled, lat, lon, accuracy, motionEnabled := currentEnabled, currentLat, currentLon, currentAccuracy, currentMotionSimulationEnabled
|
||||
stateMu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(fmt.Sprintf(`{"enabled":%t,"lat":%.6f,"lon":%.6f,"accuracy":%d}`, enabled, lat, lon, accuracy)))
|
||||
w.Write([]byte(fmt.Sprintf(`{"enabled":%t,"lat":%.6f,"lon":%.6f,"accuracy":%d,"motionSimulationEnabled":%t}`, enabled, lat, lon, accuracy, motionEnabled)))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/proxy.mobileconfig" || r.URL.Path == "/proxy.mobileconfig/" {
|
||||
@@ -221,7 +228,7 @@ func patchWlocResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Respons
|
||||
}
|
||||
|
||||
stateMu.Lock()
|
||||
enabled, lat, lon, accuracy := currentEnabled, currentLat, currentLon, currentAccuracy
|
||||
enabled, lat, lon, accuracy, motionEnabled := currentEnabled, currentLat, currentLon, currentAccuracy, currentMotionSimulationEnabled
|
||||
stateMu.Unlock()
|
||||
|
||||
const maxPatchBodyBytes int64 = 1 << 20
|
||||
@@ -249,7 +256,10 @@ func patchWlocResponse(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Respons
|
||||
return resp
|
||||
}
|
||||
|
||||
patched, stats, err := patchResponseBody(body, wlocCoords{Latitude: lat, Longitude: lon, Accuracy: accuracy})
|
||||
patched, stats, err := patchResponseBody(body, wlocCoords{
|
||||
Latitude: lat, Longitude: lon, Accuracy: accuracy,
|
||||
MotionSimulationEnabled: motionEnabled,
|
||||
})
|
||||
if err != nil || bytes.Equal(patched, body) {
|
||||
if err != nil {
|
||||
logEvent("wloc patch skipped: " + err.Error())
|
||||
@@ -326,7 +336,7 @@ func randomUint32() uint32 {
|
||||
return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
|
||||
}
|
||||
|
||||
func startProxy(certPEM, keyPEM []byte, lat, lon float64, enabled bool, accuracy int) (*http.Server, error) {
|
||||
func startProxy(certPEM, keyPEM []byte, lat, lon float64, enabled bool, accuracy int, motionEnabled bool) (*http.Server, error) {
|
||||
cert, err := parseCA(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -335,6 +345,7 @@ func startProxy(certPEM, keyPEM []byte, lat, lon float64, enabled bool, accuracy
|
||||
stateMu.Lock()
|
||||
globalCACert = cert
|
||||
currentLat, currentLon, currentEnabled, currentAccuracy = lat, lon, enabled, accuracy
|
||||
currentMotionSimulationEnabled = motionEnabled
|
||||
stateMu.Unlock()
|
||||
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", proxyPort))
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: setup core ipa-unsigned verify-ipa
|
||||
.PHONY: setup core ipa-unsigned verify-ipa test
|
||||
|
||||
setup:
|
||||
./Scripts/setup.sh
|
||||
@@ -12,3 +12,6 @@ ipa-unsigned: core
|
||||
verify-ipa:
|
||||
@test -n "$(IPA)" || (echo "Usage: make verify-ipa IPA=/absolute/path/to/signed.ipa" >&2; exit 2)
|
||||
./Scripts/verify-ipa.sh --signed "$(IPA)"
|
||||
|
||||
test:
|
||||
./Tests/run_all_contract_tests.sh
|
||||
|
||||
@@ -9,10 +9,13 @@ An open-source project for **iOS location-service research, software development
|
||||
The project uses either an on-device proxy or a third-party proxy client to simulate selected Apple location-service
|
||||
responses in a controlled test environment.
|
||||
|
||||
> ⚠️ **Starting with iOS 27 beta 6, the system blocks MITM interception of `gs-loc.apple.com`.** This project is
|
||||
> temporarily unusable on that version and later betas until a workaround is found.
|
||||
|
||||
[](project.yml)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](docs/CHANGELOG.md)
|
||||
|
||||
[Features](#feature-overview) ·
|
||||
[How It Works](#how-it-works) ·
|
||||
@@ -134,6 +137,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,19 +217,22 @@ 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 |
|
||||
|
||||
Module snapshots and provenance:
|
||||
Community configurations are reviewed by client. Accepted submissions are linked in this table with attribution unless
|
||||
the contributor requests anonymous inclusion.
|
||||
|
||||
Modules and scripts are now hosted by this repository. The original
|
||||
`Yu9191/wloc` repository was deleted and remains attribution only, not a runtime dependency:
|
||||
|
||||
- [Third-party module documentation](docs/THIRD_PARTY_MODULES.md)
|
||||
- [Yu9191/wloc](https://github.com/Yu9191/wloc)
|
||||
|
||||
The selected client owns its certificates, MITM configuration, and proxy switches. Review third-party modules and
|
||||
scripts before importing them.
|
||||
@@ -307,7 +364,7 @@ Deploy it to a test device using your own signing and installation process.
|
||||
- Runtime logs remain in the device App Group container and retain only the latest three days;
|
||||
- Issue reports are copied by the user before being submitted to GitHub;
|
||||
- App Mode accesses the local proxy and the environment-verification URL;
|
||||
- Third-party Proxy Mode may access the upstream module URL and the WLOC configuration endpoint;
|
||||
- Third-party Proxy Mode may access module URLs hosted by this repository and the WLOC configuration endpoint;
|
||||
- The CA private key generated by the app is stored in the device Keychain;
|
||||
- Third-party MITM, certificates, and proxy behavior are owned by the selected client.
|
||||
|
||||
@@ -327,21 +384,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
|
||||
|
||||
@@ -382,13 +443,21 @@ guarantee for every app or release.
|
||||
|
||||
## Acknowledgements and Links
|
||||
|
||||
The core location-response handling approach, Go implementation, and third-party modules are based on:
|
||||
The core location-response handling approach, Go implementation, and third-party modules are based on
|
||||
the following projects (`Yu9191/wloc` has since been deleted):
|
||||
|
||||
- [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.
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
项目通过本机代理或第三方代理客户端,对 Apple 定位服务的指定响应进行测试环境模拟,帮助开发者验证应用在
|
||||
不同地理位置和定位场景下的行为。
|
||||
|
||||
> ⚠️ **iOS 27 beta 6 起,系统已禁止对 `gs-loc.apple.com` 进行 MITM 拦截。** 目前该版本及之后的 beta 版本暂时无法使用本项目,等待后续适配方案。
|
||||
|
||||
[](project.yml)
|
||||
[](project.yml)
|
||||
[](Core/go.mod)
|
||||
[](docs/CHANGELOG.md)
|
||||
[](docs/CHANGELOG.md)
|
||||
|
||||
[功能概览](#功能概览) ·
|
||||
[工作原理](#工作原理) ·
|
||||
@@ -130,6 +132,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,19 +208,20 @@ WLOC 配置接口
|
||||
|
||||
当前客户端状态:
|
||||
|
||||
| 客户端 | 状态 |
|
||||
|---|---|
|
||||
| Shadowrocket | 当前用于真机测试 |
|
||||
| Surge | 已提供配置,尚未完整验证 |
|
||||
| Quantumult X | 已提供配置,尚未完整验证 |
|
||||
| Loon | 已提供配置,尚未完整验证 |
|
||||
| Stash | 已提供配置,尚未完整验证 |
|
||||
| Egern | 使用 Surge 模块,尚未完整验证 |
|
||||
| 客户端 | 状态 | 社区配置 |
|
||||
|---|---|---|
|
||||
| Shadowrocket | 当前用于真机测试 | App 内置教程 |
|
||||
| Surge | 已提供配置,尚未完整验证 | 待征集 |
|
||||
| Quantumult X | 已提供配置,尚未完整验证 | 待征集 |
|
||||
| Loon | 已提供配置,尚未完整验证 | 待征集 |
|
||||
| Stash | 已提供配置,尚未完整验证 | 待征集 |
|
||||
| Egern | 使用 Surge 模块,尚未完整验证 | 待征集 |
|
||||
|
||||
相关模块快照和来源记录:
|
||||
社区配置按客户端分区审核;采纳后会在上表链接教程和投稿者,投稿者也可以选择匿名收录。
|
||||
|
||||
模块与脚本现由本仓库托管,原 `Yu9191/wloc` 仓库已删除,仅保留来源致谢,不再作为运行时依赖:
|
||||
|
||||
- [第三方模块说明](docs/THIRD_PARTY_MODULES.md)
|
||||
- [Yu9191/wloc](https://github.com/Yu9191/wloc)
|
||||
|
||||
第三方客户端、证书、MITM 和代理开关由客户端自身负责。导入任何第三方模块前,请先审查其配置和脚本内容。
|
||||
|
||||
@@ -300,7 +350,7 @@ dist/PaopaoLocationSpoofer-unsigned.ipa
|
||||
- 运行日志保存在设备 App Group 容器中,并自动保留近三天;
|
||||
- 问题报告需要用户主动复制后提交到 GitHub;
|
||||
- APP 模式会访问本机代理和环境验证地址;
|
||||
- 第三方代理模式可能访问上游模块地址和 WLOC 配置接口;
|
||||
- 第三方代理模式可能访问本仓库托管的模块地址和 WLOC 配置接口;
|
||||
- App 生成的 CA 私钥保存在设备 Keychain 中;
|
||||
- 第三方客户端模块、MITM 和证书链路由用户选择的客户端负责。
|
||||
|
||||
@@ -320,26 +370,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)
|
||||
@@ -374,12 +429,19 @@ dist/PaopaoLocationSpoofer-unsigned.ipa
|
||||
|
||||
## 致谢与友链
|
||||
|
||||
核心定位响应处理思路、Go 实现和第三方模块参考自:
|
||||
核心定位响应处理思路、Go 实现和第三方模块参考自(`Yu9191/wloc` 原仓库已删除):
|
||||
|
||||
- [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 定位服务研究、网络代理和移动端测试工具建设的贡献者。
|
||||
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 60 KiB |
@@ -1,11 +1,11 @@
|
||||
#!name=Apple WLOC 定位修改
|
||||
#!desc=修改 Apple 网络定位返回坐标 | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f | 选点页面: https://wloc-pages.pages.dev/
|
||||
#!desc=Apple 网络定位修改 | 使用说明: https://github.com/xweiba/location-spoofer/blob/main/docs/THIRD_PARTY_MODULES.md
|
||||
#!author=Yu9191 Rewrite
|
||||
#!homepage=https://github.com/Yu9191/wloc
|
||||
#!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|gsp-ssl\.ls\.apple\.com|bluedot\.is\.autonavi\.com(?:\.gds\.alibabadns\.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?v=1.0.8
|
||||
^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save url script-echo-response https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js?v=1.0.8
|
||||
|
||||
[mitm]
|
||||
hostname = gs-loc.apple.com, gs-loc-cn.apple.com
|
||||
hostname = gs-loc.apple.com, gs-loc-cn.apple.com, gsp-ssl.ls.apple.com, bluedot.is.autonavi.com, bluedot.is.autonavi.com.gds.alibabadns.com
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
#!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
|
||||
#!desc=Apple 网络定位修改 | 使用说明: https://github.com/xweiba/location-spoofer/blob/main/docs/THIRD_PARTY_MODULES.md
|
||||
#!icon=https://raw.githubusercontent.com/xweiba/location-spoofer/main/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png
|
||||
#!author=Yu9191 Rewrite
|
||||
#!homepage=https://github.com/Yu9191/wloc
|
||||
#!openUrl=https://wloc-pages.pages.dev/
|
||||
#!homepage=https://github.com/xweiba/location-spoofer
|
||||
|
||||
|
||||
[Argument]
|
||||
longitude = input, "113.94114", tag=经度(在线选点优先)
|
||||
latitude = input, "22.544577", tag=纬度(在线选点优先)
|
||||
accuracy = input, "25", tag=精度(米)
|
||||
randomRadius = input, "0", tag=扰动半径(米,0为关闭)
|
||||
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|gsp-ssl\.ls\.apple\.com|bluedot\.is\.autonavi\.com(?:\.gds\.alibabadns\.com)?)\/clls\/wloc script-path=https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js?v=1.0.8, requires-body=true, binary-body-mode=true, timeout=30, tag=Apple WLOC, argument=[{longitude},{latitude},{accuracy},{randomRadius},{logLevel}]
|
||||
http-request ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save script-path=https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js?v=1.0.8, timeout=10, tag=WLOC Settings
|
||||
|
||||
[MITM]
|
||||
hostname = gs-loc.apple.com, gs-loc-cn.apple.com
|
||||
hostname = gs-loc.apple.com, gs-loc-cn.apple.com, gsp-ssl.ls.apple.com, bluedot.is.autonavi.com, bluedot.is.autonavi.com.gds.alibabadns.com
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
#!name=Apple WLOC 定位修改
|
||||
#!desc=修改 Apple 网络定位返回坐标 (Shadowrocket 小火箭) | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f | 选点页面: https://wloc-pages.pages.dev/
|
||||
#!desc=Apple 网络定位修改 | 使用说明: https://github.com/xweiba/location-spoofer/blob/main/docs/THIRD_PARTY_MODULES.md
|
||||
#!author=Yu9191 Rewrite
|
||||
#!homepage=https://github.com/Yu9191/wloc
|
||||
#!icon=https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/wloc.jpg
|
||||
#!homepage=https://github.com/xweiba/location-spoofer
|
||||
#!icon=https://raw.githubusercontent.com/xweiba/location-spoofer/main/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png
|
||||
#!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|gsp-ssl\.ls\.apple\.com|bluedot\.is\.autonavi\.com(?:\.gds\.alibabadns\.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?v=1.0.8,argument=longitude=113.94114&latitude=22.544577&accuracy=25&randomRadius=0&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://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js?v=1.0.8
|
||||
|
||||
[MITM]
|
||||
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com
|
||||
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com, gsp-ssl.ls.apple.com, bluedot.is.autonavi.com, bluedot.is.autonavi.com.gds.alibabadns.com
|
||||
|
||||
@@ -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/
|
||||
#!desc=Apple 网络定位修改 | 使用说明: https://github.com/xweiba/location-spoofer/blob/main/docs/THIRD_PARTY_MODULES.md
|
||||
#!author=Yu9191 Rewrite
|
||||
#!homepage=https://github.com/Yu9191/wloc
|
||||
#!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使用方法: 打开选点页面 -> 选位置 -> 储存到设备
|
||||
#!arguments=经度:113.94114, 纬度:22.544577, 精度:25, 扰动半径:0, 日志级别:info
|
||||
#!arguments-desc=经度/纬度: 默认坐标(在线选点储存后优先)\n精度: GPS精度(米)\n扰动半径: 每次响应在目标点周围随机偏移的最大距离(米),0为关闭\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|gsp-ssl\.ls\.apple\.com|bluedot\.is\.autonavi\.com(?:\.gds\.alibabadns\.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?v=1.0.8, argument=longitude={{{经度}}}&latitude={{{纬度}}}&accuracy={{{精度}}}&randomRadius={{{扰动半径}}}&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://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js?v=1.0.8
|
||||
|
||||
[MITM]
|
||||
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com
|
||||
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com, gsp-ssl.ls.apple.com, bluedot.is.autonavi.com, bluedot.is.autonavi.com.gds.alibabadns.com
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
name: Apple WLOC 定位修改
|
||||
desc: "修改 Apple 网络定位返回坐标 | 快捷指令(推荐): 设置地理位置 https://www.icloud.com/shortcuts/a82717d8fdad4e6280866fcf911173f7 清理恢复位置 https://www.icloud.com/shortcuts/f42632d406504f24a2cd163af4fe012f | 选点页面: https://wloc-pages.pages.dev/"
|
||||
desc: "Apple 网络定位修改 | 使用说明: https://github.com/xweiba/location-spoofer/blob/main/docs/THIRD_PARTY_MODULES.md"
|
||||
author: Yu9191 Rewrite
|
||||
homepage: https://github.com/Yu9191/wloc
|
||||
icon: https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/wloc.jpg
|
||||
homepage: https://github.com/xweiba/location-spoofer
|
||||
icon: https://raw.githubusercontent.com/xweiba/location-spoofer/main/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png
|
||||
category: Tools
|
||||
|
||||
http:
|
||||
mitm:
|
||||
- "gs-loc.apple.com"
|
||||
- "gs-loc-cn.apple.com"
|
||||
- "gsp-ssl.ls.apple.com"
|
||||
- "bluedot.is.autonavi.com"
|
||||
- "bluedot.is.autonavi.com.gds.alibabadns.com"
|
||||
script:
|
||||
- match: ^https?:\/\/gs-loc(-cn)?\.apple\.com\/clls\/wloc
|
||||
- match: ^https?:\/\/(?:gs-loc(?:-cn)?\.apple\.com|gsp-ssl\.ls\.apple\.com|bluedot\.is\.autonavi\.com(?:\.gds\.alibabadns\.com)?)\/clls\/wloc
|
||||
name: WLOC.Location
|
||||
type: response
|
||||
require-body: true
|
||||
binary-mode: true
|
||||
max-size: 0
|
||||
timeout: 30
|
||||
argument: longitude=113.94114&latitude=22.544577&accuracy=25&logLevel=info
|
||||
argument: longitude=113.94114&latitude=22.544577&accuracy=25&randomRadius=0&logLevel=info
|
||||
- match: ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save
|
||||
name: WLOC.Settings
|
||||
type: request
|
||||
@@ -26,8 +29,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?v=1.0.8
|
||||
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?v=1.0.8
|
||||
interval: 86400
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/* App display name */
|
||||
"CFBundleDisplayName" = "Location Spoofer";
|
||||
|
||||
/* Location permission prompts */
|
||||
"NSLocationWhenInUseUsageDescription" = "Used to display and verify whether the simulated location is active.";
|
||||
"NSLocationAlwaysAndWhenInUseUsageDescription" = "Used to display and verify whether the simulated location is active.";
|
||||
@@ -0,0 +1,703 @@
|
||||
/* ============================================================
|
||||
English localization overlay.
|
||||
Keys are the original Simplified-Chinese source strings
|
||||
(development language = zh-Hans). Any key not found here
|
||||
falls back to the Chinese key text automatically.
|
||||
============================================================ */
|
||||
|
||||
/* MARK: - Shared models (ProxyRuntimeMode / VerificationResult) */
|
||||
"APP模式" = "App Mode";
|
||||
"第三方代理模式" = "Third-party Proxy Mode";
|
||||
"成功" = "Success";
|
||||
"已有验证正在进行" = "A verification is already in progress";
|
||||
"验证已被新位置取代" = "Verification superseded by a new location";
|
||||
"证书未信任" = "Certificate not trusted";
|
||||
"WiFi代理未配置" = "Wi-Fi proxy not configured";
|
||||
"坐标写入失败" = "Failed to write coordinate";
|
||||
"改写验证失败" = "Rewrite verification failed";
|
||||
|
||||
/* MARK: - SettingsView */
|
||||
"运行模式" = "Runtime Mode";
|
||||
"模式" = "Mode";
|
||||
"状态" = "Status";
|
||||
"本机代理" = "Local Proxy";
|
||||
"第三方模块" = "Third-party Module";
|
||||
"检测连接" = "Test Connection";
|
||||
"虚拟定位" = "Simulated Location";
|
||||
"定位模拟" = "Location Simulation";
|
||||
"运动状态模拟" = "Motion State Simulation";
|
||||
"实验性功能,默认关闭。开启后会同时模拟定位响应中的运动状态。" = "Experimental, off by default. When enabled, the motion state in the location response is simulated too.";
|
||||
"随机扰动" = "Random Jitter";
|
||||
"开启后,下次同步坐标时会给目标点添加随机偏移(默认半径 50 米),避免位置固定在同一点。" = "When enabled, the next coordinate sync adds a random offset to the target point (default radius 50 m) so the location is not fixed at a single point.";
|
||||
"说明" = "Instructions";
|
||||
"工作原理" = "How It Works";
|
||||
"应用" = "App";
|
||||
"进入引导页" = "Open Setup Guide";
|
||||
"正在检查…" = "Checking…";
|
||||
"检查更新" = "Check for Updates";
|
||||
"版本" = "Version";
|
||||
"证书" = "Certificate";
|
||||
"重置证书" = "Reset Certificate";
|
||||
"仅删除 App 钥匙串中的设备 CA。iOS 中已经安装的旧证书需要在系统设置里手动移除。" = "Only removes the device CA from the app keychain. Any old certificate already installed in iOS must be removed manually in Settings.";
|
||||
"支持" = "Support";
|
||||
"使用帮助" = "Usage Help";
|
||||
"功能建议" = "Feature Request";
|
||||
"分享第三方配置" = "Share Third-party Config";
|
||||
"关于" = "About";
|
||||
"如果觉得好用,欢迎去 GitHub 给项目点个 Star" = "If you find it useful, please star the project on GitHub";
|
||||
"致谢" = "Acknowledgements";
|
||||
"核心定位改写逻辑移植自 Yu9191/wloc" = "Core location-rewrite logic ported from Yu9191/wloc";
|
||||
"设置" = "Settings";
|
||||
"完成" = "Done";
|
||||
"代理操作失败" = "Proxy Operation Failed";
|
||||
"重置证书?" = "Reset certificate?";
|
||||
"重置并生成新证书" = "Reset and Generate New Certificate";
|
||||
"当前虚拟定位和本地代理将停止。App 会删除钥匙串中的设备 CA、立即生成新证书,并打开安装与信任引导。你还需要前往 iOS「设置 → 通用 → VPN 与设备管理」手动删除旧证书,然后重新下载安装并完全信任新证书。" = "The current simulated location and local proxy will stop. The app deletes the device CA from the keychain, generates a new certificate immediately, and opens the install-and-trust guide. You must also go to iOS Settings → General → VPN & Device Management to manually delete the old certificate, then re-download, install, and fully trust the new one.";
|
||||
"第三方代理配置" = "Third-party Proxy Configuration";
|
||||
"客户端" = "Client";
|
||||
"使用国内镜像下载模块" = "Use domestic mirror to download modules";
|
||||
"仅影响之后复制和重新导入的模块地址;已安装模块需要重新导入后切换来源。" = "Only affects module URLs you copy or re-import afterward; installed modules must be re-imported to switch source.";
|
||||
"验证状态" = "Verification Status";
|
||||
"已复制模块订阅地址" = "Subscription URL copied";
|
||||
"复制模块订阅地址" = "Copy module subscription URL";
|
||||
"已复制解密域名" = "Decryption hostnames copied";
|
||||
"复制解密域名" = "Copy decryption hostnames";
|
||||
"打开 %@" = "Open %@";
|
||||
"重新打开配置引导" = "Reopen Setup Guide";
|
||||
"Egern 直接使用 Surge 的 .sgmodule 模块。" = "Egern uses Surge's .sgmodule module directly.";
|
||||
"Stash 直接订阅 .stoverride,不要通过 Script Hub 转换。" = "Stash subscribes to .stoverride directly; do not convert it via Script Hub.";
|
||||
"复制模块订阅地址后,在对应代理客户端中添加模块/重写订阅,并为复制的全部域名(含 gsp-ssl.ls.apple.com、bluedot.is.autonavi.com)启用 MITM。第三方客户端保存坐标后,即使关闭本 App,坐标仍由代理客户端持久化并继续生效。" = "After copying the subscription URL, add the module/rewrite subscription in your proxy client and enable MITM for all copied hostnames (including gsp-ssl.ls.apple.com and bluedot.is.autonavi.com). Once the third-party client saves the coordinate, it persists and stays in effect even after you close this app.";
|
||||
"已是最新版本" = "You are on the latest version";
|
||||
"当前版本 %@,远程最新版本 %@。" = "Current version %1$@, latest remote version %2$@.";
|
||||
"检查更新失败" = "Update Check Failed";
|
||||
"无法获取远程版本信息,请检查网络后重试。" = "Could not fetch remote version info. Check your network and try again.";
|
||||
"未检测" = "Not checked";
|
||||
"已连接,有坐标" = "Connected, coordinate present";
|
||||
"已连接,无坐标" = "Connected, no coordinate";
|
||||
"连接失败" = "Connection failed";
|
||||
"已开启" = "On";
|
||||
"已关闭" = "Off";
|
||||
"第三方已保存" = "Saved by third-party";
|
||||
"未保存" = "Not saved";
|
||||
"未知" = "Unknown";
|
||||
"App 只负责地图选点、收藏和发送 WGS-84 坐标。第三方代理客户端通过模块拦截 Apple WLOC 请求并持久化当前坐标;本模式不启动本机代理,不使用 App 的 CA,也不需要配置 127.0.0.1:8888。" = "The app only handles map selection, favorites, and sending WGS-84 coordinates. The third-party proxy client intercepts Apple WLOC requests through its module and persists the current coordinate. This mode does not start the local proxy, does not use the app's CA, and does not require configuring 127.0.0.1:8888.";
|
||||
"App 在设备本地运行一个代理服务器(127.0.0.1:8888)。\n\n通过 WiFi 手动代理配置,让系统发往 Apple 定位域名(gs-loc.apple.com、gsp-ssl.ls.apple.com、bluedot.is.autonavi.com 等)的定位请求经过这个本地代理。代理使用已安装的 CA 证书对 HTTPS 流量做中间人解密,把 Apple 返回的定位坐标改写为你设置的虚拟坐标,再加密返回给系统,从而实现虚拟定位。" = "The app runs a proxy server locally on the device (127.0.0.1:8888).\n\nA manual Wi-Fi proxy routes location requests sent to Apple location hostnames (gs-loc.apple.com, gsp-ssl.ls.apple.com, bluedot.is.autonavi.com, etc.) through this local proxy. Using the installed CA certificate, the proxy performs man-in-the-middle decryption of the HTTPS traffic, rewrites the location coordinate returned by Apple to the one you set, then re-encrypts and returns it to the system — producing the simulated location.";
|
||||
"模式已切换" = "Mode Switched";
|
||||
"第三方代理模式检测通过。请关闭 Wi-Fi 中的 127.0.0.1:8888 手动代理,避免双重拦截。" = "Third-party proxy mode check passed. Please turn off the 127.0.0.1:8888 manual proxy in Wi-Fi to avoid double interception.";
|
||||
"APP 模式环境检测通过。请停用第三方 WLOC 模块或代理连接,避免双重拦截。" = "App mode environment check passed. Please disable the third-party WLOC module or proxy connection to avoid double interception.";
|
||||
"证书重置失败" = "Certificate Reset Failed";
|
||||
"无法打开客户端" = "Could Not Open Client";
|
||||
"无法打开 %@,请确认客户端已安装后手动打开。" = "Could not open %@. Make sure the client is installed, then open it manually.";
|
||||
|
||||
/* MARK: - ContentView */
|
||||
"正在初始化地图与本地代理…" = "Initializing map and local proxy…";
|
||||
"正在初始化地图…" = "Initializing map…";
|
||||
"更新说明暂时无法加载,请前往最新 Release 页面查看。" = "Release notes could not be loaded. Please check the latest Release page.";
|
||||
"需要更新" = "Update Required";
|
||||
"当前版本 %@ 已停止支持,请更新到 %@ 后继续使用。\n\n%@" = "Version %1$@ is no longer supported. Please update to %2$@ to continue.\n\n%3$@";
|
||||
"立即更新" = "Update Now";
|
||||
"发现新版本" = "New Version Available";
|
||||
"当前版本 %@,最新版本 %@。\n\n%@" = "Current version %1$@, latest version %2$@.\n\n%3$@";
|
||||
"前往更新" = "Update";
|
||||
"稍后" = "Later";
|
||||
|
||||
/* MARK: - BugReportView */
|
||||
"遇到问题时,在这里生成 Bug 报告。系统会运行一次诊断测试,并将完整报告复制到剪切板。跳转到 GitHub 后,请粘贴到“App 生成的诊断报告”字段。" = "When you run into a problem, generate a bug report here. The app runs a diagnostic test and copies the full report to the clipboard. After opening GitHub, paste it into the “App-generated diagnostic report” field.";
|
||||
"可复现环境" = "Reproducible";
|
||||
"当前设备上问题稳定复现,非偶发性。" = "The issue reproduces reliably on this device and is not intermittent.";
|
||||
"问题描述" = "Problem Description";
|
||||
"正在生成报告…" = "Generating report…";
|
||||
"生成 Bug 报告" = "Generate Bug Report";
|
||||
"报告 Bug" = "Report a Bug";
|
||||
"已生成" = "Generated";
|
||||
"打开 GitHub 表单" = "Open GitHub Form";
|
||||
"稍后再说" = "Later";
|
||||
"Bug 报告已复制到剪切板。请在 GitHub 表单的“App 生成的诊断报告”字段中粘贴并提交。" = "The bug report has been copied to the clipboard. Paste it into the “App-generated diagnostic report” field of the GitHub form and submit.";
|
||||
|
||||
/* MARK: - DiagnosticsView */
|
||||
"过滤日志" = "Filter logs";
|
||||
"暂无运行日志" = "No runtime logs";
|
||||
"无匹配日志" = "No matching logs";
|
||||
"运行日志" = "Runtime Logs";
|
||||
"日志自动清理,仅保留近 3 天" = "Logs are cleared automatically; only the last 3 days are kept";
|
||||
"关闭" = "Close";
|
||||
"复制全部日志" = "Copy all logs";
|
||||
"清空全部日志" = "Clear all logs";
|
||||
"清空所有运行日志?" = "Clear all runtime logs?";
|
||||
"清空日志" = "Clear Logs";
|
||||
"取消" = "Cancel";
|
||||
"正在检测…" = "Checking…";
|
||||
"环境检测" = "Environment Check";
|
||||
"检查第三方模块能否拦截并响应 query 请求;不会写入测试坐标。" = "Checks whether the third-party module can intercept and respond to the query request; no test coordinates are written.";
|
||||
"依次检查:本地代理 → CA 证书信任 → Wi-Fi 代理链路。" = "Checks in order: local proxy → CA certificate trust → Wi-Fi proxy chain.";
|
||||
"测试日志" = "Test Log";
|
||||
"已复制" = "Copied";
|
||||
"代理运行中" = "Proxy running";
|
||||
"代理未运行" = "Proxy stopped";
|
||||
"可修改" = "Modifiable";
|
||||
"不可修改" = "Not modifiable";
|
||||
"环境检测通过" = "Environment check passed";
|
||||
"环境检测失败: %@" = "Environment check failed: %@";
|
||||
",查看下方日志" = ", see the log below";
|
||||
"第三方模块连接通过,已有坐标" = "Third-party module connected, coordinate present";
|
||||
"第三方模块连接通过,暂无坐标" = "Third-party module connected, no coordinate yet";
|
||||
"第三方模块连接失败" = "Third-party module connection failed";
|
||||
"======== 第三方代理连接检测 ========" = "======== Third-party Proxy Connection Check ========";
|
||||
"模式: 测试模式" = "Mode: Test mode";
|
||||
"请求: wloc-settings/save?action=query" = "Request: wloc-settings/save?action=query";
|
||||
"拦截响应: 有效 JSON" = "Intercepted response: Valid JSON";
|
||||
"已保存坐标: %@" = "Saved coordinate: %@";
|
||||
"结果: %@" = "Result: %@";
|
||||
|
||||
/* MARK: - TipViews */
|
||||
"生效说明" = "How to Activate";
|
||||
"失效说明" = "How to Deactivate";
|
||||
"关闭 WiFi 代理" = "Remove Wi-Fi Proxy";
|
||||
"知道了" = "Got It";
|
||||
"让虚拟定位生效" = "Make the simulated location take effect";
|
||||
"确认第三方代理已开启" = "Confirm the third-party proxy is on";
|
||||
"保持已导入的 WLOC 模块、HTTPS 解密和第三方代理/VPN 连接开启。" = "Keep the imported WLOC module, HTTPS decryption, and the third-party proxy/VPN connection enabled.";
|
||||
"开启飞行模式" = "Turn on Airplane Mode";
|
||||
"从控制中心打开飞行模式(点飞机图标),Wi‑Fi 会自动断开。这是为了清除 iOS 的定位缓存。等待 2 秒。" = "Open Control Center and enable Airplane Mode (tap the airplane icon); Wi-Fi disconnects automatically. This clears the iOS location cache. Wait 2 seconds.";
|
||||
"关闭 Wi‑Fi" = "Turn off Wi-Fi";
|
||||
"从控制中心再点一下 Wi‑Fi 图标,确认 Wi‑Fi 已关闭。等待 2 秒。" = "Tap the Wi-Fi icon in Control Center again and confirm Wi-Fi is off. Wait 2 seconds.";
|
||||
"关闭系统定位服务" = "Turn off Location Services";
|
||||
"打开系统「设置 → 隐私与安全性 → 定位服务」,关闭顶部的总开关。等待 2 秒。" = "Open Settings → Privacy & Security → Location Services and turn off the master switch at the top. Wait 2 seconds.";
|
||||
"打开 Wi‑Fi,启动虚拟定位" = "Turn on Wi-Fi and start the simulated location";
|
||||
"从控制中心打开 Wi‑Fi(飞行模式保持开启),确认第三方代理已连接。坐标已经同步到第三方代理。等待 2 秒。" = "Turn on Wi-Fi from Control Center (keep Airplane Mode on) and confirm the third-party proxy is connected. The coordinate is already synced to the third-party proxy. Wait 2 seconds.";
|
||||
"从控制中心打开 Wi‑Fi(飞行模式保持开启),进入 App 点底部「开始虚拟定位」。等待 2 秒。" = "Turn on Wi-Fi from Control Center (keep Airplane Mode on), then open the app and tap “Start Simulated Location” at the bottom. Wait 2 seconds.";
|
||||
"关闭飞行模式" = "Turn off Airplane Mode";
|
||||
"从控制中心关闭飞行模式。等待 2 秒。" = "Turn off Airplane Mode from Control Center. Wait 2 seconds.";
|
||||
"重新开启定位服务" = "Turn Location Services back on";
|
||||
"再次进入「设置 → 隐私与安全性 → 定位服务」,打开总开关。完成后打开地图验证定位是否已变化。" = "Open Settings → Privacy & Security → Location Services again and turn the master switch on. Then open a map app to verify the location has changed.";
|
||||
"还是无法生效?" = "Still not working?";
|
||||
"操作到第 3 步时关机重启,开机后从第 4 步继续。这样能彻底清除系统缓存的定位数据。" = "At step 3, power off and restart the device, then continue from step 4. This fully clears the system's cached location data.";
|
||||
"去设置" = "Open Settings";
|
||||
"取消虚拟定位" = "Cancel the simulated location";
|
||||
"从控制中心打开飞行模式,Wi‑Fi 会自动断开。等待 2 秒。" = "Enable Airplane Mode from Control Center; Wi-Fi disconnects automatically. Wait 2 seconds.";
|
||||
"从控制中心确认 Wi‑Fi 已关闭。等待 2 秒。" = "Confirm Wi-Fi is off from Control Center. Wait 2 seconds.";
|
||||
"打开「设置 → 隐私与安全性 → 定位服务」,关闭总开关。等待 2 秒。" = "Open Settings → Privacy & Security → Location Services and turn off the master switch. Wait 2 seconds.";
|
||||
"确认坐标已清除" = "Confirm the coordinate is cleared";
|
||||
"App 已通知第三方代理清除虚拟坐标。保持网络可用并等待 2 秒,让系统重新获取真实定位。" = "The app has told the third-party proxy to clear the simulated coordinate. Keep the network available and wait 2 seconds so the system can reacquire the real location.";
|
||||
"打开 Wi‑Fi,移除代理" = "Turn on Wi-Fi and remove the proxy";
|
||||
"从控制中心打开 Wi‑Fi。然后进入「设置 → 无线局域网 → 点 WiFi 右侧 (i) → HTTP 代理」,选择「关闭」后存储。等待 2 秒。" = "Turn on Wi-Fi from Control Center. Then go to Settings → Wi-Fi → tap the (i) next to your Wi-Fi → HTTP Proxy, choose Off, and save. Wait 2 seconds.";
|
||||
"再次进入「设置 → 隐私与安全性 → 定位服务」打开总开关。打开地图验证定位是否恢复。" = "Open Settings → Privacy & Security → Location Services again and turn the master switch on. Open a map app to verify the location is restored.";
|
||||
"还是无法取消?" = "Still can't cancel?";
|
||||
"操作到第 3 步时关机重启,开机后从第 4 步继续。" = "At step 3, power off and restart the device, then continue from step 4.";
|
||||
"移除代理配置" = "Remove the proxy configuration";
|
||||
"停止虚拟定位后,需要手动移除 WiFi 代理配置,否则可能无法上网。\n\n1. 打开「设置 → 无线局域网」\n2. 点击当前 WiFi 右侧 (i) 图标\n3. 找到「HTTP 代理」\n4. 选择「关闭」\n5. 点右上角「存储」" = "After stopping the simulated location, you must manually remove the Wi-Fi proxy configuration or you may lose internet access.\n\n1. Open Settings → Wi-Fi\n2. Tap the (i) icon next to your current Wi-Fi\n3. Find “HTTP Proxy”\n4. Choose “Off”\n5. Tap “Save” in the top-right corner";
|
||||
|
||||
/* MARK: - MapHomeView */
|
||||
"社区分享成功配置?" = "Share your working config with the community?";
|
||||
"去提交" = "Submit";
|
||||
"复制模板" = "Copy Template";
|
||||
"不再提示" = "Don't Show Again";
|
||||
"第三方客户端" = "Third-party client";
|
||||
"你正在使用 %@。点击“去提交”会先复制投稿模板,并在 App 内打开社区页面。采纳后将收录到 README,可选择是否匿名署名。" = "You are using %@. Tapping “Submit” first copies the submission template and opens the community page inside the app. Accepted submissions are added to the README; you can choose to stay anonymous.";
|
||||
"已复制投稿模板" = "Submission Template Copied";
|
||||
"如果 GitHub 登录或浏览器跳转后模板没有自动填充,可以直接粘贴。" = "If the template is not filled in automatically after you sign in to GitHub or the browser switches, just paste it manually.";
|
||||
"无法直接跳转" = "Cannot Open Directly";
|
||||
"定位失败" = "Location Failed";
|
||||
"打开设置" = "Open Settings";
|
||||
"无法获取当前定位,请检查定位服务是否已开启" = "Could not get the current location. Please check whether Location Services is enabled.";
|
||||
"编辑收藏名称" = "Edit Favorite Name";
|
||||
"名称" = "Name";
|
||||
"保存" = "Save";
|
||||
"修改收藏地点名称" = "Rename the favorite location";
|
||||
"搜索地点或坐标" = "Search a place or coordinates";
|
||||
"日志" = "Logs";
|
||||
"更多" = "More";
|
||||
"当前选点" = "Current Selection";
|
||||
"GCJ-02(国内)" = "GCJ-02 (Domestic)";
|
||||
"WGS-84(国际)" = "WGS-84 (Intl.)";
|
||||
"无法生效?" = "Not working?";
|
||||
"无法取消?" = "Can't cancel?";
|
||||
"已收藏,点击取消收藏" = "Favorited. Tap to remove.";
|
||||
"收藏当前选点" = "Favorite the current selection";
|
||||
"搜索或点击地图选点后,保存为收藏。" = "Search or tap the map to pick a point, then save it as a favorite.";
|
||||
"切换到此处" = "Switch Here";
|
||||
"同步到第三方代理" = "Sync to Third-party Proxy";
|
||||
"检测并同步中…" = "Checking and syncing…";
|
||||
"停止第三方虚拟定位" = "Stop Third-party Simulation";
|
||||
"开始虚拟定位" = "Start Simulated Location";
|
||||
"验证环境中…" = "Verifying environment…";
|
||||
"停止虚拟定位" = "Stop Simulated Location";
|
||||
"当前未连接可用的 Wi-Fi,请连接 Wi-Fi 后配置 127.0.0.1:8888 手动代理。" = "No usable Wi-Fi is connected. Connect to Wi-Fi, then configure the 127.0.0.1:8888 manual proxy.";
|
||||
"第三方代理查询失败" = "Third-party proxy query failed";
|
||||
"未命名" = "Unnamed";
|
||||
"没有找到相关地点" = "No matching places found";
|
||||
"虚拟定位已开启" = "Simulated Location On";
|
||||
"不再提醒" = "Don't Remind Me";
|
||||
"虚拟定位已关闭" = "Simulated Location Off";
|
||||
|
||||
/* MARK: - FirstSetupView */
|
||||
"选择模式" = "Choose Mode";
|
||||
"配置 Wi-Fi 代理" = "Configure Wi-Fi Proxy";
|
||||
"初始化 CA 证书" = "Initialize CA Certificate";
|
||||
"选择客户端" = "Choose Client";
|
||||
"导入并检测" = "Import & Test";
|
||||
"上一步" = "Back";
|
||||
"开始使用" = "Get Started";
|
||||
"操作失败" = "Operation Failed";
|
||||
"选择运行模式" = "Choose a Runtime Mode";
|
||||
"后续可在“设置 → 运行模式”中切换。两种模式不要同时拦截 WLOC 请求。" = "You can switch later in Settings → Runtime Mode. Do not intercept WLOC requests in both modes at the same time.";
|
||||
"仅 Wi-Fi" = "Wi-Fi only";
|
||||
"无外部依赖" = "No external deps";
|
||||
"App 在设备本地启动代理,通过当前 Wi-Fi 的手动 HTTP 代理改写定位响应。免费自签应用无法使用系统 VPN 的 Network Extension 能力,因此 APP模式不支持蜂窝网络,需要配置 Wi-Fi 代理并安装 App 生成的 CA。" = "The app starts a proxy locally on the device and rewrites location responses through a manual HTTP proxy on the current Wi-Fi. Free self-signed apps cannot use the system VPN's Network Extension, so App Mode does not support cellular; it requires configuring a Wi-Fi proxy and installing the CA the app generates.";
|
||||
"测试模式" = "Test mode";
|
||||
"App 负责选点,并通过 WLOC 配置接口查询和同步坐标;第三方代理客户端负责网络代理、模块拦截、MITM 和持久化。证书、VPN 与代理连接均由第三方客户端处理。" = "The app handles point selection and queries/syncs coordinates through the WLOC configuration endpoint; the third-party proxy client handles network proxying, module interception, MITM, and persistence. Certificates, VPN, and proxy connections are all handled by the third-party client.";
|
||||
"正在准备 APP模式本地服务…" = "Preparing App Mode local services…";
|
||||
"先配置 Wi-Fi 系统代理" = "First configure the Wi-Fi system proxy";
|
||||
"在当前 Wi-Fi 的详情页,将「HTTP 代理」设为「手动」:服务器填 127.0.0.1,端口填 8888。配置后点击下方「完成」。检测会自动判断是 Wi-Fi 代理还是证书信任有问题。" = "On the details page of your current Wi-Fi, set “HTTP Proxy” to “Manual”: server 127.0.0.1, port 8888. After configuring, tap “Done” below. The check automatically detects whether the Wi-Fi proxy or certificate trust is the problem.";
|
||||
"Wi-Fi 代理设置" = "Wi-Fi Proxy Settings";
|
||||
"1 选择手动,2 填写服务器 127.0.0.1,3 填写端口 8888。" = "1) Choose Manual, 2) enter server 127.0.0.1, 3) enter port 8888.";
|
||||
"复制地址" = "Copy Address";
|
||||
"打开 Wi-Fi 设置" = "Open Wi-Fi Settings";
|
||||
"第 1 步:下载证书" = "Step 1: Download the Certificate";
|
||||
"下载本机随机生成的 CA 根证书。私钥仅保存在此设备的钥匙串中,不会随证书文件导出。App 会弹出 Safari 下载页;出现配置描述文件下载提示时,选择「允许」。" = "Download the randomly generated CA root certificate for this device. The private key is stored only in this device's keychain and is never exported with the certificate file. The app opens a Safari download page; when prompted to download a configuration profile, choose “Allow”.";
|
||||
"打开下载页" = "Open Download Page";
|
||||
"无法准备证书下载页面,请查看诊断日志" = "Could not prepare the certificate download page. Please check the diagnostic log.";
|
||||
"第 2 步:安装证书" = "Step 2: Install the Certificate";
|
||||
"下载完成后打开系统「设置」。如果顶部显示「已下载描述文件」,点进去安装;否则进入「通用 → VPN 与设备管理」,找到 Location Spoofer CA 并完成安装。" = "After the download finishes, open Settings. If “Profile Downloaded” appears at the top, tap it to install; otherwise go to General → VPN & Device Management, find Location Spoofer CA, and install it.";
|
||||
"去安装" = "Install";
|
||||
"安装证书" = "Install Certificate";
|
||||
"1 在「VPN 与设备管理」中打开 Location Spoofer CA 描述文件并完成安装。" = "1) In VPN & Device Management, open the Location Spoofer CA profile and install it.";
|
||||
"第 3 步:信任证书" = "Step 3: Trust the Certificate";
|
||||
"安装后进入「设置 → 通用 → 关于本机 → 证书信任设置」,找到 Location Spoofer CA 并开启完全信任。iOS 保留钥匙串数据时,重装 App 会继续复用同一证书。" = "After installing, go to Settings → General → About → Certificate Trust Settings, find Location Spoofer CA, and enable full trust. As long as iOS keeps the keychain data, reinstalling the app reuses the same certificate.";
|
||||
"去信任" = "Trust";
|
||||
"信任证书" = "Trust Certificate";
|
||||
"1 在「证书信任设置」中为 Location Spoofer CA 开启完全信任。" = "1) In Certificate Trust Settings, enable full trust for Location Spoofer CA.";
|
||||
"已完成" = "Done";
|
||||
"已完成 ✓" = "Done ✓";
|
||||
"iOS 27 beta 6 起,系统已禁止对 gs-loc.apple.com 进行 MITM 拦截。该版本及之后的 beta 版本暂时无法使用本项目,等待后续适配方案。" = "Starting with iOS 27 beta 6, the system blocks MITM interception of gs-loc.apple.com. This project is temporarily unusable on that and later beta versions until a workaround is available.";
|
||||
"选择第三方代理客户端" = "Choose a Third-party Proxy Client";
|
||||
"除 Shadowrocket 外,当前客户端配置尚未完成真机验证,页面只提供模块导入入口和通用配置提醒。" = "Except for Shadowrocket, the current client configurations have not been fully verified on real devices; this page only provides a module import entry and general configuration reminders.";
|
||||
"第三方客户端适配说明" = "Third-party Client Integration Notes";
|
||||
"App 不连接远程坐标服务器,而是向 Apple 域名发起一个约定请求。第三方客户端需要在本机拦截该请求、保存 WGS-84 坐标并返回 JSON;定位模块再读取同一份数据,修改 Apple WLOC 响应。" = "The app does not connect to a remote coordinate server; instead it sends an agreed-upon request to an Apple hostname. The third-party client must intercept that request locally, store the WGS-84 coordinate, and return JSON; the location module then reads the same data and modifies the Apple WLOC response.";
|
||||
"配置接口" = "Configuration Endpoint";
|
||||
"查询:GET ?action=query\n保存:GET ?lon=<经度>&lat=<纬度>&acc=<精度>\n清除:GET ?action=clear" = "Query: GET ?action=query\nSave: GET ?lon=<lon>&lat=<lat>&acc=<accuracy>\nClear: GET ?action=clear";
|
||||
"返回格式" = "Response Format";
|
||||
"适配要求" = "Integration Requirements";
|
||||
"客户端需要支持请求脚本、持久化存储、HTTP 200 JSON 响应、Apple WLOC 响应脚本,以及 Apple 定位域名(gs-loc.apple.com、gsp-ssl.ls.apple.com、bluedot.is.autonavi.com 等)的 HTTPS 解密。保存接口和 WLOC 响应脚本必须读取同一份持久化数据。" = "The client must support request scripts, persistent storage, HTTP 200 JSON responses, an Apple WLOC response script, and HTTPS decryption for Apple location hostnames (gs-loc.apple.com, gsp-ssl.ls.apple.com, bluedot.is.autonavi.com, etc.). The save endpoint and the WLOC response script must read the same persistent data.";
|
||||
"检测到第三方代理连接异常,请检查模块、MITM 和代理连接后重新检测。" = "A third-party proxy connection problem was detected. Check the module, MITM, and proxy connection, then test again.";
|
||||
"第 1 步:导入 %@ 模块" = "Step 1: Import the %@ module";
|
||||
"复制 %@ 的模块订阅地址。" = "Copy the module subscription URL for %@.";
|
||||
"打开 Shadowrocket,进入“配置 → 模块”。" = "Open Shadowrocket and go to Config → Modules.";
|
||||
"打开 %@。" = "Open %@.";
|
||||
"进入 Shadowrocket 配置" = "Open Shadowrocket Config";
|
||||
"1 点击「模块」进入模块列表,2 可打开当前本地配置详情。" = "1) Tap “Modules” to open the module list, 2) open the current local config details.";
|
||||
"点击右上角“+”,粘贴模块订阅地址并导入,然后确认模块已启用。" = "Tap “+” in the top-right, paste the module subscription URL and import it, then confirm the module is enabled.";
|
||||
"导入 Shadowrocket 模块" = "Import Shadowrocket Module";
|
||||
"1 点击右上角加号导入模块,2 确认模块已启用。" = "1) Tap the plus in the top-right to import the module, 2) confirm the module is enabled.";
|
||||
"在 %@ 中导入刚才复制的模块订阅地址。" = "Import the module subscription URL you just copied into %@.";
|
||||
"第 2 步:完成 %@ 配置" = "Step 2: Finish configuring %@";
|
||||
"请在 %@ 中完成相应配置。" = "Complete the corresponding configuration in %@.";
|
||||
"配置时请复制下方全部解密域名(含 gsp-ssl.ls.apple.com、bluedot.is.autonavi.com)。" = "When configuring, copy all the decryption hostnames below (including gsp-ssl.ls.apple.com and bluedot.is.autonavi.com).";
|
||||
"第 2 步:配置 HTTPS 解密" = "Step 2: Configure HTTPS Decryption";
|
||||
"进入“配置 → 本地文件”,找到带黄点的配置,点击右侧 i 图标。" = "Go to Config → Local Files, find the config with the yellow dot, and tap the i icon on the right.";
|
||||
"进入“HTTPS 解密”,开启解密开关。" = "Open “HTTPS Decryption” and turn on the decryption switch.";
|
||||
"在域名列表中添加下方复制的全部解密域名。" = "Add all the decryption hostnames copied below to the hostname list.";
|
||||
"配置 HTTPS 解密" = "Configure HTTPS Decryption";
|
||||
"1 开启 HTTPS 解密,2 添加复制的全部解密域名,3 打开证书设置。" = "1) Turn on HTTPS Decryption, 2) add all copied decryption hostnames, 3) open certificate settings.";
|
||||
"按 Shadowrocket 提示生成并完成证书授权。" = "Follow Shadowrocket's prompts to generate and complete the certificate authorization.";
|
||||
"授权 Shadowrocket 证书" = "Authorize Shadowrocket Certificate";
|
||||
"1 打开 Shadowrocket 证书项并按提示安装、授权。" = "1) Open the Shadowrocket certificate item and install/authorize it as prompted.";
|
||||
"返回 HTTPS 解密页面,点击右上角勾号保存,然后开启代理。" = "Return to the HTTPS Decryption page, tap the checkmark in the top-right to save, then enable the proxy.";
|
||||
"打开 Shadowrocket 继续配置" = "Open Shadowrocket to Continue";
|
||||
"App 只能唤起 Shadowrocket,无法通过公开接口直接跳转到“模块”或“HTTPS 解密”页面。" = "The app can only launch Shadowrocket; it cannot jump directly to the “Modules” or “HTTPS Decryption” page through a public API.";
|
||||
"轻点查看大图" = "Tap to view full image";
|
||||
"证书尚未安装或信任" = "Certificate not installed or trusted";
|
||||
"Wi-Fi 代理未正确设置" = "Wi-Fi proxy is not set up correctly";
|
||||
"本地代理未能启动" = "The local proxy could not start";
|
||||
"检测仍在进行" = "The check is still running";
|
||||
"检测结果已过期" = "The check result is out of date";
|
||||
"定位改写检测失败" = "Location-rewrite check failed";
|
||||
"接口连接失败" = "Endpoint connection failed";
|
||||
|
||||
/* MARK: - SetupCoordinator */
|
||||
"本地代理初始化失败:%@" = "Local proxy initialization failed: %@";
|
||||
"✓ 定位环境正常" = "✓ Location environment OK";
|
||||
"CA 证书未安装或未信任" = "CA certificate not installed or not trusted";
|
||||
"Wi-Fi 代理未正确设置,请检查 127.0.0.1:8888" = "Wi-Fi proxy is not set up correctly. Please check 127.0.0.1:8888";
|
||||
|
||||
/* MARK: - SystemSettingsNavigator */
|
||||
"请手动打开「设置」,找到本 App 后检查定位权限。" = "Please open Settings manually, find this app, and check its location permission.";
|
||||
"请手动打开「设置 → 通用」。" = "Please open Settings → General manually.";
|
||||
"请手动打开「设置 → 无线局域网」,进入当前 Wi-Fi 的详情页。" = "Please open Settings → Wi-Fi manually and go to the details page of your current Wi-Fi.";
|
||||
"请手动打开「设置 → 隐私与安全性 → 定位服务」。" = "Please open Settings → Privacy & Security → Location Services manually.";
|
||||
|
||||
/* MARK: - ThirdPartyProxyManager */
|
||||
"第三方代理返回了无法识别的数据" = "The third-party proxy returned unrecognizable data";
|
||||
"请求未被第三方代理模块拦截,请检查模块、MITM 和代理连接" = "The request was not intercepted by the third-party proxy module. Check the module, MITM, and proxy connection.";
|
||||
"第三方代理保存的坐标与当前选点不一致" = "The coordinate saved by the third-party proxy does not match the current selection";
|
||||
"第三方代理请求失败:%@" = "Third-party proxy request failed: %@";
|
||||
"检查模块、MITM、证书和代理/VPN连接" = "Check the module, MITM, certificate, and proxy/VPN connection";
|
||||
"第三方代理拒绝保存坐标" = "The third-party proxy refused to save the coordinate";
|
||||
"第三方代理清除坐标失败" = "The third-party proxy failed to clear the coordinate";
|
||||
"已有第三方代理请求正在执行" = "A third-party proxy request is already running";
|
||||
"配置已提供,尚未验证" = "Configuration provided, not yet verified";
|
||||
"已连接,有保存坐标" = "Connected, coordinate saved";
|
||||
"已连接,无保存坐标" = "Connected, no saved coordinate";
|
||||
"连接失败(%@)" = "Connection failed (%@)";
|
||||
"当前客户端:%@" = "Current client: %@";
|
||||
"配置接口:/wloc-settings/save" = "Configuration endpoint: /wloc-settings/save";
|
||||
"请求动作:WLOC query" = "Request action: WLOC query";
|
||||
"检查范围:模块拦截、MITM、证书、代理/VPN 连接" = "Check scope: module interception, MITM, certificate, and proxy/VPN connection";
|
||||
"连接状态:%@" = "Connection status: %@";
|
||||
"检测结果:失败" = "Check result: Failed";
|
||||
"耗时:%lld ms" = "Duration: %lld ms";
|
||||
"错误类型:%@" = "Error type: %@";
|
||||
"错误详情:%@" = "Error details: %@";
|
||||
"处理建议:%@。" = "Suggested action: %@.";
|
||||
"======== 第三方代理运行检测 ========" = "======== Third-party Proxy Runtime Check ========";
|
||||
"触发来源:地图或设置中的第三方代理操作" = "Triggered by: third-party proxy action from the map or Settings";
|
||||
"请求动作:WLOC 配置接口" = "Request action: WLOC configuration endpoint";
|
||||
"处理建议:确认模块已启用,并检查 MITM、证书和代理/VPN 连接。" = "Suggested action: confirm the module is enabled, then check MITM, the certificate, and the proxy/VPN connection.";
|
||||
|
||||
/* MARK: - Exported diagnostics */
|
||||
"环境信息" = "Environment";
|
||||
"App 版本" = "App version";
|
||||
"系统版本" = "System version";
|
||||
"诊断日志" = "Diagnostic Logs";
|
||||
"不适用" = "Not applicable";
|
||||
"是" = "Yes";
|
||||
"否" = "No";
|
||||
"(无诊断数据)" = "(No diagnostic data)";
|
||||
"第三方代理测试模式:模块连接成功;已保存坐标=%@" = "Third-party proxy test mode: module connected; saved coordinate=%@";
|
||||
"第三方代理测试模式:模块连接失败;%@" = "Third-party proxy test mode: module connection failed; %@";
|
||||
"======== 代理验证测试 ========" = "======== Proxy Verification Test ========";
|
||||
"App 版本: %@" = "App version: %@";
|
||||
"系统版本: iOS %@" = "System version: iOS %@";
|
||||
"[步骤 A] 检查代理是否运行…" = "[Step A] Check whether the proxy is running…";
|
||||
" 端口: 127.0.0.1:8888" = " Port: 127.0.0.1:8888";
|
||||
" ⚠ 代理未运行,尝试启动…" = " ⚠ Proxy is not running; attempting to start it…";
|
||||
" ✗ 启动失败: %@" = " ✗ Failed to start: %@";
|
||||
" ✓ 代理启动成功" = " ✓ Proxy started";
|
||||
" ✓ 代理已在运行中" = " ✓ Proxy is already running";
|
||||
"[步骤 B] 检测证书与 WiFi 代理…" = "[Step B] Check certificate and Wi-Fi proxy…";
|
||||
" 方式: 请求 baidu.com/paopao-verify-<token>" = " Method: request baidu.com/paopao-verify-<token>";
|
||||
" 结果判定: TLS 错误=证书问题 / 响应不匹配=代理未配置 / 匹配=通过" = " Criteria: TLS error=certificate issue / response mismatch=proxy not configured / match=passed";
|
||||
" ✗ 无法生成验证 token" = " ✗ Could not generate a verification token";
|
||||
" ✓ 证书已信任,WiFi 代理已配置" = " ✓ Certificate is trusted and the Wi-Fi proxy is configured";
|
||||
" ✗ 响应不匹配: HTTP %lld, %lld bytes,WiFi 代理未配置" = " ✗ Response mismatch: HTTP %1$lld, %2$lld bytes; Wi-Fi proxy is not configured";
|
||||
" ✗ 请求失败 [%@ code=%lld]: %@" = " ✗ Request failed [%1$@ code=%2$lld]: %3$@";
|
||||
" TLS/证书校验失败,CA 证书未信任" = " TLS/certificate validation failed; the CA certificate is not trusted";
|
||||
"======== 环境检测通过 ✓ ========" = "======== Environment Check Passed ✓ ========";
|
||||
" --- 代理日志 ---" = " --- Proxy Logs ---";
|
||||
|
||||
/* MARK: - Setup examples and acknowledgements */
|
||||
"成功:{\"success\":true,\"longitude\":113.0,\"latitude\":22.0,\"accuracy\":25}\n失败:{\"success\":false,\"error\":\"错误说明\"}" = "Success: {\"success\":true,\"longitude\":113.0,\"latitude\":22.0,\"accuracy\":25}\nFailure: {\"success\":false,\"error\":\"Error details\"}";
|
||||
"定位改写脚本源自 Yu9191/wloc,现由本项目维护" = "Location-rewrite scripts originated from Yu9191/wloc and are now maintained by this project";
|
||||
|
||||
/* MARK: - Runtime log categories and messages */
|
||||
"地图" = "Map";
|
||||
"坐标转换" = "Coordinate Conversion";
|
||||
"定位" = "Location";
|
||||
"实时定位" = "Live Location";
|
||||
"提醒" = "Reminder";
|
||||
"搜索" = "Search";
|
||||
"收藏" = "Favorites";
|
||||
"缩放" = "Zoom";
|
||||
"========== App 启动 ==========" = "========== App Launch ==========";
|
||||
"尚未选择运行模式,跳过本地 CA 和代理初始化" = "No runtime mode selected; skipping local CA and proxy initialization";
|
||||
"第三方代理测试模式:跳过本地 CA、代理和环境检测" = "Third-party proxy test mode: skipping local CA, proxy, and environment checks";
|
||||
"旧坐标数据迁移失败,将在下次启动重试" = "Legacy coordinate migration failed; it will be retried on the next launch";
|
||||
"地图坐标标准初始化完成,开始后续启动流程" = "Map coordinate-system initialization completed; continuing app startup";
|
||||
"没有持久化图钉,地图创建前请求实时定位" = "No persisted pin; requesting live location before creating the map";
|
||||
"已使用实时定位准备唯一初始地图状态" = "Prepared the initial map state using live location";
|
||||
"地图创建前取得的初始实时位置(WGS-84)" = "Initial live location obtained before map creation (WGS-84)";
|
||||
"地图创建前无法取得实时定位,唯一初始位置使用深圳" = "Could not obtain live location before map creation; using Shenzhen as the initial location";
|
||||
"已找到持久化图钉,直接准备唯一初始地图状态" = "Found a persisted pin and prepared the initial map state";
|
||||
"启动门禁全部完成,现在创建 MapHomeView" = "All startup gates completed; creating MapHomeView";
|
||||
"初始化" = "Initialize";
|
||||
"启动代理 127.0.0.1:8888" = "Starting proxy at 127.0.0.1:8888";
|
||||
"启动代理: 恢复上次 WGS-84 定位" = "Starting proxy: restoring the last WGS-84 location";
|
||||
"启动成功" = "Started successfully";
|
||||
"启动失败" = "Failed to start";
|
||||
"写入坐标" = "Write coordinate";
|
||||
"跳过过期坐标写入" = "Skipped stale coordinate write";
|
||||
"跳过旧验证坐标恢复" = "Skipped restoring coordinates from an outdated verification";
|
||||
"运动状态模拟设置已更新" = "Motion simulation setting updated";
|
||||
"准备证书下载失败" = "Failed to prepare certificate download";
|
||||
"设置虚拟定位坐标" = "Set simulated-location coordinate";
|
||||
"apply失败" = "Apply failed";
|
||||
"保存收藏失败" = "Failed to save favorite";
|
||||
"本地服务初始化失败" = "Local service initialization failed";
|
||||
"代理运行模式已切换" = "Proxy runtime mode changed";
|
||||
"已迁移旧版模式初始化状态" = "Migrated legacy mode initialization state";
|
||||
"地图坐标标准检测已有请求进行中" = "A map coordinate-system check is already running";
|
||||
"地图坐标标准检测开始" = "Map coordinate-system check started";
|
||||
"地图坐标标准检测获得明确结果" = "Map coordinate-system check returned a definitive result";
|
||||
"地图坐标标准检测不可用,开始实时定位兜底" = "Map coordinate-system check unavailable; starting live-location fallback";
|
||||
"地图坐标标准检测使用兜底结果" = "Map coordinate-system check used the fallback result";
|
||||
"地图坐标标准检测被取消,保留默认国内标准" = "Map coordinate-system check was cancelled; keeping the default mainland-China system";
|
||||
"地图坐标标准已确定,允许创建地图" = "Map coordinate system determined; map creation is now allowed";
|
||||
"地图坐标标准运行期检测合并到进行中请求" = "Runtime map coordinate-system check joined the request already in progress";
|
||||
"地图坐标标准运行期检测开始" = "Runtime map coordinate-system check started";
|
||||
"地图坐标标准运行期检测结果已过期,取消写入" = "Runtime map coordinate-system result became stale; discarding it";
|
||||
"地图坐标标准运行期检测完成,标准未变化" = "Runtime map coordinate-system check completed with no change";
|
||||
"地图坐标标准运行期检测发现切换" = "Runtime map coordinate-system check detected a change";
|
||||
"地图坐标标准运行期检测失败,保留当前标准" = "Runtime map coordinate-system check failed; keeping the current system";
|
||||
"地图坐标标准运行期检测已取消" = "Runtime map coordinate-system check cancelled";
|
||||
"实时定位不覆盖固定锚点的明确检测结果" = "Live location did not override the definitive fixed-anchor result";
|
||||
"实时定位确认兜底地图坐标标准无需修正" = "Live location confirmed that the fallback map coordinate system needs no correction";
|
||||
"实时定位修正启动兜底地图坐标标准" = "Live location corrected the startup fallback map coordinate system";
|
||||
"第三方代理已保存 WGS-84 坐标" = "Third-party proxy saved the WGS-84 coordinate";
|
||||
"第三方代理坐标已清除" = "Third-party proxy coordinate cleared";
|
||||
"第三方代理请求失败" = "Third-party proxy request failed";
|
||||
"第三方代理坐标同步成功" = "Third-party proxy coordinate synchronized";
|
||||
"同步坐标到第三方客户端失败" = "Failed to synchronize the coordinate to the third-party client";
|
||||
"验证结果" = "Verification result";
|
||||
"验证失败" = "Verification failed";
|
||||
"开启前检测失败,进入对应环境引导" = "Pre-start check failed; opening the relevant setup guide";
|
||||
"清除第三方客户端坐标失败" = "Failed to clear the coordinate from the third-party client";
|
||||
"累计虚拟定位操作次数" = "Counted simulated-location operations";
|
||||
"已复制坐标" = "Coordinate copied";
|
||||
"地图坐标标准切换时未找到当前选点缓存" = "No current-selection cache was found when the map coordinate system changed";
|
||||
"地图坐标标准切换后已使用缓存坐标对回显当前选点" = "Restored the current selection from cached coordinates after the map coordinate system changed";
|
||||
"地图坐标类型已变化" = "Map coordinate type changed";
|
||||
"取消保存收藏:检测期间当前选点已变化" = "Cancelled saving favorite because the current selection changed during detection";
|
||||
"保存当前选点为收藏" = "Saving the current selection as a favorite";
|
||||
"第三方代理查询返回失败" = "Third-party proxy query returned a failure";
|
||||
"启动后第三方代理状态查询失败" = "Failed to query third-party proxy status after startup";
|
||||
"检测到 Wi-Fi 网络变化" = "Detected a Wi-Fi network change";
|
||||
"网络仍在变化,重新计算环境检测等待时间" = "Network is still changing; recalculating the environment-check delay";
|
||||
"等待 Wi-Fi 连接稳定后检测" = "Waiting for the Wi-Fi connection to stabilize before checking";
|
||||
"延时检测已被更新的网络事件取消" = "Delayed check was cancelled by a newer network event";
|
||||
"稳定等待结束后仍未连接 Wi-Fi,提示检查代理" = "Wi-Fi is still disconnected after the stability delay; prompting the user to check the proxy";
|
||||
"开始后台环境检测" = "Starting background environment check";
|
||||
"已有环境检测运行,1 秒后重试" = "An environment check is already running; retrying in 1 second";
|
||||
"后台环境检测完成" = "Background environment check completed";
|
||||
"环境检测连续被占用,本次不重复弹窗" = "Environment check remained busy; suppressing a duplicate alert";
|
||||
"用户点击实时定位" = "User requested live location";
|
||||
"MapKit 蓝点尚不可用,启动 CLLocationManager 兜底" = "MapKit user location is unavailable; starting the CLLocationManager fallback";
|
||||
"MapKit 蓝点抢先完成请求,取消 CLLocationManager 兜底" = "MapKit completed the request first; cancelling the CLLocationManager fallback";
|
||||
"虚拟定位开启后的 MapKit 蓝点标准判定" = "Determined the MapKit user-location coordinate system after simulated location started";
|
||||
"登记实时定位请求上下文" = "Registered live-location request context";
|
||||
"复用进行中的 CLLocationManager 请求并更新意图上下文" = "Reusing the active CLLocationManager request and updating its intent context";
|
||||
"CLLocationManager 兜底未返回坐标" = "CLLocationManager fallback returned no coordinate";
|
||||
"丢弃 CLLocationManager 结果:任务已取消或蓝点已抢先完成" = "Discarded CLLocationManager result because the task was cancelled or MapKit completed first";
|
||||
"实时定位坐标完成标准判断并提交到地图" = "Live-location coordinate system determined and submitted to the map";
|
||||
"MKLocalSearch 返回地点结果" = "MKLocalSearch returned a place result";
|
||||
"反向地理编码网络失败,准备重试" = "Reverse geocoding network request failed; preparing to retry";
|
||||
"反向地理编码失败" = "Reverse geocoding failed";
|
||||
"获得搜索结果" = "Received search results";
|
||||
"点击收藏点并回显到地图" = "Selected a favorite and displayed it on the map";
|
||||
"忽略重复 CLLocationManager 请求" = "Ignored duplicate CLLocationManager request";
|
||||
"CLLocationManager 请求入口" = "CLLocationManager request entry";
|
||||
"授权状态不允许定位" = "Authorization status does not permit location access";
|
||||
"创建 CLLocationManager 请求" = "Created CLLocationManager request";
|
||||
"请求前台定位授权" = "Requesting foreground location authorization";
|
||||
"定位授权状态变化" = "Location authorization status changed";
|
||||
"CLLocationManager 返回样本批次" = "CLLocationManager returned a batch of samples";
|
||||
"本批次没有可完成当前请求的样本" = "This batch contains no sample that can complete the current request";
|
||||
"接受 CLLocationManager 样本并完成请求" = "Accepted CLLocationManager sample and completed the request";
|
||||
"定位回调失败" = "Location callback failed";
|
||||
"跳过 CLLocationManager 缓存样本" = "Skipped cached CLLocationManager sample";
|
||||
"等待定位授权超时" = "Timed out waiting for location authorization";
|
||||
"单次定位超时,切换持续定位" = "One-shot location timed out; switching to continuous updates";
|
||||
"持续定位超时" = "Continuous location updates timed out";
|
||||
"开始 CLLocationManager 单次定位" = "Starting one-shot CLLocationManager request";
|
||||
"开始 CLLocationManager 持续定位兜底" = "Starting continuous CLLocationManager fallback";
|
||||
"CLLocationManager 请求完成" = "CLLocationManager request completed";
|
||||
"CLLocationManager 请求结束但没有坐标" = "CLLocationManager request ended without a coordinate";
|
||||
"使用 CLLocationManager 新鲜缓存" = "Using a fresh CLLocationManager cache entry";
|
||||
"实时定位按钮直接使用 MapKit 蓝点缓存" = "Live-location action used the MapKit user-location cache directly";
|
||||
"主页收到待处理请求所需的 MapKit 蓝点回调" = "Home screen received the MapKit user-location callback for the pending request";
|
||||
"主页收到 CLLocationManager 兜底坐标" = "Home screen received the CLLocationManager fallback coordinate";
|
||||
"原始实时定位坐标" = "Original live-location coordinate";
|
||||
"检查 CLLocationManager 样本" = "Checking CLLocationManager sample";
|
||||
"拒绝 CLLocationManager 样本:坐标或精度无效" = "Rejected CLLocationManager sample because its coordinate or accuracy is invalid";
|
||||
"MapKit 蓝点更新但 location 为空" = "MapKit user location updated but its location value is empty";
|
||||
"拒绝 MapKit 蓝点样本:坐标无效" = "Rejected MapKit user-location sample because its coordinate is invalid";
|
||||
"拒绝 MapKit 蓝点样本:水平精度无效" = "Rejected MapKit user-location sample because horizontal accuracy is invalid";
|
||||
"音频中断恢复" = "Recovered from audio interruption";
|
||||
"音频会话失败" = "Audio session failed";
|
||||
"无法创建静音音频缓冲区" = "Could not create the silent-audio buffer";
|
||||
"引擎启动失败" = "Audio engine failed to start";
|
||||
"后台保活已启动(静音音频)" = "Background keep-alive started (silent audio)";
|
||||
"后台保活已停止" = "Background keep-alive stopped";
|
||||
"保存当前图钉失败" = "Failed to save the current pin";
|
||||
"存储缩放" = "Stored zoom level";
|
||||
"旧坐标数据迁移完成" = "Legacy coordinate migration completed";
|
||||
"远程版本配置加载成功" = "Remote version configuration loaded";
|
||||
"版本配置源不可用,尝试下一地址" = "Version configuration source unavailable; trying the next URL";
|
||||
"版本配置加载失败,继续使用内置配置" = "Failed to load version configuration; continuing with built-in values";
|
||||
"版本说明源不可用,尝试下一地址" = "Release-notes source unavailable; trying the next URL";
|
||||
"版本说明加载失败,将使用最新 Release 页面" = "Failed to load release notes; using the latest Release page";
|
||||
"复用设备钥匙串中的 CA" = "Reusing the CA from the device keychain";
|
||||
"旧 CA 已迁移到设备钥匙串" = "Legacy CA migrated to the device keychain";
|
||||
"已生成并保存设备专属 CA" = "Generated and saved a device-specific CA";
|
||||
"已删除设备 CA,等待重新生成" = "Deleted the device CA; waiting to regenerate it";
|
||||
"钥匙串中的 CA 无效,准备回退" = "CA in the keychain is invalid; preparing fallback";
|
||||
"删除旧 CA 文件失败,将在下次启动重试" = "Failed to delete legacy CA files; it will be retried on the next launch";
|
||||
"无法解析 CA 证书 PEM" = "Could not parse the CA certificate PEM";
|
||||
"无法创建 SecTrust" = "Could not create SecTrust";
|
||||
"SecTrust 评估返回错误" = "SecTrust evaluation returned an error";
|
||||
"CA 证书已被系统信任" = "CA certificate is trusted by the system";
|
||||
"CA 证书未被系统信任" = "CA certificate is not trusted by the system";
|
||||
"调用 Go Core 生成 CA" = "Calling Go Core to generate the CA";
|
||||
"Go Core CA 生成成功" = "Go Core generated the CA";
|
||||
"本地证书服务已在运行" = "Local certificate server is already running";
|
||||
"调用 Go Core 启动本地证书服务" = "Calling Go Core to start the local certificate server";
|
||||
"本地证书服务启动成功" = "Local certificate server started";
|
||||
"停止本地证书服务" = "Stopping local certificate server";
|
||||
"开始第三方代理连接检测" = "Starting third-party proxy connection check";
|
||||
"第三方代理连接检测通过" = "Third-party proxy connection check passed";
|
||||
"第三方代理连接检测失败" = "Third-party proxy connection check failed";
|
||||
"关闭代理前已同步关闭虚拟定位" = "Stopped simulated location before stopping the proxy";
|
||||
"切换 APP 模式前无法清除第三方坐标" = "Could not clear the third-party coordinate before switching to App Mode";
|
||||
"设置页第三方连接检测通过" = "Third-party connection check passed in Settings";
|
||||
"设置页第三方连接检测失败" = "Third-party connection check failed in Settings";
|
||||
"地图实际显示坐标" = "Coordinate displayed on map";
|
||||
|
||||
/* MARK: - Runtime log fields and values */
|
||||
"App回到前台" = "App returned to foreground";
|
||||
"App已确认地图标准" = "App confirmed the map coordinate system";
|
||||
"CLLocationManager请求中" = "CLLocationManager request active";
|
||||
"Go proxy 启动失败" = "Go proxy failed to start";
|
||||
"MapKit地图蓝点" = "MapKit user location";
|
||||
"MapKit蓝点新样本" = "New MapKit user-location sample";
|
||||
"MapKit蓝点缓存" = "MapKit user-location cache";
|
||||
"SSID可读取" = "SSID readable";
|
||||
"WLOC写入标准" = "WLOC write coordinate system";
|
||||
"WLOC目标标准" = "WLOC target coordinate system";
|
||||
"Wi-Fi接口" = "Wi-Fi interface";
|
||||
"intent选点revision" = "Intent selection revision";
|
||||
"manager请求中" = "Manager request active";
|
||||
"上下文存在" = "Context present";
|
||||
"事件原因" = "Event reason";
|
||||
"任务已取消" = "Task cancelled";
|
||||
"任务已存在" = "Task already present";
|
||||
"使用兜底" = "Used fallback";
|
||||
"保存收藏" = "Save favorite";
|
||||
"保存数据包含" = "Saved data contains";
|
||||
"保持未启用" = "Kept disabled";
|
||||
"保留原第三方坐标" = "Kept previous third-party coordinate";
|
||||
"保留已启用状态" = "Kept enabled state";
|
||||
"保留标准" = "Retained coordinate system";
|
||||
"修正兜底标准" = "Corrected fallback coordinate system";
|
||||
"内存缓存存在" = "In-memory cache present";
|
||||
"初始坐标来源" = "Initial coordinate source";
|
||||
"初始来源" = "Initial source";
|
||||
"初始阶段" = "Initial phase";
|
||||
"判定规则" = "Decision rule";
|
||||
"原兜底来源" = "Original fallback source";
|
||||
"原因" = "Reason";
|
||||
"取值字段" = "Source field";
|
||||
"可显示不再提醒" = "Can show suppress option";
|
||||
"启动代理…" = "Starting proxy…";
|
||||
"命中林士街" = "Matched Lin Shi Street";
|
||||
"固定锚点" = "Fixed anchor";
|
||||
"固定锚点查询超过5秒" = "Fixed-anchor query exceeded 5 seconds";
|
||||
"固定锚点查询返回空结果" = "Fixed-anchor query returned no results";
|
||||
"国内标准(GCJ-02)" = "Mainland China system (GCJ-02)";
|
||||
"国内转换区域" = "Mainland conversion region";
|
||||
"国外非转换区域" = "Non-conversion region outside mainland China";
|
||||
"国际标准(WGS-84)" = "International system (WGS-84)";
|
||||
"国际标准(WGS-84)+国内标准(GCJ-02)" = "International (WGS-84) + mainland China (GCJ-02)";
|
||||
"图钉已按新类型重设" = "Pin reset for the new type";
|
||||
"地图取值字段" = "Map source field";
|
||||
"地图标准" = "Map coordinate system";
|
||||
"坐标有效" = "Coordinate valid";
|
||||
"坐标标准" = "Coordinate system";
|
||||
"垂直精度米" = "Vertical accuracy (m)";
|
||||
"基础校验通过" = "Basic validation passed";
|
||||
"处理建议" = "Suggested action";
|
||||
"失败时提示" = "Show alert on failure";
|
||||
"实时定位存在" = "Live location present";
|
||||
"实时定位服务区域" = "Live-location service region";
|
||||
"客户端模式" = "Client mode";
|
||||
"尝试" = "Attempt";
|
||||
"已取消位置更新" = "Location updates cancelled";
|
||||
"已恢复真实定位" = "Real location restored";
|
||||
"已清理" = "Cleared";
|
||||
"已重置" = "Reset";
|
||||
"开启" = "Enable";
|
||||
"异步地理编码" = "Asynchronous geocoding";
|
||||
"当前地图标准" = "Current map coordinate system";
|
||||
"当前客户端" = "Current client";
|
||||
"当前标准" = "Current coordinate system";
|
||||
"当前选点revision" = "Current selection revision";
|
||||
"恢复状态" = "Restore state";
|
||||
"批次索引" = "Batch index";
|
||||
"持久化字段" = "Persisted field";
|
||||
"授权状态" = "Authorization status";
|
||||
"授权状态rawValue" = "Authorization status raw value";
|
||||
"探测失败原因" = "Probe failure reason";
|
||||
"搜索结果" = "Search results";
|
||||
"操作" = "Operation";
|
||||
"新类型" = "New type";
|
||||
"无已保存" = "None saved";
|
||||
"无法判定" = "Undetermined";
|
||||
"无法启动本地证书服务" = "Could not start the local certificate server";
|
||||
"无法生成本地证书" = "Could not generate the local certificate";
|
||||
"日志策略" = "Logging policy";
|
||||
"旧类型" = "Previous type";
|
||||
"显示坐标字段" = "Displayed coordinate field";
|
||||
"最低版本" = "Minimum version";
|
||||
"最新版本" = "Latest version";
|
||||
"最终标准" = "Final coordinate system";
|
||||
"最终阶段" = "Final phase";
|
||||
"有坐标" = "Coordinate present";
|
||||
"有效样本数" = "Valid sample count";
|
||||
"有缓存" = "Cache present";
|
||||
"未知错误" = "Unknown error";
|
||||
"本地 CA 证书或私钥无效" = "Local CA certificate or private key is invalid";
|
||||
"无法写入设备钥匙串(%d)" = "Could not write to the device keychain (%d)";
|
||||
"来源" = "Source";
|
||||
"来源类型" = "Source type";
|
||||
"林士街" = "Lin Shi Street";
|
||||
"查看诊断日志" = "View diagnostic logs";
|
||||
"样本年龄秒" = "Sample age (s)";
|
||||
"样本数" = "Sample count";
|
||||
"样本时间" = "Sample time";
|
||||
"检查范围" = "Check scope";
|
||||
"检测前标准" = "Coordinate system before check";
|
||||
"模块拦截、MITM、代理/VPN连接" = "Module interception, MITM, and proxy/VPN connection";
|
||||
"次数" = "Count";
|
||||
"每次开启首次或判定变化" = "First sample after each start or classification change";
|
||||
"水平精度米" = "Horizontal accuracy (m)";
|
||||
"活动requestID" = "Active request ID";
|
||||
"活动阶段" = "Active phase";
|
||||
"海拔米" = "Altitude (m)";
|
||||
"满足当前请求时间窗" = "Within current request time window";
|
||||
"点击实时定位" = "Live-location action";
|
||||
"目标所在区域" = "Target region";
|
||||
"确认标准" = "Confirmed coordinate system";
|
||||
"社区征集客户端数" = "Clients requesting community contributions";
|
||||
"等待秒数" = "Wait duration (s)";
|
||||
"系统缓存存在" = "System cache present";
|
||||
"结果" = "Result";
|
||||
"结果数" = "Result count";
|
||||
"结果来源" = "Result source";
|
||||
"缓存" = "Cache";
|
||||
"缓存上限秒" = "Cache limit (s)";
|
||||
"缓存时效通过" = "Cache freshness valid";
|
||||
"缩放米" = "Zoom (m)";
|
||||
"网络可用" = "Network available";
|
||||
"耗时毫秒" = "Duration (ms)";
|
||||
"蓝点回调更接近" = "User-location callback is closer";
|
||||
"蓝点缓存" = "User-location cache";
|
||||
"蓝点缓存存在" = "User-location cache present";
|
||||
"蓝点距GCJ目标米" = "User-location distance to GCJ target (m)";
|
||||
"蓝点距WGS目标米" = "User-location distance to WGS target (m)";
|
||||
"触发原因" = "Trigger reason";
|
||||
"诊断位置" = "Diagnostic location";
|
||||
"请求动作" = "Request action";
|
||||
"超时毫秒" = "Timeout (ms)";
|
||||
"输入坐标标准" = "Input coordinate system";
|
||||
"连接状态" = "Connection status";
|
||||
"选点revision" = "Selection revision";
|
||||
"选点期间发生变化" = "Selection changed during operation";
|
||||
"错误" = "Error";
|
||||
"错误类型" = "Error type";
|
||||
"锚点" = "Anchor";
|
||||
"阶段" = "Phase";
|
||||
"首条名称" = "First result name";
|
||||
"首条名称=林士街→GCJ-02,否则→WGS-84" = "First name=Lin Shi Street → GCJ-02; otherwise → WGS-84";
|
||||
"默认国内标准" = "Default mainland-China coordinate system";
|
||||
|
||||
/* MARK: - ProxyManager */
|
||||
"证书下载地址无效" = "Invalid certificate download URL";
|
||||
"启动代理失败: %@" = "Failed to start proxy: %@";
|
||||
@@ -0,0 +1,6 @@
|
||||
/* App display name */
|
||||
"CFBundleDisplayName" = "Location Spoofer";
|
||||
|
||||
/* Location permission prompts */
|
||||
"NSLocationWhenInUseUsageDescription" = "用于显示和验证虚拟定位是否生效";
|
||||
"NSLocationAlwaysAndWhenInUseUsageDescription" = "用于显示和验证虚拟定位是否生效";
|
||||
@@ -27,6 +27,16 @@ fi
|
||||
|
||||
mkdir -p build/UnsignedIPA/Payload dist
|
||||
ditto "$APP" "build/UnsignedIPA/Payload/PaopaoLocationSpoofer.app"
|
||||
BUILD_TIMESTAMP="${BUILD_TIMESTAMP:-$(date '+%Y%m%d-%H%M%S')}"
|
||||
case "$BUILD_TIMESTAMP" in
|
||||
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) ;;
|
||||
*) echo "BUILD_TIMESTAMP must use yyyyMMdd-HHmmss format" >&2; exit 2 ;;
|
||||
esac
|
||||
IPA="$ROOT/dist/PaopaoLocationSpoofer-unsigned.ipa"
|
||||
TIMESTAMPED_IPA="$ROOT/dist/PaopaoLocationSpoofer-${BUILD_TIMESTAMP}-unsigned.ipa"
|
||||
rm -f "$IPA"
|
||||
cd build/UnsignedIPA
|
||||
zip -qry "$ROOT/dist/PaopaoLocationSpoofer-unsigned.ipa" Payload
|
||||
echo "Output: dist/PaopaoLocationSpoofer-unsigned.ipa"
|
||||
zip -qry "$IPA" Payload
|
||||
ditto "$IPA" "$TIMESTAMPED_IPA"
|
||||
echo "Output: $IPA"
|
||||
echo "Timestamped output: $TIMESTAMPED_IPA"
|
||||
|
||||
@@ -44,6 +44,80 @@ enum WlocSettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class MotionSimulationStore: ObservableObject {
|
||||
static let shared = MotionSimulationStore()
|
||||
|
||||
private enum Key {
|
||||
static let enabled = "motionSimulation.enabled"
|
||||
}
|
||||
|
||||
@Published private(set) var isEnabled: Bool
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
self.defaults = defaults
|
||||
isEnabled = defaults.bool(forKey: Key.enabled)
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool) {
|
||||
isEnabled = enabled
|
||||
defaults.set(enabled, forKey: Key.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class RandomRadiusStore: ObservableObject {
|
||||
static let shared = RandomRadiusStore()
|
||||
|
||||
private enum Key {
|
||||
static let isEnabled = "randomRadius.isEnabled"
|
||||
static let radius = "randomRadius.radius"
|
||||
}
|
||||
|
||||
@Published private(set) var isEnabled: Bool
|
||||
@Published private(set) var radius: Double
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
self.defaults = defaults
|
||||
isEnabled = defaults.bool(forKey: Key.isEnabled)
|
||||
radius = defaults.object(forKey: Key.radius) as? Double ?? 50
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool) {
|
||||
isEnabled = enabled
|
||||
defaults.set(enabled, forKey: Key.isEnabled)
|
||||
}
|
||||
|
||||
func setRadius(_ radius: Double) {
|
||||
self.radius = radius
|
||||
defaults.set(radius, forKey: Key.radius)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ThirdPartyModuleSourceStore: ObservableObject {
|
||||
static let shared = ThirdPartyModuleSourceStore()
|
||||
|
||||
private enum Key {
|
||||
static let useMirror = "thirdPartyModule.useMirror"
|
||||
}
|
||||
|
||||
@Published private(set) var useMirror: Bool
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
self.defaults = defaults
|
||||
useMirror = defaults.object(forKey: Key.useMirror) as? Bool ?? true
|
||||
}
|
||||
|
||||
func setUseMirror(_ enabled: Bool) {
|
||||
useMirror = enabled
|
||||
defaults.set(enabled, forKey: Key.useMirror)
|
||||
}
|
||||
}
|
||||
|
||||
enum VirtualLocationTipKind: Equatable {
|
||||
case activation
|
||||
case deactivation
|
||||
@@ -116,3 +190,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.7",
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,8 @@ enum CertificateAuthorityStoreError: LocalizedError {
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidAuthority: return "本地 CA 证书或私钥无效"
|
||||
case let .keychain(status): return "无法写入设备钥匙串(\(status))"
|
||||
case .invalidAuthority: return String(localized: "本地 CA 证书或私钥无效")
|
||||
case let .keychain(status): return String(localized: "无法写入设备钥匙串(\(status))")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ enum CoreBridgeError: LocalizedError {
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .generationFailed: return "无法生成本地证书"
|
||||
case .serverStartFailed: return "无法启动本地证书服务"
|
||||
case .generationFailed: return String(localized: "无法生成本地证书")
|
||||
case .serverStartFailed: return String(localized: "无法启动本地证书服务")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 展示投稿账号
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ enum ProxyRuntimeMode: String, CaseIterable, Codable, Identifiable {
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .localWiFi: return "APP模式"
|
||||
case .thirdParty: return "第三方代理模式"
|
||||
case .localWiFi: return String(localized: "APP模式")
|
||||
case .thirdParty: return String(localized: "第三方代理模式")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,10 +38,35 @@ struct RuntimeLogEntry: Codable, Identifiable, Equatable {
|
||||
var renderedText: String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
let suffix = details.isEmpty
|
||||
let localizedDetails = details.map {
|
||||
(localizedDiagnosticText($0.key), localizedDiagnosticValue($0.value))
|
||||
}
|
||||
let suffix = localizedDetails.isEmpty
|
||||
? ""
|
||||
: " " + details.sorted(by: { $0.key < $1.key }).map { "\($0.key)=\($0.value)" }.joined(separator: " ")
|
||||
return "\(formatter.string(from: timestamp)) [\(source)] [\(level.rawValue.uppercased())] [\(category)] \(message)\(suffix)"
|
||||
: " " + localizedDetails.sorted(by: { $0.0 < $1.0 }).map { "\($0.0)=\($0.1)" }.joined(separator: " ")
|
||||
return "\(formatter.string(from: timestamp)) [\(source)] [\(level.rawValue.uppercased())] [\(localizedCategory)] \(localizedMessage)\(suffix)"
|
||||
}
|
||||
|
||||
var localizedCategory: String { localizedDiagnosticText(category) }
|
||||
var localizedMessage: String { localizedDiagnosticText(message) }
|
||||
|
||||
var localizedDetailsText: String {
|
||||
details.map { (localizedDiagnosticText($0.key), localizedDiagnosticValue($0.value)) }
|
||||
.sorted(by: { $0.0 < $1.0 })
|
||||
.map { "\($0.0): \($0.1)" }
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func localizedDiagnosticText(_ text: String) -> String {
|
||||
String(localized: String.LocalizationValue(text))
|
||||
}
|
||||
|
||||
private func localizedDiagnosticValue(_ value: String) -> String {
|
||||
switch value {
|
||||
case "true": return String(localized: "是")
|
||||
case "false": return String(localized: "否")
|
||||
default: return localizedDiagnosticText(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ struct ThirdPartyProxySettingsResponse: Decodable, Equatable {
|
||||
let latitude: Double?
|
||||
let accuracy: Int?
|
||||
let error: String?
|
||||
let motionSimulationEnabled: Bool?
|
||||
}
|
||||
|
||||
enum ThirdPartyProxyConnectionState: Equatable {
|
||||
@@ -24,17 +25,26 @@ enum ThirdPartyProxyError: LocalizedError, Equatable {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidResponse:
|
||||
return "第三方代理返回了无法识别的数据"
|
||||
return String(localized: "第三方代理返回了无法识别的数据")
|
||||
case .moduleNotIntercepted:
|
||||
return "请求未被第三方代理模块拦截,请检查模块、MITM 和代理连接"
|
||||
return String(localized: "请求未被第三方代理模块拦截,请检查模块、MITM 和代理连接")
|
||||
case .rejected(let message):
|
||||
return message
|
||||
case .coordinateMismatch:
|
||||
return "第三方代理保存的坐标与当前选点不一致"
|
||||
return String(localized: "第三方代理保存的坐标与当前选点不一致")
|
||||
case .network(let message):
|
||||
return "第三方代理请求失败:\(message)"
|
||||
return String(localized: "第三方代理请求失败:\(message)")
|
||||
}
|
||||
}
|
||||
|
||||
var recoverySuggestion: String {
|
||||
return String(localized: "检查模块、MITM、证书和代理/VPN连接")
|
||||
}
|
||||
|
||||
static func recoverySuggestion(for error: Error) -> String {
|
||||
(error as? Self)?.recoverySuggestion
|
||||
?? String(localized: "检查模块、MITM、证书和代理/VPN连接")
|
||||
}
|
||||
}
|
||||
|
||||
protocol ThirdPartyProxyRequesting {
|
||||
@@ -46,13 +56,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",
|
||||
"gsp-ssl.ls.apple.com",
|
||||
"bluedot.is.autonavi.com",
|
||||
"bluedot.is.autonavi.com.gds.alibabadns.com"
|
||||
]
|
||||
static let configurationEndpoint = URL(string: "https://gs-loc.apple.com/wloc-settings/save")!
|
||||
|
||||
@Published private(set) var connectionState: ThirdPartyProxyConnectionState = .unknown
|
||||
@Published private(set) var activeSettings: ThirdPartyProxySettingsResponse?
|
||||
@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 +85,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,10 +101,11 @@ final class ThirdPartyProxyManager: ObservableObject {
|
||||
let response = try await perform(action: .save(
|
||||
latitude: wgs84.latitude,
|
||||
longitude: wgs84.longitude,
|
||||
accuracy: favorite.accuracy
|
||||
accuracy: favorite.accuracy,
|
||||
randomRadius: RandomRadiusStore.shared.isEnabled ? RandomRadiusStore.shared.radius : 0
|
||||
))
|
||||
guard response.success else {
|
||||
throw ThirdPartyProxyError.rejected(response.error ?? "第三方代理拒绝保存坐标")
|
||||
throw ThirdPartyProxyError.rejected(response.error ?? String(localized: "第三方代理拒绝保存坐标"))
|
||||
}
|
||||
guard let latitude = response.latitude,
|
||||
let longitude = response.longitude,
|
||||
@@ -114,37 +126,53 @@ final class ThirdPartyProxyManager: ObservableObject {
|
||||
func clear() async throws {
|
||||
let response = try await perform(action: .clear)
|
||||
guard response.success else {
|
||||
throw ThirdPartyProxyError.rejected(response.error ?? "第三方代理清除坐标失败")
|
||||
throw ThirdPartyProxyError.rejected(response.error ?? String(localized: "第三方代理清除坐标失败"))
|
||||
}
|
||||
activeSettings = nil
|
||||
connectionState = .connected(active: false)
|
||||
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 ?? String(localized: "第三方代理查询失败"))
|
||||
}
|
||||
|
||||
private enum Action {
|
||||
case query
|
||||
case save(latitude: Double, longitude: Double, accuracy: Int)
|
||||
case save(latitude: Double, longitude: Double, accuracy: Int, randomRadius: Double)
|
||||
case clear
|
||||
}
|
||||
|
||||
private func perform(action: Action) async throws -> ThirdPartyProxySettingsResponse {
|
||||
guard !isRequesting else {
|
||||
throw ThirdPartyProxyError.rejected("已有第三方代理请求正在执行")
|
||||
throw ThirdPartyProxyError.rejected(String(localized: "已有第三方代理请求正在执行"))
|
||||
}
|
||||
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 randomRadius):
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "lon", value: String(format: "%.8f", locale: Locale(identifier: "en_US_POSIX"), longitude)),
|
||||
URLQueryItem(name: "lat", value: String(format: "%.8f", locale: Locale(identifier: "en_US_POSIX"), latitude)),
|
||||
URLQueryItem(name: "acc", value: String(accuracy))
|
||||
URLQueryItem(name: "acc", value: String(accuracy)),
|
||||
URLQueryItem(
|
||||
name: "randomRadius",
|
||||
value: String(format: "%g", locale: Locale(identifier: "en_US_POSIX"), randomRadius)
|
||||
)
|
||||
]
|
||||
}
|
||||
guard let url = components.url else { throw ThirdPartyProxyError.invalidResponse }
|
||||
@@ -177,6 +205,9 @@ final class ThirdPartyProxyManager: ObservableObject {
|
||||
}
|
||||
|
||||
enum ThirdPartyProxyClient: String, CaseIterable, Identifiable {
|
||||
/// Bump when a hosted module or script changes to invalidate proxy-client caches.
|
||||
static let moduleSubscriptionVersion = "1.0.8"
|
||||
|
||||
case shadowrocket
|
||||
case surge
|
||||
case quantumultX
|
||||
@@ -197,8 +228,8 @@ enum ThirdPartyProxyClient: String, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
var verificationText: String {
|
||||
self == .shadowrocket ? "当前可测试" : "配置已提供,尚未验证"
|
||||
var verificationText: String? {
|
||||
self == .shadowrocket ? nil : String(localized: "配置已提供,尚未验证")
|
||||
}
|
||||
|
||||
var moduleFileName: String {
|
||||
@@ -211,21 +242,16 @@ enum ThirdPartyProxyClient: String, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var subscriptionURL: URL {
|
||||
let url: String
|
||||
switch self {
|
||||
case .surge, .egern:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.sgmodule"
|
||||
case .quantumultX:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.conf"
|
||||
case .loon:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.lpx"
|
||||
case .stash:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.stoverride"
|
||||
case .shadowrocket:
|
||||
url = "https://raw.githubusercontent.com/Yu9191/wloc/refs/heads/main/modules/wloc.module"
|
||||
}
|
||||
return URL(string: url)!
|
||||
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/"
|
||||
let base = "\(prefix)\(directory)/\(moduleFileName)"
|
||||
return URL(string: "\(base)?v=\(Self.moduleSubscriptionVersion)")!
|
||||
}
|
||||
|
||||
var launchURL: URL? {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// 环境验证结果,UI 层根据此值弹出对应的引导面板。
|
||||
/// 环境验证结果,由 SetupCoordinator 统一路由到对应引导页面。
|
||||
enum VerificationResult: Equatable, Identifiable {
|
||||
case success
|
||||
case proxyNotRunning
|
||||
@@ -26,27 +26,18 @@ enum VerificationResult: Equatable, Identifiable {
|
||||
|
||||
var isSuccess: Bool { self == .success }
|
||||
|
||||
/// 对应的引导页类型
|
||||
var tipKind: TipKind? {
|
||||
/// Localized, user-facing title. `id` stays stable for routing/equality.
|
||||
var localizedTitle: String {
|
||||
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
|
||||
case .success: return String(localized: "成功")
|
||||
case .proxyNotRunning: return String(localized: "代理未运行")
|
||||
case .verificationInProgress: return String(localized: "已有验证正在进行")
|
||||
case .verificationSuperseded: return String(localized: "验证已被新位置取代")
|
||||
case .certNotTrusted: return String(localized: "证书未信任")
|
||||
case .wifiProxyNotConfigured: return String(localized: "WiFi代理未配置")
|
||||
case .coordinateWriteFailed: return String(localized: "坐标写入失败")
|
||||
case .patchFailed: return String(localized: "改写验证失败")
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.7")
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,9 +73,13 @@ final class RealtimeLocationManagerTests: XCTestCase {
|
||||
func testOneShotTimeoutTransitionsToContinuousFallback() async {
|
||||
let driver = FakeRealtimeLocationDriver()
|
||||
let manager = RealtimeLocationManager(driver: driver, oneShotTimeoutNanoseconds: 5_000_000, fallbackTimeoutNanoseconds: 1_000_000_000)
|
||||
let fallbackStarted = expectation(description: "Continuous location fallback started")
|
||||
driver.onStartUpdating = {
|
||||
fallbackStarted.fulfill()
|
||||
}
|
||||
|
||||
let request = Task { await manager.requestLocation() }
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
await fulfillment(of: [fallbackStarted], timeout: 1)
|
||||
XCTAssertEqual(driver.startUpdatingCallCount, 1)
|
||||
|
||||
driver.emit(CLLocation(latitude: 39.90, longitude: 116.40))
|
||||
@@ -148,6 +152,7 @@ private final class FakeRealtimeLocationDriver: RealtimeLocationDriving {
|
||||
var authorizationStatus: CLAuthorizationStatus = .authorizedWhenInUse
|
||||
weak var delegate: CLLocationManagerDelegate?
|
||||
var onRequestLocation: (() -> Void)?
|
||||
var onStartUpdating: (() -> Void)?
|
||||
private(set) var requestAuthorizationCallCount = 0
|
||||
private(set) var requestLocationCallCount = 0
|
||||
private(set) var startUpdatingCallCount = 0
|
||||
@@ -164,6 +169,7 @@ private final class FakeRealtimeLocationDriver: RealtimeLocationDriving {
|
||||
|
||||
func startUpdatingLocation() {
|
||||
startUpdatingCallCount += 1
|
||||
onStartUpdating?()
|
||||
}
|
||||
|
||||
func stopUpdatingLocation() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,66 @@ final class ThirdPartyProxyManagerTests: XCTestCase {
|
||||
XCTAssertEqual(manager.connectionState, .connected(active: true))
|
||||
}
|
||||
|
||||
func testConnectionUsesLegacySaveQueryEndpoint() async throws {
|
||||
let requester = FakeThirdPartyRequester(body: #"{"success":false,"error":"无已保存的坐标"}"#)
|
||||
let manager = ThirdPartyProxyManager(requester: requester)
|
||||
|
||||
let response = try await manager.query()
|
||||
|
||||
XCTAssertFalse(response.success)
|
||||
XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/save"])
|
||||
XCTAssertEqual(requester.requestedURLs.first?.query, "action=query")
|
||||
}
|
||||
|
||||
func testLegacyModuleCanStillSaveBasicCoordinates() async throws {
|
||||
let favorite = FavoriteLocation(
|
||||
name: "深圳湾",
|
||||
latitude: 22.494,
|
||||
longitude: 113.951,
|
||||
accuracy: 20,
|
||||
mapCoordinateSystem: .gcj02
|
||||
)
|
||||
let wgs84 = favorite.coordinatePair.wgs84
|
||||
let body = String(
|
||||
format: #"{"success":true,"longitude":%.8f,"latitude":%.8f,"accuracy":20}"#,
|
||||
locale: Locale(identifier: "en_US_POSIX"),
|
||||
wgs84.longitude,
|
||||
wgs84.latitude
|
||||
)
|
||||
let requester = FakeThirdPartyRequester(body: body)
|
||||
let manager = ThirdPartyProxyManager(requester: requester)
|
||||
|
||||
let response = try await manager.save(favorite)
|
||||
|
||||
XCTAssertTrue(response.success)
|
||||
XCTAssertEqual(manager.connectionState, .connected(active: true))
|
||||
XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/save"])
|
||||
let queryComponents = URLComponents(
|
||||
url: try XCTUnwrap(requester.requestedURLs.first),
|
||||
resolvingAgainstBaseURL: false
|
||||
)
|
||||
let values = Dictionary(
|
||||
uniqueKeysWithValues: (queryComponents?.queryItems ?? []).map { ($0.name, $0.value ?? "") }
|
||||
)
|
||||
XCTAssertEqual(values["lon"], String(format: "%.8f", locale: Locale(identifier: "en_US_POSIX"), wgs84.longitude))
|
||||
XCTAssertEqual(values["lat"], String(format: "%.8f", locale: Locale(identifier: "en_US_POSIX"), wgs84.latitude))
|
||||
XCTAssertEqual(values["acc"], "20")
|
||||
XCTAssertEqual(values["randomRadius"], "0")
|
||||
}
|
||||
|
||||
func testBrokenSaveQueryFailsWithoutCheckingVersion() async {
|
||||
let requester = FakeThirdPartyRequester(body: "not-json")
|
||||
let manager = ThirdPartyProxyManager(requester: requester)
|
||||
|
||||
do {
|
||||
_ = try await manager.query()
|
||||
XCTFail("expected interception failure")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? ThirdPartyProxyError, .moduleNotIntercepted)
|
||||
}
|
||||
XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/save"])
|
||||
}
|
||||
|
||||
func testSaveRejectsCoordinateMismatchWithoutMarkingActive() async {
|
||||
let requester = FakeThirdPartyRequester(body: #"{"success":true,"longitude":1,"latitude":2,"accuracy":25}"#)
|
||||
let manager = ThirdPartyProxyManager(requester: requester)
|
||||
@@ -65,12 +125,38 @@ final class ThirdPartyProxyManagerTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
func testClientLinksUseOfficialUpstreamModulesAndVerificationLabels() {
|
||||
XCTAssertEqual(ThirdPartyProxyClient.shadowrocket.verificationText, "当前可测试")
|
||||
XCTAssertTrue(ThirdPartyProxyClient.surge.verificationText.contains("尚未验证"))
|
||||
func testClientLinksUseProjectOwnedModulesAndVerificationLabels() {
|
||||
XCTAssertEqual(
|
||||
ThirdPartyProxyManager.interceptionHostnames,
|
||||
[
|
||||
"gs-loc.apple.com",
|
||||
"gs-loc-cn.apple.com",
|
||||
"gsp-ssl.ls.apple.com",
|
||||
"bluedot.is.autonavi.com",
|
||||
"bluedot.is.autonavi.com.gds.alibabadns.com"
|
||||
]
|
||||
)
|
||||
XCTAssertNil(ThirdPartyProxyClient.shadowrocket.verificationText)
|
||||
XCTAssertEqual(
|
||||
ThirdPartyProxyClient.surge.verificationText,
|
||||
String(localized: "配置已提供,尚未验证")
|
||||
)
|
||||
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/Resources/ThirdPartyProxyModules/"
|
||||
))
|
||||
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")
|
||||
@@ -95,6 +181,7 @@ final class ThirdPartyProxyManagerTests: XCTestCase {
|
||||
private final class FakeThirdPartyRequester: ThirdPartyProxyRequesting {
|
||||
private let data: Data
|
||||
private(set) var lastURL: URL?
|
||||
private(set) var requestedURLs: [URL] = []
|
||||
|
||||
init(body: String) {
|
||||
data = Data(body.utf8)
|
||||
@@ -102,6 +189,7 @@ private final class FakeThirdPartyRequester: ThirdPartyProxyRequesting {
|
||||
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
lastURL = request.url
|
||||
requestedURLs.append(request.url!)
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!,
|
||||
statusCode: 200,
|
||||
|
||||
@@ -8,6 +8,10 @@ test -x "$BUILD_SCRIPT" || fail "build.sh must be executable"
|
||||
grep -qF "build-unsigned-ipa.sh" "$BUILD_SCRIPT" || fail "build.sh must call build-unsigned-ipa.sh"
|
||||
|
||||
test -f "$ROOT/Scripts/build-unsigned-ipa.sh" || fail "build-unsigned-ipa.sh must exist"
|
||||
grep -qF 'BUILD_TIMESTAMP="${BUILD_TIMESTAMP:-$(date' "$ROOT/Scripts/build-unsigned-ipa.sh" \
|
||||
|| fail "build output must include its generation timestamp"
|
||||
grep -qF 'PaopaoLocationSpoofer-${BUILD_TIMESTAMP}-unsigned.ipa' "$ROOT/Scripts/build-unsigned-ipa.sh" \
|
||||
|| fail "timestamped IPA filename is missing"
|
||||
|
||||
# Should NOT contain Tunnel references
|
||||
! grep -qF "Tunnel" "$ROOT/Scripts/build-unsigned-ipa.sh" || fail "build-unsigned-ipa.sh must not reference Tunnel"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
STRINGS="$ROOT/Resources/en.lproj/Localizable.strings"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
plutil -lint "$STRINGS" >/dev/null || fail "English localization file is invalid"
|
||||
|
||||
ruby - "$STRINGS" <<'RUBY' || exit 1
|
||||
path = ARGV.fetch(0)
|
||||
entries = File.readlines(path).map do |line|
|
||||
match = line.match(/^"((?:\\.|[^"])*)"\s*=\s*"((?:\\.|[^"])*)";/)
|
||||
[match[1], match[2]] if match
|
||||
end.compact
|
||||
|
||||
duplicates = entries.group_by(&:first).select { |_key, values| values.length > 1 }.keys
|
||||
abort "FAIL: duplicate English localization keys: #{duplicates.join(', ')}" unless duplicates.empty?
|
||||
|
||||
placeholder = /%(?:\d+\$)?(?:@|lld|ld|d|f)/
|
||||
entries.each do |source, target|
|
||||
source_count = source.scan(placeholder).length
|
||||
target_count = target.scan(placeholder).length
|
||||
abort "FAIL: placeholder mismatch for #{source.inspect}" unless source_count == target_count
|
||||
end
|
||||
RUBY
|
||||
|
||||
for key in \
|
||||
'环境信息' \
|
||||
'诊断日志' \
|
||||
'======== 代理验证测试 ========' \
|
||||
'======== 第三方代理连接检测 ========' \
|
||||
'======== 第三方代理运行检测 ========' \
|
||||
'第三方代理测试模式:模块连接成功;已保存坐标=%@' \
|
||||
'成功:{\"success\":true,\"longitude\":113.0,\"latitude\":22.0,\"accuracy\":25}\n失败:{\"success\":false,\"error\":\"错误说明\"}'; do
|
||||
grep -Fq "\"$key\" = " "$STRINGS" || fail "missing critical English localization: $key"
|
||||
done
|
||||
|
||||
grep -q 'localizedCategory' "$ROOT/Shared/RuntimeLog.swift" \
|
||||
|| fail "runtime log categories must be localized when rendered"
|
||||
grep -q 'localizedDetailsText' "$ROOT/App/DiagnosticsView.swift" \
|
||||
|| fail "runtime log details must be localized in the diagnostics UI"
|
||||
grep -q 'String(localized: "诊断日志")' "$ROOT/App/BugReportView.swift" \
|
||||
|| fail "generated bug reports must localize their diagnostic section"
|
||||
grep -q 'log(" " + e.localizedMessage)' "$ROOT/App/SetupCoordinator.swift" \
|
||||
|| fail "bug-report verification logs must render stored messages in the active language"
|
||||
|
||||
if grep -R -n 'raw.githubusercontent.com/Yu9191/wloc' \
|
||||
"$ROOT/App" "$ROOT/Shared" "$ROOT/Resources/ThirdPartyProxyModules" \
|
||||
"$ROOT/ThirdParty/WlocScripts/modules"; then
|
||||
fail "deleted Yu9191/wloc repository must not remain a runtime dependency"
|
||||
fi
|
||||
|
||||
echo "PASS: localization and diagnostic export contract"
|
||||
@@ -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"
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
passed=0
|
||||
failed=0
|
||||
failed_scripts=()
|
||||
|
||||
for script in "$ROOT"/Tests/*_test.sh; do
|
||||
name="$(basename "$script")"
|
||||
echo "==> $name"
|
||||
if bash "$script"; then
|
||||
echo "PASS: $name"
|
||||
passed=$((passed + 1))
|
||||
else
|
||||
echo "FAIL: $name"
|
||||
failed_scripts+=("$name")
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "----------------------------------------"
|
||||
echo "Contract tests: $passed passed / $failed failed"
|
||||
if [ "$failed" -gt 0 ]; then
|
||||
echo "Failed scripts:"
|
||||
for name in "${failed_scripts[@]}"; do
|
||||
echo " - $name"
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PASS: all contract tests"
|
||||
@@ -9,41 +9,140 @@ MANAGER="$ROOT/Shared/ThirdPartyProxyManager.swift"
|
||||
CONTENT="$ROOT/App/ContentView.swift"
|
||||
SETUP="$ROOT/App/FirstSetupView.swift"
|
||||
SETTINGS="$ROOT/App/SettingsView.swift"
|
||||
MODULES="$ROOT/Resources/ThirdPartyProxyModules"
|
||||
|
||||
grep -q 'return "APP模式"' "$MODE" || fail "APP mode display name is missing"
|
||||
grep -q 'return "第三方代理模式"' "$MODE" || fail "third-party mode display name is missing"
|
||||
grep -q 'String(localized: "APP模式")' "$MODE" || fail "localized APP mode display name is missing"
|
||||
grep -q 'String(localized: "第三方代理模式")' "$MODE" || fail "localized third-party mode display name is missing"
|
||||
grep -q 'hasSelectedMode' "$MODE" || fail "first-launch mode selection must be persisted"
|
||||
grep -q 'guard runtimeMode.hasSelectedMode else' "$CONTENT" || fail "mode selection must gate startup"
|
||||
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"
|
||||
|
||||
grep -q 'raw.githubusercontent.com/xweiba/location-spoofer/main' "$MANAGER" \
|
||||
|| fail "third-party subscription must point at project-owned modules"
|
||||
! grep -q 'raw.githubusercontent.com/Yu9191/wloc' "$MANAGER" \
|
||||
|| fail "third-party subscription must not depend on the removed upstream repository"
|
||||
MODULES="$ROOT/Resources/ThirdPartyProxyModules"
|
||||
SCRIPTS="$ROOT/ThirdParty/WlocScripts"
|
||||
for file in wloc.module wloc.sgmodule wloc.conf wloc.lpx wloc.stoverride; do
|
||||
test -s "$MODULES/$file" || fail "missing bundled module: $file"
|
||||
test -s "$MODULES/$file" || fail "missing project-owned mirrored module: $file"
|
||||
test -s "$SCRIPTS/modules/direct/$file" || fail "missing project-owned direct module: $file"
|
||||
! grep 'ThirdParty/WlocScripts/dist/v1/wloc' "$MODULES/$file" "$SCRIPTS/modules/direct/$file" \
|
||||
| grep -Fvq '?v=1.0.8' \
|
||||
|| fail "hosted script URLs must carry the current cache version: $file"
|
||||
done
|
||||
|
||||
test -s "$SCRIPTS/dist/v1/wloc.js" || fail "missing hosted WLOC response script"
|
||||
test -s "$SCRIPTS/dist/v1/wloc-settings.js" || fail "missing hosted WLOC settings script"
|
||||
! grep -R -q 'raw.githubusercontent.com/Yu9191/wloc' "$MODULES" "$SCRIPTS/modules/direct" \
|
||||
|| fail "hosted modules must not reference the removed upstream repository"
|
||||
grep -q 'wloc.sgmodule' "$MANAGER" || fail "Surge/Egern module mapping is missing"
|
||||
grep -q 'wloc.stoverride' "$MANAGER" || fail "Stash must use .stoverride directly"
|
||||
grep -q 'shadowrocket://' "$MANAGER" || fail "Shadowrocket launch URL is missing"
|
||||
for scheme in surge quantumult-x loon stash egern; do
|
||||
grep -q "${scheme}://" "$MANAGER" || fail "$scheme launch URL is missing"
|
||||
done
|
||||
grep -q '复制解密域名' "$SETUP" || fail "Shadowrocket MITM hostname copy action is missing"
|
||||
grep -Fq 'Text("复制 \(hostname)")' "$SETUP" \
|
||||
|| fail "all clients must expose a copy action for each MITM hostname"
|
||||
grep -q '配置时请复制下方全部解密域名' "$SETUP" \
|
||||
|| fail "setup guidance must direct users to copy all Apple location hostnames"
|
||||
grep -q 'ForEach(ThirdPartyProxyManager.interceptionHostnames' "$SETUP" \
|
||||
|| fail "setup must render every interception hostname"
|
||||
grep -q 'ForEach(ThirdPartyProxyManager.interceptionHostnames' "$SETTINGS" \
|
||||
|| fail "Settings must render every interception hostname"
|
||||
test "$(grep -h 'UIPasteboard.general.string = hostname' "$SETUP" "$SETTINGS" | wc -l | tr -d ' ')" -eq 2 \
|
||||
|| fail "both hostname lists must copy one selected hostname"
|
||||
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.query()' "$SETUP" \
|
||||
|| fail "the import page must validate the save/query endpoint"
|
||||
grep -A20 'let response = try await thirdPartyProxy.query()' "$SETUP" | grep -q 'onComplete()' \
|
||||
|| fail "a successful third-party connection test must close setup immediately"
|
||||
grep -q 'components.queryItems = \[URLQueryItem(name: "action", value: "query")\]' "$MANAGER" \
|
||||
|| fail "the connection test must preserve the established save?action=query contract"
|
||||
grep -B4 'Toggle("运动状态模拟"' "$SETTINGS" | grep -q 'runtimeMode.mode == .localWiFi' \
|
||||
|| fail "the motion-state toggle must only appear in APP mode"
|
||||
grep -B4 'Toggle("随机扰动"' "$SETTINGS" | grep -q 'runtimeMode.mode == .thirdParty' \
|
||||
|| fail "the random-perturbation toggle must only appear in third-party mode"
|
||||
! grep -q 'validateVersion\|refreshAdvancedFeatureAvailability\|moduleUpdateRecommended' \
|
||||
"$SETUP" "$SETTINGS" "$MANAGER" \
|
||||
|| fail "script version detection must be removed from the app"
|
||||
! grep -q 'thirdPartyTestResult?.success' "$SETUP" \
|
||||
|| fail "a successful third-party test must not leave a separate completion state"
|
||||
grep -q 'setupStep = \.thirdPartyImport' "$ROOT/App/SetupCoordinator.swift" \
|
||||
|| 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: String(localized: \"接口连接失败\")' "$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.7"' "$ROOT/project.yml" || fail "marketing version must be 1.0.7"
|
||||
grep -q 'CURRENT_PROJECT_VERSION: "8"' "$ROOT/project.yml" || fail "build version must be 8"
|
||||
|
||||
echo "PASS: third-party proxy mode contract"
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/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.7"
|
||||
assert config["minimumSupportedVersion"] == "1.0.0"
|
||||
assert "shadowrocket" not in config["communityPromptClients"]
|
||||
assert set(config["communityPromptClients"]) == {
|
||||
"surge", "quantumultX", "loon", "stash", "egern"
|
||||
}
|
||||
PY
|
||||
|
||||
LATEST_VERSION="$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["latestVersion"])' "$ROOT/version.txt")"
|
||||
|
||||
grep -Fq "latestVersion: \"$LATEST_VERSION\"" "$CONFIG" \
|
||||
|| fail "Shared/AppRemoteConfiguration.swift fallback latestVersion must match version.txt ($LATEST_VERSION)"
|
||||
grep -Fq "version-v$LATEST_VERSION" "$ROOT/README.md" \
|
||||
|| fail "README.md version badge must match version.txt ($LATEST_VERSION)"
|
||||
grep -Fq "version-v$LATEST_VERSION" "$ROOT/README.en.md" \
|
||||
|| fail "README.en.md version badge must match version.txt ($LATEST_VERSION)"
|
||||
|
||||
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 'String(localized: "第三方客户端")' "$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"
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Third-Party Notices
|
||||
|
||||
The files in `dist/v1/` are recovered prebuilt artifacts from `Yu9191/wloc`,
|
||||
obtained from the community restoration at `xepes0/wloc`. The original author
|
||||
and contributor attribution is preserved. These files are distributed under
|
||||
the included GNU Affero General Public License v3.0 (`LICENSE`).
|
||||
|
||||
The recovered upstream notice states that the prebuilt files may contain
|
||||
third-party components whose complete dependency and reproducible-build records
|
||||
were not recovered. This notice is not a complete software bill of materials or
|
||||
legal audit.
|
||||
|
||||
See `UPSTREAM.md` for the pinned source, hashes, local differences, regression
|
||||
checks, and maintenance exit condition.
|
||||
@@ -0,0 +1,44 @@
|
||||
# WLOC Upstream Provenance
|
||||
|
||||
## Pinned source
|
||||
|
||||
- Original repository: `https://github.com/Yu9191/wloc` (deleted)
|
||||
- Community restoration: `https://github.com/xepes0/wloc`
|
||||
- Pinned restoration commit: `7f28c5e4fb6b802862988735b92e5c800382f2a2`
|
||||
- Recovered original baseline: `529fcd841952571e79f40faf2d3e0ef90a7bdfa6`
|
||||
- License: GNU Affero General Public License v3.0; retained in `LICENSE`
|
||||
|
||||
The restoration preserves the original repository history and attributes the
|
||||
scripts to Yu9191 and the original contributors.
|
||||
|
||||
## Included file integrity
|
||||
|
||||
| File | SHA-256 |
|
||||
| --- | --- |
|
||||
| `dist/v1/wloc.js` | `a1b361e60f0b434585260fb59c65d1ddbe3bff89ace3639f592e7d8af432b3c1` |
|
||||
| `dist/v1/wloc-settings.js` | `cbff047a7f42055615ee19e208ddfc2ec66833e6f2b75555eac84f1c299a66cf` |
|
||||
| `LICENSE` | `8486a10c4393cee1c25392769ddd3b2d6c242d6ec7928e1414efff7dfb2f07ef` |
|
||||
|
||||
The response-script hash also matches the independent `pixian5/wloc`
|
||||
restoration at the time of import.
|
||||
|
||||
## Local differences
|
||||
|
||||
The two files under `dist/v1/` and `LICENSE` are byte-for-byte copies from the
|
||||
pinned restoration. Module files retain the recovered interception behavior but
|
||||
replace homepage, documentation, icon, and script URLs with project-owned URLs.
|
||||
The mirrored module variants use `gh-proxy.org`; direct variants use GitHub Raw.
|
||||
Both carry cache version `1.0.8`.
|
||||
|
||||
## Regression checks
|
||||
|
||||
Run `npm test` in this directory. Tests pin the exact artifact and license
|
||||
hashes and exercise Shadowrocket save/query behavior with the `wloc_settings`
|
||||
persistent key. Project contract and Xcode tests validate module URLs and app
|
||||
integration.
|
||||
|
||||
## Exit condition
|
||||
|
||||
Re-evaluate these copies when an authoritative maintained upstream becomes
|
||||
available. Return to an upstream dependency only after its license, provenance,
|
||||
protocol compatibility, cache behavior, and regression tests have been checked.
|
||||
@@ -0,0 +1,11 @@
|
||||
#!name=Apple WLOC 定位修改
|
||||
#!desc=Apple 网络定位修改 | 使用说明: https://github.com/xweiba/location-spoofer/blob/main/docs/THIRD_PARTY_MODULES.md
|
||||
#!author=Yu9191 Rewrite
|
||||
#!homepage=https://github.com/xweiba/location-spoofer
|
||||
|
||||
[rewrite_local]
|
||||
^https?:\/\/(?:gs-loc(?:-cn)?\.apple\.com|gsp-ssl\.ls\.apple\.com|bluedot\.is\.autonavi\.com(?:\.gds\.alibabadns\.com)?)\/clls\/wloc url script-response-body https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js?v=1.0.8
|
||||
^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save url script-echo-response https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js?v=1.0.8
|
||||
|
||||
[mitm]
|
||||
hostname = gs-loc.apple.com, gs-loc-cn.apple.com, gsp-ssl.ls.apple.com, bluedot.is.autonavi.com, bluedot.is.autonavi.com.gds.alibabadns.com
|
||||
@@ -0,0 +1,20 @@
|
||||
#!name=Apple WLOC 定位修改
|
||||
#!desc=Apple 网络定位修改 | 使用说明: https://github.com/xweiba/location-spoofer/blob/main/docs/THIRD_PARTY_MODULES.md
|
||||
#!icon=https://raw.githubusercontent.com/xweiba/location-spoofer/main/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png
|
||||
#!author=Yu9191 Rewrite
|
||||
#!homepage=https://github.com/xweiba/location-spoofer
|
||||
|
||||
|
||||
[Argument]
|
||||
longitude = input, "113.94114", tag=经度(在线选点优先)
|
||||
latitude = input, "22.544577", tag=纬度(在线选点优先)
|
||||
accuracy = input, "25", tag=精度(米)
|
||||
randomRadius = input, "0", tag=扰动半径(米,0为关闭)
|
||||
logLevel = select, "info", "off", "error", "warn", "debug", "all", tag=日志级别
|
||||
|
||||
[Script]
|
||||
http-response ^https?:\/\/(?:gs-loc(?:-cn)?\.apple\.com|gsp-ssl\.ls\.apple\.com|bluedot\.is\.autonavi\.com(?:\.gds\.alibabadns\.com)?)\/clls\/wloc script-path=https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js?v=1.0.8, requires-body=true, binary-body-mode=true, timeout=30, tag=Apple WLOC, argument=[{longitude},{latitude},{accuracy},{randomRadius},{logLevel}]
|
||||
http-request ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save script-path=https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js?v=1.0.8, timeout=10, tag=WLOC Settings
|
||||
|
||||
[MITM]
|
||||
hostname = gs-loc.apple.com, gs-loc-cn.apple.com, gsp-ssl.ls.apple.com, bluedot.is.autonavi.com, bluedot.is.autonavi.com.gds.alibabadns.com
|
||||
@@ -0,0 +1,13 @@
|
||||
#!name=Apple WLOC 定位修改
|
||||
#!desc=Apple 网络定位修改 | 使用说明: https://github.com/xweiba/location-spoofer/blob/main/docs/THIRD_PARTY_MODULES.md
|
||||
#!author=Yu9191 Rewrite
|
||||
#!homepage=https://github.com/xweiba/location-spoofer
|
||||
#!icon=https://raw.githubusercontent.com/xweiba/location-spoofer/main/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png
|
||||
#!category=Tools
|
||||
|
||||
[Script]
|
||||
Apple WLOC = type=http-response,pattern=^https?:\/\/(?:gs-loc(?:-cn)?\.apple\.com|gsp-ssl\.ls\.apple\.com|bluedot\.is\.autonavi\.com(?:\.gds\.alibabadns\.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?v=1.0.8,argument=longitude=113.94114&latitude=22.544577&accuracy=25&randomRadius=0&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/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js?v=1.0.8
|
||||
|
||||
[MITM]
|
||||
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com, gsp-ssl.ls.apple.com, bluedot.is.autonavi.com, bluedot.is.autonavi.com.gds.alibabadns.com
|
||||
@@ -0,0 +1,14 @@
|
||||
#!name=Apple WLOC 定位修改
|
||||
#!desc=Apple 网络定位修改 | 使用说明: https://github.com/xweiba/location-spoofer/blob/main/docs/THIRD_PARTY_MODULES.md
|
||||
#!author=Yu9191 Rewrite
|
||||
#!homepage=https://github.com/xweiba/location-spoofer
|
||||
#!category=Tools
|
||||
#!arguments=经度:113.94114, 纬度:22.544577, 精度:25, 扰动半径:0, 日志级别:info
|
||||
#!arguments-desc=经度/纬度: 默认坐标(在线选点储存后优先)\n精度: GPS精度(米)\n扰动半径: 每次响应在目标点周围随机偏移的最大距离(米),0为关闭\n日志级别: off/error/warn/info/debug/all\n\n使用方法: 打开选点页面 -> 选位置 -> 储存到设备
|
||||
|
||||
[Script]
|
||||
Apple WLOC = type=http-response, pattern="^https?:\/\/(?:gs-loc(?:-cn)?\.apple\.com|gsp-ssl\.ls\.apple\.com|bluedot\.is\.autonavi\.com(?:\.gds\.alibabadns\.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?v=1.0.8, argument=longitude={{{经度}}}&latitude={{{纬度}}}&accuracy={{{精度}}}&randomRadius={{{扰动半径}}}&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/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js?v=1.0.8
|
||||
|
||||
[MITM]
|
||||
hostname = %APPEND% gs-loc.apple.com, gs-loc-cn.apple.com, gsp-ssl.ls.apple.com, bluedot.is.autonavi.com, bluedot.is.autonavi.com.gds.alibabadns.com
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Apple WLOC 定位修改
|
||||
desc: "Apple 网络定位修改 | 使用说明: https://github.com/xweiba/location-spoofer/blob/main/docs/THIRD_PARTY_MODULES.md"
|
||||
author: Yu9191 Rewrite
|
||||
homepage: https://github.com/xweiba/location-spoofer
|
||||
icon: https://raw.githubusercontent.com/xweiba/location-spoofer/main/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png
|
||||
category: Tools
|
||||
|
||||
http:
|
||||
mitm:
|
||||
- "gs-loc.apple.com"
|
||||
- "gs-loc-cn.apple.com"
|
||||
- "gsp-ssl.ls.apple.com"
|
||||
- "bluedot.is.autonavi.com"
|
||||
- "bluedot.is.autonavi.com.gds.alibabadns.com"
|
||||
script:
|
||||
- match: ^https?:\/\/(?:gs-loc(?:-cn)?\.apple\.com|gsp-ssl\.ls\.apple\.com|bluedot\.is\.autonavi\.com(?:\.gds\.alibabadns\.com)?)\/clls\/wloc
|
||||
name: WLOC.Location
|
||||
type: response
|
||||
require-body: true
|
||||
binary-mode: true
|
||||
max-size: 0
|
||||
timeout: 30
|
||||
argument: longitude=113.94114&latitude=22.544577&accuracy=25&randomRadius=0&logLevel=info
|
||||
- match: ^https?:\/\/gs-loc(-cn)?\.apple\.com\/wloc-settings\/save
|
||||
name: WLOC.Settings
|
||||
type: request
|
||||
require-body: false
|
||||
timeout: 10
|
||||
|
||||
script-providers:
|
||||
WLOC.Location:
|
||||
url: https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc.js?v=1.0.8
|
||||
interval: 86400
|
||||
WLOC.Settings:
|
||||
url: https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/dist/v1/wloc-settings.js?v=1.0.8
|
||||
interval: 86400
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@location-spoofer/wloc-scripts",
|
||||
"version": "1.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import vm from "node:vm";
|
||||
|
||||
const files = new Map([
|
||||
[new URL("../dist/v1/wloc.js", import.meta.url), "a1b361e60f0b434585260fb59c65d1ddbe3bff89ace3639f592e7d8af432b3c1"],
|
||||
[new URL("../dist/v1/wloc-settings.js", import.meta.url), "cbff047a7f42055615ee19e208ddfc2ec66833e6f2b75555eac84f1c299a66cf"],
|
||||
[new URL("../LICENSE", import.meta.url), "8486a10c4393cee1c25392769ddd3b2d6c242d6ec7928e1414efff7dfb2f07ef"]
|
||||
]);
|
||||
|
||||
test("vendored upstream files retain their pinned SHA-256 hashes", async () => {
|
||||
for (const [file, expectedHash] of files) {
|
||||
const contents = await readFile(file);
|
||||
const actualHash = createHash("sha256").update(contents).digest("hex");
|
||||
assert.equal(actualHash, expectedHash, file.pathname);
|
||||
}
|
||||
});
|
||||
|
||||
test("settings bundle saves and queries wloc_settings in Shadowrocket", async () => {
|
||||
const source = await readFile(new URL("../dist/v1/wloc-settings.js", import.meta.url), "utf8");
|
||||
const storage = new Map();
|
||||
|
||||
const run = (url) => {
|
||||
let result;
|
||||
vm.runInNewContext(source, {
|
||||
$rocket: {},
|
||||
$request: { url },
|
||||
$persistentStore: {
|
||||
read: (key) => storage.get(key) ?? null,
|
||||
write: (value, key) => {
|
||||
storage.set(key, value);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
$done: (value) => { result = value; },
|
||||
console
|
||||
});
|
||||
return JSON.parse(result.response.body);
|
||||
};
|
||||
|
||||
const saved = run("https://gs-loc.apple.com/wloc-settings/save?lon=121.1&lat=31.2&acc=25");
|
||||
assert.equal(saved.success, true);
|
||||
assert.equal(storage.has("wloc_settings"), true);
|
||||
|
||||
const queried = run("https://gs-loc.apple.com/wloc-settings/save?action=query");
|
||||
assert.deepEqual(
|
||||
{ success: queried.success, longitude: queried.longitude, latitude: queried.latitude, accuracy: queried.accuracy },
|
||||
{ success: true, longitude: 121.1, latitude: 31.2, accuracy: 25 }
|
||||
);
|
||||
});
|
||||
@@ -8,7 +8,7 @@ usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: ./build.sh [--test]
|
||||
|
||||
Builds dist/PaopaoLocationSpoofer-unsigned.ipa without signing it.
|
||||
Builds an unsigned IPA with both stable and timestamped filenames in dist/.
|
||||
|
||||
Options:
|
||||
--test After the unsigned IPA is created, run iOS Simulator unit tests.
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
|
||||
## 已发布
|
||||
|
||||
- [v1.0.7](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.7) — 2026-09-11
|
||||
- [v1.0.6](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.6) — 2026-09-01
|
||||
- [v1.0.5](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.5) — 2026-08-10
|
||||
- [v1.0.4](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.4) — 2026-08-09
|
||||
- [v1.0.3](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.3) — 2026-08-09
|
||||
- [v1.0.2](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.2) — 2026-08-07
|
||||
- [v1.0.1](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.1) — 2026-08-06
|
||||
- [v1.0.0](https://github.com/xweiba/location-spoofer/releases/tag/v1.0.0) — 2026-08-05
|
||||
|
||||
@@ -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/`。
|
||||
|
||||
## 审核边界
|
||||
|
||||
- 教程必须来自实际客户端操作,不能把未验证配置描述为已支持。
|
||||
- 截图和步骤不得包含付费节点、共享账号、破解内容或绕过服务限制的方法。
|
||||
- 项目只审核教程是否清晰、是否脱敏以及是否能复现,不为第三方客户端、订阅服务或网络环境提供担保。
|
||||
- 后续客户端版本变化导致界面或步骤失效时,教程可能被标记为待复核或替换。
|
||||
@@ -1,15 +1,25 @@
|
||||
# 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 third-party proxy modules and recovered WLOC scripts are hosted in this
|
||||
repository. The original `Yu9191/wloc` repository was deleted, so it is retained
|
||||
as provenance and is not a runtime dependency.
|
||||
|
||||
- Upstream commit: `eec07a8dc8de6dbaee8eac1fb376e4d03020154a`
|
||||
- Snapshot date: 2026-08-06
|
||||
- Source directory: `modules/`
|
||||
## Hosted files
|
||||
|
||||
| Bundled file | Client |
|
||||
- Mirrored module subscriptions: `Resources/ThirdPartyProxyModules/`
|
||||
- Direct module subscriptions: `ThirdParty/WlocScripts/modules/direct/`
|
||||
- Versioned recovered scripts: `ThirdParty/WlocScripts/dist/v1/`
|
||||
- Integrity and runtime tests: `ThirdParty/WlocScripts/test/`
|
||||
|
||||
The App appends `?v=1.0.8` to module subscription URLs, and every module uses
|
||||
the same cache version for its script URLs:
|
||||
|
||||
- default mirror:
|
||||
`https://gh-proxy.org/https://raw.githubusercontent.com/xweiba/location-spoofer/main/Resources/ThirdPartyProxyModules/<file>?v=1.0.8`
|
||||
- direct:
|
||||
`https://raw.githubusercontent.com/xweiba/location-spoofer/main/ThirdParty/WlocScripts/modules/direct/<file>?v=1.0.8`
|
||||
|
||||
| Module file | Client |
|
||||
|---|---|
|
||||
| `wloc.module` | Shadowrocket |
|
||||
| `wloc.sgmodule` | Surge and Egern |
|
||||
@@ -17,15 +27,39 @@ copies the official subscription URL instead of exporting these files.
|
||||
| `wloc.lpx` | Loon |
|
||||
| `wloc.stoverride` | Stash |
|
||||
|
||||
SHA-256:
|
||||
These URLs become downloadable after the change is merged into `main`. Until
|
||||
then, validate them from the checked-out paths.
|
||||
|
||||
```text
|
||||
bb5e17b60027704971660b0ea2df3560ceff973c27d43e7f2c2c18b48d368ac6 wloc.conf
|
||||
1fb451616fb17242849f72490f016afcdb8aa81a0b086f6dd5f94e1af3d58ee1 wloc.lpx
|
||||
97cab104056428aa0e90521c3bf2646e9739b0b4c83272b31790f99584bca89e wloc.module
|
||||
5d6b82c31316f4a7be65e3b8d2335f4338e01af98e262948118eefe63abf7034 wloc.sgmodule
|
||||
cb06593752db8b223dfa5cd1cbd089115fe3a541f5c8532491615923e83df2cb wloc.stoverride
|
||||
## Script protocol
|
||||
|
||||
`wloc.js` patches Apple WLOC responses and reads coordinates from the
|
||||
`wloc_settings` persistent key or module arguments. `wloc-settings.js`
|
||||
implements `wloc-settings/save` query, clear, and save actions using
|
||||
`lon`/`lat`/`acc`/`randomRadius` parameters.
|
||||
|
||||
The App's third-party save sends WGS-84 `lon`/`lat`/`acc` values. Motion-state
|
||||
simulation is unavailable in third-party mode; it remains available in App Mode
|
||||
through the built-in proxy.
|
||||
|
||||
## Provenance and maintenance
|
||||
|
||||
The prebuilt scripts and license were copied byte-for-byte from the
|
||||
`xepes0/wloc` community restoration pinned at
|
||||
`7f28c5e4fb6b802862988735b92e5c800382f2a2`. The recovered original baseline is
|
||||
`529fcd841952571e79f40faf2d3e0ef90a7bdfa6`. Module metadata keeps the original
|
||||
author attribution while runtime URLs point to this project.
|
||||
|
||||
Full hashes, local differences, regression checks, licensing notes, and the
|
||||
maintenance exit condition are recorded in
|
||||
`ThirdParty/WlocScripts/UPSTREAM.md` and
|
||||
`ThirdParty/WlocScripts/THIRD_PARTY_NOTICES.md`.
|
||||
|
||||
Test the scripts with:
|
||||
|
||||
```bash
|
||||
cd ThirdParty/WlocScripts
|
||||
npm test
|
||||
```
|
||||
|
||||
The official subscription URLs are the setup UI's import path. Egern reuses the
|
||||
Surge module. Stash imports `.stoverride` directly.
|
||||
There is no local rebuild command because the restoration did not recover a
|
||||
complete reproducible source tree for these prebuilt artifacts.
|
||||
|
||||
@@ -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 |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 154 KiB |