feat: add third-party proxy mode and improve onboarding

This commit is contained in:
xweiba
2026-08-06 19:20:25 +08:00
parent e86909cc2f
commit b52d72bef2
36 changed files with 1946 additions and 218 deletions
@@ -82,6 +82,46 @@ final class FavoriteLocationStoreTests: XCTestCase {
XCTAssertFalse(pair.matchesWGS84(latitude: gcj.latitude, longitude: gcj.longitude))
}
func testCoordinateRepresentationDiagnosisDistinguishesDomesticPair() {
let pair = CoordinateConverter.coordinatePair(
lat: 22.539,
lon: 113.934,
mapCoordinateSystem: .wgs84
)
XCTAssertEqual(
CoordinateConverter.diagnoseRepresentation(sample: pair.wgs84.coordinate, pair: pair).inferredSystem,
.wgs84
)
XCTAssertEqual(
CoordinateConverter.diagnoseRepresentation(sample: pair.gcj02.coordinate, pair: pair).inferredSystem,
.gcj02
)
}
func testCoordinateRepresentationDiagnosisKeepsOverseasIdentityPairAmbiguous() {
let pair = CoordinateConverter.coordinatePair(
lat: 48.858_37,
lon: 2.294_481,
mapCoordinateSystem: .wgs84
)
XCTAssertNil(
CoordinateConverter.diagnoseRepresentation(sample: pair.wgs84.coordinate, pair: pair).inferredSystem
)
}
func testCoordinateRepresentationDiagnosisRejectsUnrelatedSample() {
let pair = CoordinateConverter.coordinatePair(
lat: 22.539,
lon: 113.934,
mapCoordinateSystem: .wgs84
)
let unrelated = CLLocationCoordinate2D(latitude: 31.2304, longitude: 121.4737)
XCTAssertNil(CoordinateConverter.diagnoseRepresentation(sample: unrelated, pair: pair).inferredSystem)
}
func testMapConfigurationNeverRequestsRealUserLocation() {
XCTAssertFalse(MapConfiguration.default.showsUserLocation)
XCTAssertFalse(MapConfiguration.default.allowsCurrentLocationRequest)
@@ -0,0 +1,20 @@
import XCTest
@testable import PaopaoLocationSpoofer
@MainActor
final class ProxyRuntimeModeTests: XCTestCase {
func testDefaultsToLocalWiFiAndPersistsThirdPartyMode() {
let suiteName = "ProxyRuntimeModeTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
let initial = ProxyRuntimeModeStore(defaults: defaults)
XCTAssertEqual(initial.mode, .localWiFi)
XCTAssertFalse(initial.hasSelectedMode)
initial.setMode(.thirdParty)
let restored = ProxyRuntimeModeStore(defaults: defaults)
XCTAssertEqual(restored.mode, .thirdParty)
XCTAssertTrue(restored.hasSelectedMode)
}
}
@@ -0,0 +1,113 @@
import XCTest
@testable import PaopaoLocationSpoofer
@MainActor
final class ThirdPartyProxyManagerTests: XCTestCase {
func testQueryDistinguishesConnectedWithoutCoordinate() async throws {
let requester = FakeThirdPartyRequester(body: #"{"success":false,"error":""}"#)
let manager = ThirdPartyProxyManager(requester: requester)
let response = try await manager.query()
XCTAssertFalse(response.success)
XCTAssertEqual(manager.connectionState, .connected(active: false))
XCTAssertEqual(requester.lastURL?.query, "action=query")
}
func testSaveUsesFavoriteWGS84AndAcceptsMatchingResponse() 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)
_ = try await manager.save(favorite)
let components = URLComponents(url: try XCTUnwrap(requester.lastURL), resolvingAgainstBaseURL: false)
let values = Dictionary(uniqueKeysWithValues: (components?.queryItems ?? []).map { ($0.name, $0.value ?? "") })
let latitude = try XCTUnwrap(Double(values["lat"] ?? ""))
let longitude = try XCTUnwrap(Double(values["lon"] ?? ""))
XCTAssertEqual(latitude, wgs84.latitude, accuracy: 0.000_000_01)
XCTAssertEqual(longitude, wgs84.longitude, accuracy: 0.000_000_01)
XCTAssertEqual(values["acc"], "20")
XCTAssertEqual(manager.connectionState, .connected(active: true))
}
func testSaveRejectsCoordinateMismatchWithoutMarkingActive() async {
let requester = FakeThirdPartyRequester(body: #"{"success":true,"longitude":1,"latitude":2,"accuracy":25}"#)
let manager = ThirdPartyProxyManager(requester: requester)
let favorite = FavoriteLocation(name: "深圳湾", latitude: 22.494, longitude: 113.951, accuracy: 25)
do {
_ = try await manager.save(favorite)
XCTFail("expected coordinate mismatch")
} catch {
XCTAssertEqual(error as? ThirdPartyProxyError, .coordinateMismatch)
}
XCTAssertEqual(manager.connectionState, .unknown)
XCTAssertNil(manager.activeSettings)
}
func testMalformedResponseIsNotTreatedAsSuccess() async {
let manager = ThirdPartyProxyManager(requester: FakeThirdPartyRequester(body: "not-json"))
do {
_ = try await manager.query()
XCTFail("expected interception failure")
} catch {
XCTAssertEqual(error as? ThirdPartyProxyError, .moduleNotIntercepted)
}
}
func testClientLinksUseOfficialUpstreamModulesAndVerificationLabels() {
XCTAssertEqual(ThirdPartyProxyClient.shadowrocket.verificationText, "当前可测试")
XCTAssertTrue(ThirdPartyProxyClient.surge.verificationText.contains("尚未验证"))
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"))
XCTAssertEqual(ThirdPartyProxyClient.shadowrocket.launchURL?.scheme, "shadowrocket")
XCTAssertEqual(ThirdPartyProxyClient.surge.launchURL?.scheme, "surge")
XCTAssertEqual(ThirdPartyProxyClient.quantumultX.launchURL?.scheme, "quantumult-x")
XCTAssertEqual(ThirdPartyProxyClient.loon.launchURL?.scheme, "loon")
XCTAssertEqual(ThirdPartyProxyClient.stash.launchURL?.scheme, "stash")
XCTAssertEqual(ThirdPartyProxyClient.egern.launchURL?.scheme, "egern")
}
func testSelectedClientPersists() {
let suiteName = "ThirdPartyProxyClientStoreTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = ThirdPartyProxyClientStore(defaults: defaults)
XCTAssertEqual(store.selectedClient, .shadowrocket)
store.select(.stash)
XCTAssertEqual(ThirdPartyProxyClientStore(defaults: defaults).selectedClient, .stash)
}
}
private final class FakeThirdPartyRequester: ThirdPartyProxyRequesting {
private let data: Data
private(set) var lastURL: URL?
init(body: String) {
data = Data(body.utf8)
}
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
lastURL = request.url
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"]
)!
return (data, response)
}
}
+3 -1
View File
@@ -25,6 +25,8 @@ done
! grep -q '@Binding var coordinate' "$MAP_BRIDGE" || fail "map bridge must not write a coordinate Binding"
grep -q 'showsUserLocation = true' "$MAP_BRIDGE" || fail "MapKit native user location must be visible"
grep -q 'didUpdate userLocation' "$MAP_BRIDGE" || fail "MapKit native user location must feed realtime state"
grep -q 'coordinate: userLocation.coordinate' "$MAP_BRIDGE" || fail "visible MapKit blue-point samples must use MKUserLocation.coordinate"
! grep -Eq 'userLocation\.location\??\.coordinate' "$MAP_BRIDGE" || fail "MapKit blue-point samples must not use the underlying Core Location coordinate"
grep -q 'MapCameraCommand' "$MAP_BRIDGE" || fail "map bridge must consume MapCameraCommand"
grep -q 'let initialViewportMeters:' "$MAP_BRIDGE" || fail "map bridge must receive initial viewport from MapLocationState"
! grep -q 'ViewportStore.loadOrDefault()' "$MAP_BRIDGE" || fail "map bridge must not bypass MapLocationState for initial viewport"
@@ -72,7 +74,7 @@ grep -q '地图创建前请求实时定位' "$CONTENT" || fail "fresh realtime p
! grep -q '瓦片检测' "$CONVERTER" || fail "coordinate-system probe logs must not claim to inspect map tiles"
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 = 5_000_000_000' "$MAP_HOME" || fail "Wi-Fi changes must wait five seconds before environment verification"
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"
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
fail() { echo "FAIL: $*" >&2; exit 1; }
MODE="$ROOT/Shared/ProxyRuntimeMode.swift"
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 '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 '生成并导入配置文件' "$SETUP" || fail "setup must not offer file generation/import"
grep -q '复制订阅地址' "$SETUP" || fail "subscription URL copy action is missing"
grep -Fq 'Label("打开 \(client.name)"' "$SETUP" || fail "setup must expose a 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"
for file in wloc.module wloc.sgmodule wloc.conf wloc.lpx wloc.stoverride; do
test -s "$MODULES/$file" || fail "missing bundled module: $file"
done
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 -q '配置 → 模块' "$SETUP" || fail "Shadowrocket module import guidance is missing"
grep -q 'HTTPS 解密' "$SETUP" || fail "Shadowrocket HTTPS decryption guidance is missing"
! 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.1"' "$ROOT/project.yml" || fail "marketing version must be 1.0.1"
grep -q 'CURRENT_PROJECT_VERSION: "2"' "$ROOT/project.yml" || fail "build version must be 2"
echo "PASS: third-party proxy mode contract"