mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-29 13:43:49 +00:00
refactor(ui): share detail feedback timing lifecycle
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Result: DetailFeedbackResult> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 稳定容器持有详情计时会话,即使暂时没有结果也能接收后续完成事件。
|
||||
struct DetailFeedback<Result: DetailFeedbackResult, Content: View>: View {
|
||||
let completion: Result?
|
||||
@ViewBuilder let content: (Result) -> Content
|
||||
@State private var feedback = DetailFeedbackState<Result>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<SubscriptionRefreshCompletion>(
|
||||
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<SubscriptionRefreshCompletion>(
|
||||
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<SubscriptionRefreshCompletion>(
|
||||
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<SubscriptionRefreshCompletion>(
|
||||
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: DetailFeedbackResult & Equatable>(
|
||||
_ result: Result, seconds: TimeInterval
|
||||
) async {
|
||||
var now = result.completedAt
|
||||
var delays: [TimeInterval] = []
|
||||
let entered = AsyncGate()
|
||||
let sleeper = AsyncGate()
|
||||
let state = DetailFeedbackState<Result>(
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
Reference in New Issue
Block a user