diff --git a/App/FirstSetupView.swift b/App/FirstSetupView.swift index 5164591..02b62e5 100644 --- a/App/FirstSetupView.swift +++ b/App/FirstSetupView.swift @@ -52,7 +52,6 @@ struct FirstSetupView: View { @ObservedObject private var runtimeMode = ProxyRuntimeModeStore.shared @ObservedObject private var thirdPartyProxy = ThirdPartyProxyManager.shared @ObservedObject private var thirdPartyClient = ThirdPartyProxyClientStore.shared - @ObservedObject private var motionSimulation = MotionSimulationStore.shared @State private var copiedSubscriptionURL = false @State private var copiedMITMHostname = false @State private var screenshotPreview: SetupScreenshotPreview? @@ -848,17 +847,12 @@ struct FirstSetupView: View { Task { @MainActor in defer { isVerifying = false } do { - let response = try await thirdPartyProxy.validateConnection() - let advancedFeaturesAvailable = await thirdPartyProxy.refreshAdvancedFeatureAvailability() - if !advancedFeaturesAvailable { - motionSimulation.setEnabled(false) - } + 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 ? "已连接,无保存坐标" : "已连接,有保存坐标", - "运动状态模拟": advancedFeaturesAvailable ? "支持" : "不支持,已关闭", "耗时毫秒": String(elapsedMilliseconds) ]) onComplete() diff --git a/App/SettingsView.swift b/App/SettingsView.swift index 0b64175..c0b2778 100644 --- a/App/SettingsView.swift +++ b/App/SettingsView.swift @@ -25,6 +25,7 @@ struct SettingsView: View { @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? @@ -85,15 +86,23 @@ struct SettingsView: View { } Section("定位模拟") { - Toggle("运动状态模拟", isOn: motionSimulationBinding) - .disabled( - modeOperationRunning || - actions.state.isBusy || - thirdPartyProxy.isRequesting - ) - Text("实验性功能,默认关闭。开启后会同时模拟定位响应中的运动状态。") - .font(.footnote) - .foregroundStyle(.secondary) + 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 { @@ -257,16 +266,6 @@ struct SettingsView: View { } message: { Text("当前虚拟定位和本地代理将停止。App 会删除钥匙串中的设备 CA、立即生成新证书,并打开安装与信任引导。你还需要前往 iOS「设置 → 通用 → VPN 与设备管理」手动删除旧证书,然后重新下载安装并完全信任新证书。") } - .task(id: runtimeMode.mode) { - guard runtimeMode.mode == .thirdParty, !modeOperationRunning else { return } - if !(await thirdPartyProxy.refreshAdvancedFeatureAvailability()) { - disableUnsupportedThirdPartyMotionSimulation() - } - } - .onChange(of: thirdPartyProxy.moduleUpdateRecommended) { updateRecommended in - guard updateRecommended else { return } - disableUnsupportedThirdPartyMotionSimulation() - } } private func valueRow(_ title: String, value: String) -> some View { @@ -377,51 +376,18 @@ struct SettingsView: View { Binding( get: { motionSimulation.isEnabled }, set: { enabled in - if runtimeMode.mode == .localWiFi { - proxy.applyMotionSimulation(enabled) - return - } - guard thirdPartyProxy.activeSettings?.success == true else { - guard enabled else { - motionSimulation.setEnabled(false) - return - } - modeOperationRunning = true - Task { @MainActor in - if await thirdPartyProxy.refreshAdvancedFeatureAvailability() { - motionSimulation.setEnabled(true) - } else { - presentMotionSimulationModuleUpdateAlert() - } - modeOperationRunning = false - } - return - } - modeOperationRunning = true - Task { @MainActor in - do { - _ = try await thirdPartyProxy.updateMotionSimulation(enabled) - motionSimulation.setEnabled(enabled) - } catch { - RuntimeLogger.error( - "APP", - "ThirdPartyProxy", - "同步运动状态设置失败", - error: error, - details: ["当前客户端": thirdPartyClient.selectedClient.name] - ) - if error as? ThirdPartyProxyError == .moduleOutdated { - presentMotionSimulationModuleUpdateAlert() - } else { - setup.requestThirdPartySetup(message: error.localizedDescription) - } - } - modeOperationRunning = false - } + proxy.applyMotionSimulation(enabled) } ) } + private var randomRadiusBinding: Binding { + Binding( + get: { randomRadius.isEnabled }, + set: { randomRadius.setEnabled($0) } + ) + } + @ViewBuilder private var thirdPartyConfigurationSection: some View { Section("第三方代理配置") { @@ -442,12 +408,6 @@ struct SettingsView: View { .font(.footnote) .foregroundStyle(.secondary) - if thirdPartyProxy.moduleUpdateRecommended { - Text("当前模块版本较旧,基础坐标功能仍可继续使用。重新导入最新模块后可使用版本检测和运动状态模拟。") - .font(.footnote) - .foregroundStyle(.orange) - } - if let verificationText = thirdPartyClient.selectedClient.verificationText { HStack { Text("验证状态") @@ -548,8 +508,7 @@ struct SettingsView: View { runtimeMode.setMode(.thirdParty) if runtimeMode.isInitialized(.thirdParty) { do { - _ = try await thirdPartyProxy.validateConnection() - refreshThirdPartyAdvancedFeatures() + _ = try await thirdPartyProxy.query() proxyOperationAlertTitle = "模式已切换" proxyOperationError = "第三方代理模式检测通过。请关闭 Wi-Fi 中的 127.0.0.1:8888 手动代理,避免双重拦截。" } catch { @@ -591,8 +550,7 @@ struct SettingsView: View { let startedAt = Date() Task { @MainActor in do { - _ = try await thirdPartyProxy.validateConnection() - refreshThirdPartyAdvancedFeatures() + _ = try await thirdPartyProxy.query() RuntimeLogger.info("APP", "ThirdPartyProxy", "设置页第三方连接检测通过", details: [ "当前客户端": client.name, "请求动作": "WLOC query", @@ -618,25 +576,6 @@ struct SettingsView: View { } } - private func refreshThirdPartyAdvancedFeatures() { - Task { @MainActor in - if !(await thirdPartyProxy.refreshAdvancedFeatureAvailability()) { - disableUnsupportedThirdPartyMotionSimulation() - } - } - } - - private func presentMotionSimulationModuleUpdateAlert() { - disableUnsupportedThirdPartyMotionSimulation() - proxyOperationAlertTitle = "无法开启运动状态模拟" - proxyOperationError = "当前模块脚本不支持运动状态模拟,请重新导入最新模块脚本后再开启。基础坐标功能仍可继续使用。" - } - - private func disableUnsupportedThirdPartyMotionSimulation() { - guard runtimeMode.mode == .thirdParty else { return } - motionSimulation.setEnabled(false) - } - private func openThirdPartySetup(for error: Error) { setup.requestThirdPartySetup(message: error.localizedDescription) dismiss() diff --git a/Shared/AppGroup.swift b/Shared/AppGroup.swift index fc9c05a..2153b4a 100644 --- a/Shared/AppGroup.swift +++ b/Shared/AppGroup.swift @@ -66,6 +66,36 @@ final class MotionSimulationStore: ObservableObject { } } +@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() diff --git a/Shared/ThirdPartyProxyManager.swift b/Shared/ThirdPartyProxyManager.swift index bbcd650..3b83d4f 100644 --- a/Shared/ThirdPartyProxyManager.swift +++ b/Shared/ThirdPartyProxyManager.swift @@ -9,21 +9,6 @@ struct ThirdPartyProxySettingsResponse: Decodable, Equatable { let motionSimulationEnabled: Bool? } -struct ThirdPartyProxyVersionResponse: Decodable, Equatable { - let success: Bool - let moduleVersion: String - let protocolVersion: Int - let capabilities: Set - - static let requiredCapabilities: Set = [ - "wifi", "cellTower", "arpc", "marker", "synthetic", "bare", "motionSimulation" - ] - - var isCompatible: Bool { - success && protocolVersion >= 1 && capabilities.isSuperset(of: Self.requiredCapabilities) - } -} - enum ThirdPartyProxyConnectionState: Equatable { case unknown case connected(active: Bool) @@ -36,7 +21,6 @@ enum ThirdPartyProxyError: LocalizedError, Equatable { case rejected(String) case coordinateMismatch case network(String) - case moduleOutdated var errorDescription: String? { switch self { @@ -50,18 +34,11 @@ enum ThirdPartyProxyError: LocalizedError, Equatable { return "第三方代理保存的坐标与当前选点不一致" case .network(let message): return "第三方代理请求失败:\(message)" - case .moduleOutdated: - return "模块版本过低,请重新导入模块" } } var recoverySuggestion: String { - switch self { - case .moduleOutdated: - return "删除旧模块后,重新复制并导入最新模块" - default: - return "检查模块、MITM、证书和代理/VPN连接" - } + return "检查模块、MITM、证书和代理/VPN连接" } static func recoverySuggestion(for error: Error) -> String { @@ -88,11 +65,9 @@ final class ThirdPartyProxyManager: ObservableObject { ] static let interceptionHostnamesText = interceptionHostnames.joined(separator: ", ") static let configurationEndpoint = URL(string: "https://gs-loc.apple.com/wloc-settings/save")! - static let versionEndpoint = URL(string: "https://gs-loc.apple.com/wloc-settings/version")! @Published private(set) var connectionState: ThirdPartyProxyConnectionState = .unknown @Published private(set) var activeSettings: ThirdPartyProxySettingsResponse? - @Published private(set) var moduleUpdateRecommended = false @Published private(set) var isRequesting = false private let requester: any ThirdPartyProxyRequesting @@ -127,7 +102,8 @@ 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 ?? "第三方代理拒绝保存坐标") @@ -148,72 +124,6 @@ final class ThirdPartyProxyManager: ObservableObject { return response } - func updateMotionSimulation(_ enabled: Bool) async throws -> ThirdPartyProxySettingsResponse { - // Upstream Yu9191/wloc scripts do not implement motion-state simulation, - // so this can never succeed in third-party mode. - RuntimeLogger.warning("APP", "ThirdPartyProxy", "第三方模式不支持运动状态模拟", details: [ - "请求动作": "motion simulation", - "处理建议": "运动状态模拟仅在 APP模式(内置代理)下可用" - ]) - throw ThirdPartyProxyError.moduleOutdated - } - - func validateConnection() async throws -> ThirdPartyProxySettingsResponse { - try await query() - } - - func validateVersion() async throws -> ThirdPartyProxyVersionResponse { - guard !isRequesting else { - throw ThirdPartyProxyError.rejected("已有第三方代理请求正在执行") - } - isRequesting = true - defer { isRequesting = false } - - var request = URLRequest(url: Self.versionEndpoint) - request.httpMethod = "GET" - request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData - request.timeoutInterval = 8 - do { - let (data, response) = try await requester.data(for: request) - guard let http = response as? HTTPURLResponse, http.statusCode == 200, - let version = try? JSONDecoder().decode(ThirdPartyProxyVersionResponse.self, from: data), - version.isCompatible else { - throw ThirdPartyProxyError.moduleOutdated - } - RuntimeLogger.info("APP", "ThirdPartyProxy", "第三方模块版本检测通过", details: [ - "模块版本": version.moduleVersion, - "协议版本": String(version.protocolVersion), - "能力": version.capabilities.sorted().joined(separator: ",") - ]) - return version - } catch let error as ThirdPartyProxyError { - throw error - } catch { - throw ThirdPartyProxyError.network(error.localizedDescription) - } - } - - @discardableResult - func refreshAdvancedFeatureAvailability() async -> Bool { - do { - _ = try await validateVersion() - moduleUpdateRecommended = false - return true - } catch { - moduleUpdateRecommended = true - RuntimeLogger.warning( - "APP", - "ThirdPartyProxy", - "第三方模块不支持高级功能", - details: [ - "版本检测": error.localizedDescription, - "处理建议": "基础坐标功能可继续使用;更新模块后可使用运动状态模拟" - ] - ) - return false - } - } - func clear() async throws { let response = try await perform(action: .clear) guard response.success else { @@ -238,7 +148,7 @@ final class ThirdPartyProxyManager: ObservableObject { 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 } @@ -255,11 +165,12 @@ final class ThirdPartyProxyManager: ObservableObject { 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(randomRadius)) ] } guard let url = components.url else { throw ThirdPartyProxyError.invalidResponse } diff --git a/Tests/PaopaoLocationSpooferTests/ThirdPartyProxyManagerTests.swift b/Tests/PaopaoLocationSpooferTests/ThirdPartyProxyManagerTests.swift index b29eac9..39aea45 100644 --- a/Tests/PaopaoLocationSpooferTests/ThirdPartyProxyManagerTests.swift +++ b/Tests/PaopaoLocationSpooferTests/ThirdPartyProxyManagerTests.swift @@ -40,44 +40,17 @@ final class ThirdPartyProxyManagerTests: XCTestCase { XCTAssertEqual(manager.connectionState, .connected(active: true)) } - func testVersionEndpointRequiresProtocolAndCapabilities() async throws { - let requester = FakeThirdPartyRequester(body: #"{"success":false,"error":"无已保存的坐标"}"#) - let manager = ThirdPartyProxyManager(requester: requester) - - let version = try await manager.validateVersion() - - XCTAssertEqual(requester.lastURL?.path, "/wloc-settings/version") - XCTAssertEqual(version.moduleVersion, "1.0.0") - XCTAssertTrue(version.isCompatible) - } - func testConnectionUsesLegacySaveQueryEndpoint() async throws { let requester = FakeThirdPartyRequester(body: #"{"success":false,"error":"无已保存的坐标"}"#) let manager = ThirdPartyProxyManager(requester: requester) - let response = try await manager.validateConnection() + let response = try await manager.query() XCTAssertFalse(response.success) - XCTAssertFalse(manager.moduleUpdateRecommended) XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/save"]) XCTAssertEqual(requester.requestedURLs.first?.query, "action=query") } - func testMissingVersionDisablesOnlyAdvancedFeatures() async { - let requester = FakeThirdPartyRequester( - body: #"{"success":false,"error":"无已保存的坐标"}"#, - versionBody: "not-json" - ) - let manager = ThirdPartyProxyManager(requester: requester) - - let isAvailable = await manager.refreshAdvancedFeatureAvailability() - - XCTAssertFalse(isAvailable) - XCTAssertTrue(manager.moduleUpdateRecommended) - XCTAssertEqual(manager.connectionState, .unknown) - XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/version"]) - } - func testLegacyModuleCanStillSaveBasicCoordinates() async throws { let favorite = FavoriteLocation( name: "深圳湾", @@ -93,16 +66,25 @@ final class ThirdPartyProxyManagerTests: XCTestCase { wgs84.longitude, wgs84.latitude ) - let requester = FakeThirdPartyRequester(body: body, versionBody: "not-json") + let requester = FakeThirdPartyRequester(body: body) let manager = ThirdPartyProxyManager(requester: requester) let response = try await manager.save(favorite) XCTAssertTrue(response.success) - XCTAssertFalse(manager.moduleUpdateRecommended) XCTAssertEqual(manager.connectionState, .connected(active: true)) XCTAssertEqual(requester.requestedURLs.map(\.path), ["/wloc-settings/save"]) - XCTAssertNil(requester.requestedURLs.first?.query) + 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 { @@ -110,7 +92,7 @@ final class ThirdPartyProxyManagerTests: XCTestCase { let manager = ThirdPartyProxyManager(requester: requester) do { - _ = try await manager.validateConnection() + _ = try await manager.query() XCTFail("expected interception failure") } catch { XCTAssertEqual(error as? ThirdPartyProxyError, .moduleNotIntercepted) @@ -189,16 +171,11 @@ final class ThirdPartyProxyManagerTests: XCTestCase { private final class FakeThirdPartyRequester: ThirdPartyProxyRequesting { private let data: Data - private let versionData: Data private(set) var lastURL: URL? private(set) var requestedURLs: [URL] = [] - init( - body: String, - versionBody: String = #"{"success":true,"moduleVersion":"1.0.0","protocolVersion":1,"capabilities":["wifi","cellTower","arpc","marker","synthetic","bare","motionSimulation"]}"# - ) { + init(body: String) { data = Data(body.utf8) - versionData = Data(versionBody.utf8) } func data(for request: URLRequest) async throws -> (Data, URLResponse) { @@ -210,6 +187,6 @@ private final class FakeThirdPartyRequester: ThirdPartyProxyRequesting { httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "application/json"] )! - return (request.url?.path == "/wloc-settings/version" ? versionData : data, response) + return (data, response) } } diff --git a/Tests/third_party_mode_contract_test.sh b/Tests/third_party_mode_contract_test.sh index 1741b0a..93e5e80 100755 --- a/Tests/third_party_mode_contract_test.sh +++ b/Tests/third_party_mode_contract_test.sh @@ -67,43 +67,19 @@ grep -q 'Label("查看诊断日志"' "$SETUP" \ || fail "third-party connection failure must expose the diagnostics action" ! grep -q 'setupActionError = error.localizedDescription' "$SETUP" \ || fail "third-party connection failure must not use the generic failure alert" -grep -q 'let response = try await thirdPartyProxy.validateConnection()' "$SETUP" \ - || fail "the import page must validate the legacy save/query endpoint" -grep -A20 'let response = try await thirdPartyProxy.validateConnection()' "$SETUP" \ - | grep -q 'refreshAdvancedFeatureAvailability()' \ - || fail "the import page must also probe advanced module capabilities" -grep -A20 'let response = try await thirdPartyProxy.validateConnection()' "$SETUP" \ - | grep -q 'motionSimulation.setEnabled(false)' \ - || fail "an incompatible module must turn motion simulation off without failing basic setup" -grep -A20 'let response = try await thirdPartyProxy.validateConnection()' "$SETUP" | grep -q 'onComplete()' \ +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 -q 'thirdPartyProxy.moduleUpdateRecommended' "$SETTINGS" \ - || fail "Settings must react to legacy module compatibility mode" -grep -q '当前模块版本较旧,基础坐标功能仍可继续使用' "$SETTINGS" \ - || fail "Settings must explain that legacy modules retain basic coordinate support" -! grep -A8 'Toggle("运动状态模拟"' "$SETTINGS" | grep -q 'thirdPartyProxy.moduleUpdateRecommended' \ - || fail "legacy module compatibility must turn motion simulation off without disabling its toggle" -grep -q 'refreshAdvancedFeatureAvailability' "$SETTINGS" \ - || fail "Settings must probe advanced module availability independently" -grep -A18 'guard thirdPartyProxy.activeSettings?.success == true else' "$SETTINGS" \ - | grep -q 'refreshAdvancedFeatureAvailability()' \ - || fail "enabling inactive third-party motion simulation must validate the version endpoint first" -grep -A18 'guard thirdPartyProxy.activeSettings?.success == true else' "$SETTINGS" \ - | grep -q 'motionSimulation.setEnabled(true)' \ - || fail "inactive third-party motion simulation may enable only after version validation succeeds" -grep -q 'proxyOperationAlertTitle = "无法开启运动状态模拟"' "$SETTINGS" \ - || fail "motion simulation must show a dedicated failure alert when version validation fails" -grep -q '请重新导入最新模块脚本后再开启' "$SETTINGS" \ - || fail "motion simulation failure must tell the user to update the module script" -test "$(grep -c 'presentMotionSimulationModuleUpdateAlert()' "$SETTINGS")" -ge 3 \ - || fail "inactive and active motion simulation paths must share the module-update alert" -grep -q 'onChange(of: thirdPartyProxy.moduleUpdateRecommended)' "$SETTINGS" \ - || fail "Settings must react when an installed module becomes incompatible" -grep -A5 'private func disableUnsupportedThirdPartyMotionSimulation()' "$SETTINGS" \ - | grep -q 'motionSimulation.setEnabled(false)' \ - || fail "unsupported third-party modules must force motion simulation off before disabling the toggle" +grep -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" \