From 7bd0cb44cb395302a8a6e38dc72a4fc45bf350d4 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Fri, 16 Oct 2026 01:46:01 +0800 Subject: [PATCH] refactor(ui): share detail feedback timing lifecycle --- .../ActivationDetailFeedbackState.swift | 25 --- .../Presentation/DetailFeedbackState.swift | 52 ++++++ .../SubscriptionRefreshFeedbackState.swift | 25 --- .../ActivationDetailFeedbackView.swift | 24 +-- .../App/UI/Servers/DetailFeedback.swift | 19 ++ .../SubscriptionRefreshFeedbackView.swift | 37 ++-- .../ActivationDetailFeedbackTests.swift | 41 ----- .../DetailFeedbackStateTests.swift | 171 ++++++++++++++++++ .../SubscriptionRefreshFeedbackTests.swift | 25 --- 9 files changed, 266 insertions(+), 153 deletions(-) delete mode 100644 ShadowsocksX-NG2/App/Presentation/ActivationDetailFeedbackState.swift create mode 100644 ShadowsocksX-NG2/App/Presentation/DetailFeedbackState.swift delete mode 100644 ShadowsocksX-NG2/App/Presentation/SubscriptionRefreshFeedbackState.swift create mode 100644 ShadowsocksX-NG2/App/UI/Servers/DetailFeedback.swift delete mode 100644 ShadowsocksX-NG2/Tests/Activation/ActivationDetailFeedbackTests.swift create mode 100644 ShadowsocksX-NG2/Tests/Presentation/DetailFeedbackStateTests.swift diff --git a/ShadowsocksX-NG2/App/Presentation/ActivationDetailFeedbackState.swift b/ShadowsocksX-NG2/App/Presentation/ActivationDetailFeedbackState.swift deleted file mode 100644 index 28f73bf9..00000000 --- a/ShadowsocksX-NG2/App/Presentation/ActivationDetailFeedbackState.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Combine -import Foundation - -/// 详情结果从命令结束时计时;命令状态保留给其他激活入口。 -@MainActor -final class ActivationDetailFeedbackState: ObservableObject { - @Published private(set) var completion: ActivationFeedbackState.Completion? - - var expiresAt: Date? { - completion.map { Self.expiration(of: $0) } - } - - func present(_ completion: ActivationFeedbackState.Completion?, now: Date = Date()) { - self.completion = completion.flatMap { Self.expiration(of: $0) > now ? $0 : nil } - } - - func expire(_ id: UUID) { - guard completion?.id == id else { return } - completion = nil - } - - private static func expiration(of completion: ActivationFeedbackState.Completion) -> Date { - completion.completedAt.addingTimeInterval(completion.feedback.isSuccess ? 3 : 10) - } -} diff --git a/ShadowsocksX-NG2/App/Presentation/DetailFeedbackState.swift b/ShadowsocksX-NG2/App/Presentation/DetailFeedbackState.swift new file mode 100644 index 00000000..dd9d4b2f --- /dev/null +++ b/ShadowsocksX-NG2/App/Presentation/DetailFeedbackState.swift @@ -0,0 +1,52 @@ +import Foundation +import Observation + +/// 结果身份和完成时间来自命令;业务内容留在各自的呈现入口。 +protocol DetailFeedbackResult: Identifiable where ID == UUID { + var completedAt: Date { get } + var isSuccess: Bool { get } +} + +extension ActivationFeedbackState.Completion: DetailFeedbackResult { + var isSuccess: Bool { feedback.isSuccess } +} + +extension SubscriptionRefreshCompletion: DetailFeedbackResult { + var isSuccess: Bool { failure == nil } +} + +/// 详情会话持有展示寿命;命令结果仍由原工作流保留。 +@Observable +@MainActor +final class DetailFeedbackState { + private(set) var completion: Result? + private var generation = 0 + private let now: () -> Date + private let sleep: (TimeInterval) async throws -> Void + + init( + now: @escaping () -> Date = Date.init, + sleep: @escaping (TimeInterval) async throws -> Void = { + try await Task.sleep(for: .seconds($0)) + } + ) { + self.now = now + self.sleep = sleep + } + + func present(_ result: Result?) async { + guard !Task.isCancelled else { return } + generation += 1 + let currentGeneration = generation + completion = nil + guard let result else { return } + let remaining = result.completedAt.addingTimeInterval(result.isSuccess ? 3 : 10) + .timeIntervalSince(now()) + guard remaining > 0 else { return } + completion = result + defer { + if generation == currentGeneration { completion = nil } + } + try? await sleep(remaining) + } +} diff --git a/ShadowsocksX-NG2/App/Presentation/SubscriptionRefreshFeedbackState.swift b/ShadowsocksX-NG2/App/Presentation/SubscriptionRefreshFeedbackState.swift deleted file mode 100644 index 97670111..00000000 --- a/ShadowsocksX-NG2/App/Presentation/SubscriptionRefreshFeedbackState.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Combine -import Foundation - -/// 提示寿命从命令结束时计算;切回详情不会重新计时。 -@MainActor -final class SubscriptionRefreshFeedbackState: ObservableObject { - @Published private(set) var completion: SubscriptionRefreshCompletion? - - var expiresAt: Date? { - completion.map { Self.expiration(of: $0) } - } - - func present(_ completion: SubscriptionRefreshCompletion?, now: Date = Date()) { - self.completion = completion.flatMap { Self.expiration(of: $0) > now ? $0 : nil } - } - - func expire(_ id: UUID) { - guard completion?.id == id else { return } - completion = nil - } - - private static func expiration(of completion: SubscriptionRefreshCompletion) -> Date { - completion.completedAt.addingTimeInterval(completion.failure == nil ? 3 : 10) - } -} diff --git a/ShadowsocksX-NG2/App/UI/Servers/ActivationDetailFeedbackView.swift b/ShadowsocksX-NG2/App/UI/Servers/ActivationDetailFeedbackView.swift index 3da6c75b..85093338 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/ActivationDetailFeedbackView.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/ActivationDetailFeedbackView.swift @@ -4,31 +4,25 @@ import SwiftUI struct ActivationDetailFeedbackView: View { let messages: [String] let completion: ActivationFeedbackState.Completion? - @StateObject private var feedback = ActivationDetailFeedbackState() var body: some View { - VStack(alignment: .leading, spacing: 8) { - ForEach(messages, id: \.self) { message in - feedbackRow(message, symbol: "exclamationmark.triangle.fill", color: .orange) + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 8) { + ForEach(messages, id: \.self) { message in + feedbackRow(message, symbol: "exclamationmark.triangle.fill", color: .orange) + } } - if let result = feedback.completion, result.id == completion?.id { + DetailFeedback(completion: completion) { result in feedbackRow( message(for: result.feedback), symbol: result.feedback.isSuccess ? "checkmark.circle.fill" : "exclamationmark.triangle.fill", - color: result.feedback.isSuccess ? .green : .red) + color: result.feedback.isSuccess ? .green : .red + ) + .padding(.top, messages.isEmpty ? 0 : 8) } } .font(.callout) - .task(id: completion?.id) { - feedback.present(completion) - guard let completion, let expiresAt = feedback.expiresAt else { return } - do { - try await Task.sleep(for: .seconds(max(0, expiresAt.timeIntervalSinceNow))) - } catch { return } - guard !Task.isCancelled else { return } - feedback.expire(completion.id) - } } private func feedbackRow(_ message: String, symbol: String, color: Color) -> some View { diff --git a/ShadowsocksX-NG2/App/UI/Servers/DetailFeedback.swift b/ShadowsocksX-NG2/App/UI/Servers/DetailFeedback.swift new file mode 100644 index 00000000..e91f6d63 --- /dev/null +++ b/ShadowsocksX-NG2/App/UI/Servers/DetailFeedback.swift @@ -0,0 +1,19 @@ +import SwiftUI + +/// 稳定容器持有详情计时会话,即使暂时没有结果也能接收后续完成事件。 +struct DetailFeedback: View { + let completion: Result? + @ViewBuilder let content: (Result) -> Content + @State private var feedback = DetailFeedbackState() + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let result = feedback.completion, result.id == completion?.id { + content(result) + } + } + .task(id: completion?.id) { + await feedback.present(completion) + } + } +} diff --git a/ShadowsocksX-NG2/App/UI/Servers/SubscriptionRefreshFeedbackView.swift b/ShadowsocksX-NG2/App/UI/Servers/SubscriptionRefreshFeedbackView.swift index 4fe09d85..6a65e9b1 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/SubscriptionRefreshFeedbackView.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/SubscriptionRefreshFeedbackView.swift @@ -4,7 +4,6 @@ import SwiftUI struct SubscriptionRefreshFeedbackView: View { let isRefreshing: Bool let completion: SubscriptionRefreshCompletion? - @StateObject private var feedback = SubscriptionRefreshFeedbackState() var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -14,31 +13,25 @@ struct SubscriptionRefreshFeedbackView: View { Text(String(localized: "正在更新订阅…", table: "ServerDetails")) } .foregroundStyle(.secondary) - } else if let result = feedback.completion { - HStack(alignment: .top, spacing: 8) { - Image( - systemName: result.failure == nil - ? "checkmark.circle.fill" : "exclamationmark.triangle.fill" - ) - .foregroundStyle(result.failure == nil ? Color.green : Color.red) - Text(message(for: result)) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) + } + DetailFeedback(completion: completion) { result in + if !isRefreshing { + HStack(alignment: .top, spacing: 8) { + Image( + systemName: result.failure == nil + ? "checkmark.circle.fill" : "exclamationmark.triangle.fill" + ) + .foregroundStyle(result.failure == nil ? Color.green : Color.red) + Text(message(for: result)) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + .accessibilityElement(children: .combine) } - .accessibilityElement(children: .combine) } } .font(.callout) - .task(id: completion?.id) { - feedback.present(completion) - guard let completion, let expiresAt = feedback.expiresAt else { return } - do { - try await Task.sleep(for: .seconds(max(0, expiresAt.timeIntervalSinceNow))) - } catch { return } - guard !Task.isCancelled else { return } - feedback.expire(completion.id) - } } private func message(for result: SubscriptionRefreshCompletion) -> String { diff --git a/ShadowsocksX-NG2/Tests/Activation/ActivationDetailFeedbackTests.swift b/ShadowsocksX-NG2/Tests/Activation/ActivationDetailFeedbackTests.swift deleted file mode 100644 index de632f35..00000000 --- a/ShadowsocksX-NG2/Tests/Activation/ActivationDetailFeedbackTests.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation -import Testing - -@testable import ShadowsocksX_NG2 - -@MainActor -struct ActivationDetailFeedbackTests { - @Test func successExpiresFromCompletionWithoutRestartingOnReturn() { - let date = Date(timeIntervalSince1970: 100) - let result = ActivationFeedbackState.Completion( - targetID: NodeID(rawValue: "a"), feedback: .activated(skippedInvalid: 2), completedAt: date) - let state = ActivationDetailFeedbackState() - state.present(result, now: date.addingTimeInterval(2)) - #expect(state.completion == result) - #expect(state.expiresAt == date.addingTimeInterval(3)) - state.present(result, now: date.addingTimeInterval(3)) - #expect(state.completion == nil) - } - - @Test(arguments: [ - ActivationFeedbackState.Feedback.failed(message: "failure"), - .rejected(.noActiveTarget), - ]) - func failuresLastTenSecondsAndStaleExpirationDoesNotClearNewResult( - feedback: ActivationFeedbackState.Feedback - ) { - let date = Date(timeIntervalSince1970: 100) - let first = ActivationFeedbackState.Completion( - targetID: NodeID(rawValue: "a"), feedback: .activated(skippedInvalid: 0), completedAt: date) - let next = ActivationFeedbackState.Completion( - targetID: NodeID(rawValue: "a"), feedback: feedback, completedAt: date) - let state = ActivationDetailFeedbackState() - state.present(first, now: date) - state.present(next, now: date.addingTimeInterval(3)) - state.expire(first.id) - #expect(state.completion == next) - #expect(state.expiresAt == date.addingTimeInterval(10)) - state.present(next, now: date.addingTimeInterval(10)) - #expect(state.completion == nil) - } -} diff --git a/ShadowsocksX-NG2/Tests/Presentation/DetailFeedbackStateTests.swift b/ShadowsocksX-NG2/Tests/Presentation/DetailFeedbackStateTests.swift new file mode 100644 index 00000000..626ec29e --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Presentation/DetailFeedbackStateTests.swift @@ -0,0 +1,171 @@ +import Foundation +import Testing + +@testable import ShadowsocksX_NG2 + +@MainActor +struct DetailFeedbackStateTests { + @Test func successExpiresFromCompletionRatherThanAppearance() async { + let completedAt = Date(timeIntervalSince1970: 100) + let result = SubscriptionRefreshCompletion(completedAt: completedAt) + let gate = AsyncGate() + let entered = AsyncGate() + var delays: [TimeInterval] = [] + let state = DetailFeedbackState( + now: { completedAt.addingTimeInterval(2) }, + sleep: { delay in + delays.append(delay) + entered.release() + await gate.wait() + }) + let task = Task { await state.present(result) } + await entered.wait() + #expect(state.completion == result) + #expect(delays == [1]) + gate.release() + await task.value + #expect(state.completion == nil) + } + @Test func supersededTaskCannotClearNewResult() async { + let date = Date(timeIntervalSince1970: 100) + let first = SubscriptionRefreshCompletion(completedAt: date) + let next = SubscriptionRefreshCompletion(completedAt: date, failure: .httpStatus(code: 503)) + let firstEntered = AsyncGate() + let nextEntered = AsyncGate() + let firstSleep = AsyncGate() + let nextSleep = AsyncGate() + var calls = 0 + let state = DetailFeedbackState( + now: { date }, + sleep: { _ in + calls += 1 + if calls == 1 { + firstEntered.release() + await firstSleep.wait() + } else { + nextEntered.release() + await nextSleep.wait() + } + }) + let oldTask = Task { await state.present(first) } + await firstEntered.wait() + oldTask.cancel() + let newTask = Task { await state.present(next) } + await nextEntered.wait() + firstSleep.release() + await oldTask.value + #expect(state.completion == next) + nextSleep.release() + await newTask.value + #expect(state.completion == nil) + } + @Test(arguments: [ + (ActivationFeedbackState.Feedback.activated(skippedInvalid: 2), 3.0), + (.rejected(.noActiveTarget), 10.0), + (.failed(message: "failure"), 10.0), + ]) + func activationUsesTheSharedLifetime( + feedback: ActivationFeedbackState.Feedback, seconds: TimeInterval + ) async { + let date = Date(timeIntervalSince1970: 100) + let result = ActivationFeedbackState.Completion( + targetID: NodeID(rawValue: "a"), feedback: feedback, completedAt: date) + await checkLifetime(result, seconds: seconds) + } + + @Test func subscriptionFailureLastsTenSeconds() async { + await checkLifetime( + SubscriptionRefreshCompletion( + completedAt: Date(timeIntervalSince1970: 100), failure: .httpStatus(code: 503)), + seconds: 10) + } + + @Test func leavingCancelsAndReturningUsesRemainingLifetime() async { + let date = Date(timeIntervalSince1970: 100) + let result = SubscriptionRefreshCompletion(completedAt: date) + var now = date + var delays: [TimeInterval] = [] + let firstEntered = AsyncGate() + let nextEntered = AsyncGate() + let firstSleep = AsyncGate() + let nextSleep = AsyncGate() + let state = DetailFeedbackState( + now: { now }, + sleep: { delay in + delays.append(delay) + let isFirst = delays.count == 1 + let entered = isFirst ? firstEntered : nextEntered + let sleeper = isFirst ? firstSleep : nextSleep + try await withTaskCancellationHandler { + entered.release() + await sleeper.wait() + try Task.checkCancellation() + } onCancel: { + sleeper.release() + } + }) + let firstVisit = Task { await state.present(result) } + await firstEntered.wait() + firstVisit.cancel() + await firstVisit.value + #expect(state.completion == nil) + now = date.addingTimeInterval(2) + let nextVisit = Task { await state.present(result) } + await nextEntered.wait() + #expect(state.completion == result) + #expect(delays == [3, 1]) + nextSleep.release() + await nextVisit.value + now = date.addingTimeInterval(3) + await state.present(result) + #expect(state.completion == nil) + #expect(delays == [3, 1]) + } + + @Test func clearingInputInvalidatesPendingExpiration() async { + let date = Date(timeIntervalSince1970: 100) + let result = SubscriptionRefreshCompletion(completedAt: date) + let entered = AsyncGate() + let sleeper = AsyncGate() + let state = DetailFeedbackState( + now: { date }, + sleep: { _ in + entered.release() + await sleeper.wait() + }) + let task = Task { await state.present(result) } + await entered.wait() + await state.present(nil) + #expect(state.completion == nil) + sleeper.release() + await task.value + #expect(state.completion == nil) + } + + private func checkLifetime( + _ result: Result, seconds: TimeInterval + ) async { + var now = result.completedAt + var delays: [TimeInterval] = [] + let entered = AsyncGate() + let sleeper = AsyncGate() + let state = DetailFeedbackState( + now: { now }, + sleep: { delay in + delays.append(delay) + entered.release() + await sleeper.wait() + }) + let task = Task { await state.present(result) } + await entered.wait() + #expect(state.completion == result) + #expect(delays == [seconds]) + sleeper.release() + await task.value + #expect(state.completion == nil) + now = result.completedAt.addingTimeInterval(seconds) + await state.present(result) + #expect(state.completion == nil) + #expect(delays == [seconds]) + } +} diff --git a/ShadowsocksX-NG2/Tests/Subscription/SubscriptionRefreshFeedbackTests.swift b/ShadowsocksX-NG2/Tests/Subscription/SubscriptionRefreshFeedbackTests.swift index b37fc4c2..d4ebcb1d 100644 --- a/ShadowsocksX-NG2/Tests/Subscription/SubscriptionRefreshFeedbackTests.swift +++ b/ShadowsocksX-NG2/Tests/Subscription/SubscriptionRefreshFeedbackTests.swift @@ -5,31 +5,6 @@ import Testing @MainActor struct SubscriptionRefreshFeedbackTests { - @Test func failureLastsTenSecondsAndOldExpirationCannotClearNewResult() { - let date = Date(timeIntervalSince1970: 100) - let first = SubscriptionRefreshCompletion(completedAt: date) - let next = SubscriptionRefreshCompletion(completedAt: date, failure: .httpStatus(code: 503)) - let state = SubscriptionRefreshFeedbackState() - state.present(first, now: date) - state.present(next, now: date.addingTimeInterval(3)) - state.expire(first.id) - #expect(state.completion == next) - #expect(state.expiresAt == date.addingTimeInterval(10)) - state.present(next, now: date.addingTimeInterval(10)) - #expect(state.completion == nil) - } - - @Test func successExpiresThreeSecondsAfterCompletionWithoutRestartingOnReturn() { - let date = Date(timeIntervalSince1970: 100) - let completion = SubscriptionRefreshCompletion(completedAt: date) - let state = SubscriptionRefreshFeedbackState() - state.present(completion, now: date.addingTimeInterval(2)) - #expect(state.completion == completion) - #expect(state.expiresAt == date.addingTimeInterval(3)) - state.present(completion, now: date.addingTimeInterval(3)) - #expect(state.completion == nil) - } - @Test func successfulRefreshPublishesSessionResultWithoutReplayingOnReload() async throws { let fixture = try Fixture() defer { fixture.removeFiles() }