mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 11:14:51 +00:00
200 lines
7.2 KiB
Swift
200 lines
7.2 KiB
Swift
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
|
|
}
|
|
}
|