From b76d6e0854cfb5136f1ac0714799d96e7a905398 Mon Sep 17 00:00:00 2001 From: ryme Date: Mon, 21 Sep 2026 00:28:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20macOS=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=E6=8C=89=E9=92=AE=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E6=81=A2=E5=A4=8D=E5=A4=B1=E8=B4=A5=E5=90=8E?= =?UTF-8?q?=E7=9A=84=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 调整锁定、恢复与选点操作的布局,取消恢复时待执行的定位刷新。保留未恢复的 PAC 备份,支持重试及重启后恢复,并补充回归测试和教程说明。 --- WLocApp/WLocAppMac/AppWLocPACManager.swift | 43 ++++++--- .../WLocAppMac/WLocMacMapViewController.swift | 84 ++++++++++++++--- .../WLocMacTutorialWindowController.swift | 4 +- tests/test_pac_restore.py | 92 +++++++++++++++++++ 4 files changed, 192 insertions(+), 31 deletions(-) create mode 100644 tests/test_pac_restore.py diff --git a/WLocApp/WLocAppMac/AppWLocPACManager.swift b/WLocApp/WLocAppMac/AppWLocPACManager.swift index 158a55c..fde57b9 100644 --- a/WLocApp/WLocAppMac/AppWLocPACManager.swift +++ b/WLocApp/WLocAppMac/AppWLocPACManager.swift @@ -89,11 +89,9 @@ final class AppWLocPACManager { try ensureHelper() let defaults = UserDefaults.standard - if let data = defaults.data(forKey: savedSettingsKey), - let staleSettings = try? JSONDecoder().decode([String: SavedPACSetting].self, from: data), - !staleSettings.isEmpty { - restorePAC(staleSettings) - defaults.removeObject(forKey: savedSettingsKey) + try loadSavedSettings() + if let error = restorePAC(previousSettings) { + throw error } let services = try activeNetworkServices() @@ -153,12 +151,11 @@ final class AppWLocPACManager { @discardableResult private func stop() -> Error? { var restoreError: Error? - if !previousSettings.isEmpty { + do { + try loadSavedSettings() restoreError = restorePAC(previousSettings) - previousSettings.removeAll() - if restoreError == nil { - UserDefaults.standard.removeObject(forKey: savedSettingsKey) - } + } catch { + restoreError = error } if pacServer.isRunning { pacServer.stop() } proxyServer.stop() @@ -167,9 +164,17 @@ final class AppWLocPACManager { return restoreError } + private func loadSavedSettings() throws { + guard previousSettings.isEmpty, + let data = UserDefaults.standard.data(forKey: savedSettingsKey) else { return } + previousSettings = try JSONDecoder().decode([String: SavedPACSetting].self, from: data) + } + @discardableResult private func restorePAC(_ settings: [String: SavedPACSetting]) -> Error? { - var restoreError: Error? + guard !settings.isEmpty else { return nil } + var failures: [String] = [] + var remaining = settings for (service, setting) in settings { do { try setPAC([AppWLocPACNetworkSetting( @@ -177,12 +182,22 @@ final class AppWLocPACManager { url: setting.url, enabled: setting.enabled )]) + remaining.removeValue(forKey: service) } catch { - restoreError = error - AppWLocUtils.debugLog("PAC 忽略无法恢复的网络服务 \(service):\(error.localizedDescription)") + failures.append("\(service):\(error.localizedDescription)") } } - return restoreError + previousSettings = remaining + if remaining.isEmpty { + UserDefaults.standard.removeObject(forKey: savedSettingsKey) + } else { + do { + UserDefaults.standard.set(try JSONEncoder().encode(remaining), forKey: savedSettingsKey) + } catch { + return error + } + } + return failures.isEmpty ? nil : AppWLocPACError.commandFailed(failures.sorted().joined(separator: "\n")) } private func ensureHelper() throws { diff --git a/WLocApp/WLocAppMac/WLocMacMapViewController.swift b/WLocApp/WLocAppMac/WLocMacMapViewController.swift index b69cae3..17c53e9 100644 --- a/WLocApp/WLocAppMac/WLocMacMapViewController.swift +++ b/WLocApp/WLocAppMac/WLocMacMapViewController.swift @@ -90,16 +90,16 @@ private final class WLocMacLinkButton: NSButton { } private func updateAppearance() { - let alpha: CGFloat = isPointerInside ? 0.92 : 0.78 + let alpha: CGFloat = isPointerInside ? 0.14 : 0.04 layer?.backgroundColor = baseColor.withAlphaComponent(alpha).cgColor attributedTitle = NSAttributedString( string: title, attributes: [ - .foregroundColor: NSColor.white, + .foregroundColor: NSColor.secondaryLabelColor, .font: NSFont.systemFont(ofSize: 13, weight: .semibold) ] ) - contentTintColor = .white + contentTintColor = .secondaryLabelColor } } @@ -126,6 +126,7 @@ final class WLocMacMapViewController: NSViewController { private let detailLabel = NSTextField.wlocLabel("") private let coordinateLabel = NSTextField.wlocLabel("") private let lockButton = NSButton.wlocButton("锁定位置") + private let restoreButton = NSButton.wlocButton("恢复定位") private let favoriteButton = NSButton.wlocButton("加入收藏") private let coordinateInputButton = NSButton.wlocButton("经纬度选点") private let tutorialButton = NSButton.wlocButton("教程与证书") @@ -156,8 +157,11 @@ final class WLocMacMapViewController: NSViewController { private var availableUpdate: AppWLocAvailableUpdate? private var shouldSelectNextLocationUpdate = false private var isRefreshingLocationAfterLock = false + private var isChangingLocation = false + private var locationRefreshWorkItem: DispatchWorkItem? deinit { + locationRefreshWorkItem?.cancel() if let outsideSearchClickMonitor { NSEvent.removeMonitor(outsideSearchClickMonitor) } @@ -268,7 +272,7 @@ final class WLocMacMapViewController: NSViewController { favoriteMenu.addItem(deleteItem) favoritesTable.menu = favoriteMenu - [lockButton, favoriteButton, coordinateInputButton, tutorialButton, telegramButton, githubButton].forEach { + [lockButton, restoreButton, favoriteButton, coordinateInputButton, tutorialButton, telegramButton, githubButton].forEach { $0.bezelStyle = .rounded $0.controlSize = .regular } @@ -285,6 +289,8 @@ final class WLocMacMapViewController: NSViewController { button.font = .systemFont(ofSize: 18, weight: .semibold) } + restoreButton.target = self + restoreButton.action = #selector(restoreLocation) lockButton.target = self lockButton.action = #selector(lockCurrentPlace) favoriteButton.target = self @@ -383,12 +389,18 @@ final class WLocMacMapViewController: NSViewController { favoritesScroll.hasVerticalScroller = true favoritesScroll.borderType = .noBorder - let actionStack = NSStackView(views: [lockButton, favoriteButton, coordinateInputButton]) + let actionStack = NSStackView(views: [lockButton, restoreButton]) actionStack.orientation = .horizontal actionStack.spacing = 10 actionStack.distribution = .fillEqually - let secondaryActionStack = NSStackView(views: [tutorialButton]) + lockButton.bezelColor = .controlAccentColor + let selectionStack = NSStackView(views: [favoriteButton, coordinateInputButton]) + selectionStack.orientation = .horizontal + selectionStack.spacing = 10 + selectionStack.distribution = .fillEqually + + let secondaryActionStack = NSStackView(views: [selectionStack, tutorialButton]) secondaryActionStack.orientation = .vertical secondaryActionStack.spacing = 15 secondaryActionStack.distribution = .fillEqually @@ -397,19 +409,17 @@ final class WLocMacMapViewController: NSViewController { externalLinkStack.orientation = .horizontal externalLinkStack.spacing = 10 externalLinkStack.distribution = .fillEqually - secondaryActionStack.addArrangedSubview(externalLinkStack) - - externalLinkStack.snp.makeConstraints { make in - make.width.equalTo(secondaryActionStack).offset(-36) + selectionStack.snp.makeConstraints { make in + make.width.equalTo(secondaryActionStack) } - [lockButton, favoriteButton, coordinateInputButton, tutorialButton, telegramButton, githubButton].forEach { button in + [lockButton, restoreButton, favoriteButton, coordinateInputButton, tutorialButton, telegramButton, githubButton].forEach { button in button.snp.makeConstraints { make in make.height.equalTo(36) } } - [appNameLabel, versionLabel, updateButton, searchField, titleLabel, detailLabel, coordinateLabel, actionStack, secondaryActionStack, favoriteTitle, favoritesScroll].forEach { + [appNameLabel, versionLabel, updateButton, searchField, titleLabel, detailLabel, coordinateLabel, actionStack, secondaryActionStack, favoriteTitle, favoritesScroll, externalLinkStack].forEach { sidebar.contentView.addSubview($0) } @@ -450,7 +460,7 @@ final class WLocMacMapViewController: NSViewController { } secondaryActionStack.snp.makeConstraints { make in make.top.equalTo(actionStack.snp.bottom).offset(10) - make.leading.trailing.equalToSuperview() + make.leading.trailing.equalTo(searchField) } favoriteTitle.snp.makeConstraints { make in make.top.equalTo(secondaryActionStack.snp.bottom).offset(20) @@ -459,7 +469,11 @@ final class WLocMacMapViewController: NSViewController { favoritesScroll.snp.makeConstraints { make in make.top.equalTo(favoriteTitle.snp.bottom).offset(8) make.leading.trailing.equalTo(searchField) - make.bottom.equalToSuperview().inset(16) + make.bottom.equalTo(externalLinkStack.snp.top).offset(-12) + } + externalLinkStack.snp.makeConstraints { make in + make.leading.trailing.equalTo(searchField) + make.bottom.equalToSuperview().inset(12) } searchResultsPanel.snp.makeConstraints { make in @@ -672,18 +686,26 @@ final class WLocMacMapViewController: NSViewController { } private func lock(_ place: AppWLocPlace, successMessage: String) { + guard !isChangingLocation else { return } + isChangingLocation = true + locationRefreshWorkItem?.cancel() + restoreButton.isEnabled = false lockButton.isEnabled = false lockButton.title = "锁定中..." pacManager.lock(to: place) { [weak self] result in AppWLocUtils.mainThread { guard let self else { return } + self.isChangingLocation = false + self.restoreButton.isEnabled = true self.lockButton.isEnabled = true switch result { case .success: self.lockButton.title = "锁定位置" - AppWLocUtils.mainThreadAfter(2.0) { [weak self] in + let refresh = DispatchWorkItem { [weak self] in self?.startSystemLocationRefresh(selectResult: false) } + self.locationRefreshWorkItem = refresh + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: refresh) self.showAlert(title: "已锁定", message: successMessage) case .failure(let error): self.lockButton.title = "锁定位置" @@ -693,6 +715,33 @@ final class WLocMacMapViewController: NSViewController { } } + @objc private func restoreLocation() { + guard !isChangingLocation else { return } + isChangingLocation = true + locationRefreshWorkItem?.cancel() + locationRefreshWorkItem = nil + shouldSelectNextLocationUpdate = false + isRefreshingLocationAfterLock = false + locationManager.stopUpdatingLocation() + restoreButton.isEnabled = false + lockButton.isEnabled = false + restoreButton.title = "恢复中…" + + pacManager.stop(clearState: true) { [weak self] error in + guard let self else { return } + self.isChangingLocation = false + self.restoreButton.isEnabled = true + self.lockButton.isEnabled = true + if let error { + self.restoreButton.title = "重试恢复" + self.showAlert(title: "恢复失败", message: "原代理设置尚未全部恢复,请重试。\n\n\(error.localizedDescription)") + } else { + self.restoreButton.title = "恢复定位" + self.showAlert(title: "已停止修改定位", message: "已恢复原代理设置。请关闭系统定位服务,等待两秒后重新开启,以刷新实际位置。") + } + } + } + @discardableResult func handleDeepLink(_ url: URL) -> Bool { do { @@ -712,6 +761,7 @@ final class WLocMacMapViewController: NSViewController { } private func applyExternalLocation(_ place: AppWLocPlace) { + guard !isChangingLocation else { return } view.window?.makeFirstResponder(nil) reverseGeocodeWorkItem?.cancel() geocoder.cancelGeocode() @@ -933,6 +983,7 @@ final class WLocMacMapViewController: NSViewController { } private func startSystemLocationRefresh(selectResult: Bool) { + guard !isChangingLocation else { return } shouldSelectNextLocationUpdate = selectResult isRefreshingLocationAfterLock = !selectResult mapView.showsUserLocation = true @@ -962,6 +1013,7 @@ final class WLocMacMapViewController: NSViewController { } private func handleLocationAuthorizationChange(_ status: CLAuthorizationStatus) { + guard !isChangingLocation else { return } let hasLockedState = AppWLocStateStore.shared.load() != nil AppWLocUtils.debugLog( "\(AppWLocConfig.displayName) macOS 定位授权变化 status=\(authorizationStatusDescription(status)),afterLock=\(isRefreshingLocationAfterLock),selectNext=\(shouldSelectNextLocationUpdate),locked=\(hasLockedState)" @@ -1148,6 +1200,7 @@ extension WLocMacMapViewController: CLLocationManagerDelegate { } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard !isChangingLocation else { return } guard let location = locations.last else { return } manager.stopUpdatingLocation() AppWLocUtils.debugLog( @@ -1162,6 +1215,7 @@ extension WLocMacMapViewController: CLLocationManagerDelegate { } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + guard !isChangingLocation else { return } manager.stopUpdatingLocation() AppWLocUtils.debugLog("\(AppWLocConfig.displayName) macOS 定位刷新失败:\(error.localizedDescription)") let shouldShowError = shouldSelectNextLocationUpdate || !isRefreshingLocationAfterLock diff --git a/WLocApp/WLocAppMac/WLocMacTutorialWindowController.swift b/WLocApp/WLocAppMac/WLocMacTutorialWindowController.swift index 4bba4c6..1a4e90a 100644 --- a/WLocApp/WLocAppMac/WLocMacTutorialWindowController.swift +++ b/WLocApp/WLocAppMac/WLocMacTutorialWindowController.swift @@ -81,7 +81,7 @@ final class WLocMacTutorialViewController: NSViewController { addTitle("二、锁定位置") addStep("1. 在主界面搜索地点,或拖动地图到目标位置。") - addStep("2. 点击“锁定位置”,系统会自动添加或启动 \(AppWLocConfig.displayName) VPN。") + addStep("2. 点击“锁定位置”,应用会启用本地定位代理,并配置系统 PAC。") addStep("3. 打开系统定位服务,关闭后等待两秒再打开。") let line = NSView() @@ -91,7 +91,7 @@ final class WLocMacTutorialViewController: NSViewController { } addTitle("三、恢复原始位置") - addStep("1. 退出应用会自动断开 \(AppWLocConfig.displayName) VPN。") + addStep("1. 点击主界面的“恢复定位”恢复原代理设置;若提示失败,请点击“重试恢复”。退出应用也会尝试恢复。") addStep("2. 再次关开定位服务。如未恢复,重启电脑后再试。") } diff --git a/tests/test_pac_restore.py b/tests/test_pac_restore.py new file mode 100644 index 0000000..381472c --- /dev/null +++ b/tests/test_pac_restore.py @@ -0,0 +1,92 @@ +"""Run the production recovery methods with an isolated preferences suite and fake helper. + +No system network settings or real app preferences are changed. Requires Swift. +""" +from pathlib import Path +import subprocess +import tempfile + +root = Path(__file__).resolve().parents[1] +source = (root / 'WLocApp/WLocAppMac/AppWLocPACManager.swift').read_text() +start = source.index(' private func loadSavedSettings()') +end = source.index(' private func ensureHelper()', start) +methods = source[start:end].replace('UserDefaults.standard', 'defaults') +harness = r''' +import Foundation +struct AppWLocPACNetworkSetting { let service: String; let url: String?; let enabled: Bool } +enum AppWLocPACError: Error { case commandFailed(String) } +final class RecoveryHarness { + struct SavedPACSetting: Codable { let url: String?; let enabled: Bool } + let defaults: UserDefaults + let savedSettingsKey = "backup" + var previousSettings: [String: SavedPACSetting] = [:] + var failures: Set = [] + var calls: [String] = [] + init(_ defaults: UserDefaults) { self.defaults = defaults } + func setPAC(_ settings: [AppWLocPACNetworkSetting]) throws { + for setting in settings { + calls.append(setting.service) + if failures.contains(setting.service) { throw AppWLocPACError.commandFailed("unavailable") } + } + } + func recover() throws -> Error? { + try loadSavedSettings() + return restorePAC(previousSettings) + } +''' +harness += methods + '\n}\n' +harness += r''' +let suite = "wloc.restore.tests." + UUID().uuidString +let defaults = UserDefaults(suiteName: suite)! +defer { defaults.removePersistentDomain(forName: suite) } +let settings = [ + "Wi-Fi": RecoveryHarness.SavedPACSetting(url: "https://example.invalid/original.pac", enabled: true), + "Ethernet": RecoveryHarness.SavedPACSetting(url: nil, enabled: false) +] +let original = try JSONEncoder().encode(settings) +defaults.set(original, forKey: "backup") +let first = RecoveryHarness(defaults) +first.failures = ["Ethernet"] +assert(try first.recover() != nil) +let pending = try JSONDecoder().decode([String: RecoveryHarness.SavedPACSetting].self, from: defaults.data(forKey: "backup")!) +assert(Set(pending.keys) == ["Ethernet"]) +assert(pending["Ethernet"]!.enabled == false) +assert(pending["Ethernet"]!.url == nil) +first.calls = [] +first.failures = [] +assert(try first.recover() == nil) +assert(first.calls == ["Ethernet"]) +assert(defaults.data(forKey: "backup") == nil) +first.calls = [] +assert(try first.recover() == nil) +assert(first.calls.isEmpty) +print("PASS: partial failure, retry only pending services, repeated restore") + +defaults.set(original, forKey: "backup") +let failed = RecoveryHarness(defaults) +failed.failures = ["Wi-Fi", "Ethernet"] +assert(try failed.recover() != nil) +let restarted = RecoveryHarness(defaults) +assert(try restarted.recover() == nil) +assert(Set(restarted.calls) == Set(settings.keys)) +assert(defaults.data(forKey: "backup") == nil) +print("PASS: failed recovery survives app restart") + +defaults.set(Data("broken".utf8), forKey: "backup") +let corrupt = RecoveryHarness(defaults) +do { + _ = try corrupt.recover() + fatalError("Corrupt backup must fail") +} catch { + assert(defaults.data(forKey: "backup") == Data("broken".utf8)) + assert(corrupt.calls.isEmpty) +} +print("PASS: corrupt backup is preserved and never applied") +''' +# Swift assert uses a nonthrowing autoclosure. +harness = harness.replace('assert(try ', 'assertValue(try ') +harness += '\nfunc assertValue(_ value: Bool) { assert(value) }\n' +with tempfile.TemporaryDirectory(prefix='wloc-restore-tests-') as folder: + test = Path(folder) / 'main.swift' + test.write_text(harness) + subprocess.run(['swift', str(test)], check=True)