import Combine import Foundation /// Settings item editors read the committed snapshot and submit one item at a time. /// The module exposes validation, occupancy facts, shared commit state, and typed /// commands; editor-local drafts and presentation state remain in the UI. /// /// Listening writes and system-proxy exceptions use `SettingsCommitting`; the test URL /// preference uses UserDefaults. Runtime convergence stays behind the commit seam. @MainActor final class SettingsWorkflow: ObservableObject { let listenerOccupancy: ListenerOccupancy private var occupancyChanges: AnyCancellable? /// Shared gate that prevents concurrent saves across setting items. @Published private(set) var isCommitting = false /// Typed persistence failure for the editor to present. @Published private(set) var lastFailure: SettingsWorkflowFailure? var connectivityURLDidChange: ((URL) -> Void)? let committing: SettingsCommitting let connectivityDefaults: UserDefaults init( committing: SettingsCommitting, occupancyProbe: PortOccupancyProbing = SystemPortOccupancyProbe(), connectivityDefaults: UserDefaults = .standard ) { self.committing = committing self.connectivityDefaults = connectivityDefaults listenerOccupancy = ListenerOccupancy(committing: committing, probe: occupancyProbe) occupancyChanges = listenerOccupancy.objectWillChange.sink { [weak self] in self?.objectWillChange.send() } } /// Current committed port pair; editors start from this snapshot. var committedPortDraft: SettingsPortDraft { let listen = committing.committedSettings.listen return SettingsPortDraft(socksPort: listen.socksPort, httpPort: listen.httpPort) } var committedProxyExceptionCount: Int { committing.committedSettings.proxyExceptionList.count } func beginProxyExceptionsEditing() -> String { lastFailure = nil return committing.committedSettings.proxyExceptions } /// Start a port editor from committed values and discard prior probe facts. func beginPortSettingsEditing() -> SettingsPortDraft { lastFailure = nil listenerOccupancy.reset() return committedPortDraft } /// Field state uses the editor group and its proposed listening mode. func portFieldState( for id: SettingsPortID, editorDraft: SettingsPortDraft ) -> SettingsPortFieldState { let facts = listenerOccupancy.fieldFacts(for: id, draft: editorDraft) let exception = facts.isRuntimeException let occupancy = facts.occupancy let issues = portIssues(for: editorDraft).filter { $0.field == .port(id) } let canSuggest: Bool if case .occupied? = occupancy { canSuggest = !exception } else { canSuggest = false } return SettingsPortFieldState( id: id, draftValue: editorDraft.portValue(for: id), occupancy: occupancy, issues: issues, isRuntimePortException: exception, canSuggestFreePort: canSuggest) } /// Refresh port occupancy facts. Results from an older editor draft are ignored. func refreshPortEditorOccupancy(for editorDraft: SettingsPortDraft) { listenerOccupancy.refresh(editorDraft) } /// Invalid ports and known external occupancy block the save. Unknown /// occupancy remains advisory, and probing must finish for this exact draft. func canSavePortSettings(_ editorDraft: SettingsPortDraft) -> Bool { listenSettings(for: editorDraft).portValidationErrors().isEmpty && listenerOccupancy.canSave(editorDraft) && !isCommitting } /// Return an available port candidate for the editor to apply. This command /// never persists the candidate. @discardableResult func suggestFreePort( for id: SettingsPortID, from editorDraft: SettingsPortDraft ) async -> SettingsCommandOutcome { guard !isCommitting else { return .rejected(.inProgress) } return await listenerOccupancy.suggestFreePort(for: id, draft: editorDraft) } /// Save the SOCKS5 and HTTP ports as one item. Validity is enforced at the /// store seam; this command only maps a store rejection back to field issues. @discardableResult func savePortSettings(_ editorDraft: SettingsPortDraft) async -> SettingsCommandOutcome { guard !isCommitting else { return .rejected(.inProgress) } guard editorDraft.group == .local else { return .rejected(.superseded) } if let rejection = listenerOccupancy.cachedSaveRejection(for: editorDraft) { return .rejected(rejection) } var proposed = committing.committedSettings.listen proposed.socksPort = editorDraft.socksPort proposed.httpPort = editorDraft.httpPort return await commitPortSettings(proposed, fieldGroup: editorDraft.group) } /// Save only user-added system-proxy exceptions. @discardableResult func saveProxyExceptions(_ rawValue: String) async -> SettingsCommandOutcome { guard !isCommitting else { return .rejected(.inProgress) } let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "" : rawValue guard value != committing.committedSettings.proxyExceptions else { lastFailure = nil return .persisted } isCommitting = true lastFailure = nil await committing.updateProxyExceptions(value) isCommitting = false return .persisted } func listenSettings(for editorDraft: SettingsPortDraft) -> ProxyListeningConfiguration { listenerOccupancy.listenSettings(for: editorDraft) } private func portIssues(for editorDraft: SettingsPortDraft) -> [SettingsFieldIssue] { let errors = listenSettings(for: editorDraft).portValidationErrors() return SettingsPortAdapter.fieldIssues(from: errors, group: editorDraft.group) } func startCommit() -> Bool { guard !isCommitting else { return false } isCommitting = true lastFailure = nil return true } func finishCommit() { isCommitting = false } func commitPortSettings( _ proposed: ProxyListeningConfiguration, fieldGroup: SettingsListeningGroup? = nil ) async -> SettingsCommandOutcome { guard startCommit() else { return .rejected(.inProgress) } defer { finishCommit() } return await persistListening(proposed, fieldGroup: fieldGroup) } /// 调用方持有共享提交锁;持久化结果与运行状态分别报告。 /// store 的 `.invalid` 按编辑组归位成字段 issue;其余失败保持持久化形状。 func persistListening( _ proposed: ProxyListeningConfiguration, fieldGroup: SettingsListeningGroup? = nil ) async -> SettingsCommandOutcome { do { try await committing.updateListening(proposed) lastFailure = nil return .persisted } catch { if let fieldGroup, let error = error as? ProxyListeningStoreError, case .invalid(let portErrors) = error { return .rejected( .validation(SettingsPortAdapter.fieldIssues(from: portErrors, group: fieldGroup))) } return commitFailure(for: error) } } private func commitFailure(for error: Error) -> SettingsCommandOutcome { let failure = Self.workflowFailure(for: error) lastFailure = failure return .commitFailed(failure) } private static func workflowFailure(for error: Error) -> SettingsWorkflowFailure { if let error = error as? ProxyListeningStoreError { return .store(error) } return .unknown } }