diff --git a/ShadowsocksX-NG2/App/Presentation/ActivationDetailFeedbackState.swift b/ShadowsocksX-NG2/App/Presentation/ActivationDetailFeedbackState.swift new file mode 100644 index 00000000..28f73bf9 --- /dev/null +++ b/ShadowsocksX-NG2/App/Presentation/ActivationDetailFeedbackState.swift @@ -0,0 +1,25 @@ +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/ActivationFeedbackState.swift b/ShadowsocksX-NG2/App/Presentation/ActivationFeedbackState.swift index 2eb690cd..553511dd 100644 --- a/ShadowsocksX-NG2/App/Presentation/ActivationFeedbackState.swift +++ b/ShadowsocksX-NG2/App/Presentation/ActivationFeedbackState.swift @@ -15,8 +15,21 @@ final class ActivationFeedbackState: ObservableObject { case rejected(ActivationFailure) /// 意外错误的呈现文案(AppPresentation 边缘生成)。 case failed(message: String) + + var isSuccess: Bool { + if case .activated = self { return true } + return false + } } + struct Completion: Equatable { + let id = UUID() + let targetID: NodeID + let feedback: Feedback + let completedAt: Date + } + + @Published private(set) var completion: Completion? @Published private(set) var pendingTargetID: NodeID? @Published private(set) var feedback: Feedback? @Published private(set) var feedbackTargetID: NodeID? @@ -33,7 +46,11 @@ final class ActivationFeedbackState: ObservableObject { pendingTargetID = id feedbackTargetID = id feedback = nil - defer { pendingTargetID = nil } + completion = nil + defer { + completion = feedback.map { Completion(targetID: id, feedback: $0, completedAt: Date()) } + pendingTargetID = nil + } do { let outcome = try await workflow.activate(id) switch outcome { diff --git a/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift b/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift index b9a66c04..5fdfb787 100644 --- a/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift +++ b/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift @@ -14,7 +14,8 @@ struct NodeDetailPresentation { } let title: String - var subtitle: String + let subtitle: String + let activationMessages: [String] let secondarySubtitle: String? var properties: [Properties] @@ -40,21 +41,23 @@ struct NodeDetailPresentation { : String(localized: "订阅配置", table: "ServerDetails")) ] if isActiveTarget { descriptions.append(String(localized: "活动目标", table: "ServerDetails")) } + subtitle = descriptions.joined(separator: " · ") + var messages: [String] = [] if node.isGroup { if let reason = eligibility?.ineligibility { switch reason { case .emptyGroup: - descriptions.append(String(localized: "空分组,暂无子节点,不能激活。", table: "ServerDetails")) + messages.append(String(localized: "空分组,暂无子节点,不能激活。", table: "ServerDetails")) case .noCandidates: - descriptions.append(String(localized: "分组中没有可激活的有效服务器。", table: "ServerDetails")) + messages.append(String(localized: "分组中没有可激活的有效服务器。", table: "ServerDetails")) } } if let count = eligibility?.skippedInvalidCount, count > 0, eligibility?.canActivate == true { - descriptions.append( + messages.append( String(localized: "激活时将跳过 \(count) 个存在已知阻塞问题的服务器。", table: "ServerDetails")) } } else { - descriptions += node.invalidReasons.map { reason in + messages += node.invalidReasons.map { reason in if case .pluginNotProvided(let program) = reason, let warning = server?.plugin.availabilityWarning(for: program) { @@ -64,7 +67,7 @@ struct NodeDetailPresentation { for: ActivationFailure.invalidLeaf(node: node.id, reason: reason)) } } - subtitle = descriptions.joined(separator: " · ") + activationMessages = messages properties = [] if isRootSubscriptionGroup, let subscriptionProperties = Self.subscriptionInformation( diff --git a/ShadowsocksX-NG2/App/Resources/ServerDetails.xcstrings b/ShadowsocksX-NG2/App/Resources/ServerDetails.xcstrings index 59b368db..2648b90d 100644 --- a/ShadowsocksX-NG2/App/Resources/ServerDetails.xcstrings +++ b/ShadowsocksX-NG2/App/Resources/ServerDetails.xcstrings @@ -1,6 +1,17 @@ { "sourceLanguage": "zh-Hans", "strings": { + "激活成功": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Activation succeeded" + } + } + } + }, "信息": { "extractionState": "manual", "localizations": { diff --git a/ShadowsocksX-NG2/App/UI/Servers/ActivationDetailFeedbackView.swift b/ShadowsocksX-NG2/App/UI/Servers/ActivationDetailFeedbackView.swift new file mode 100644 index 00000000..3da6c75b --- /dev/null +++ b/ShadowsocksX-NG2/App/UI/Servers/ActivationDetailFeedbackView.swift @@ -0,0 +1,57 @@ +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) + } + if let result = feedback.completion, result.id == completion?.id { + feedbackRow( + message(for: result.feedback), + symbol: result.feedback.isSuccess + ? "checkmark.circle.fill" : "exclamationmark.triangle.fill", + color: result.feedback.isSuccess ? .green : .red) + } + } + .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 { + HStack(alignment: .top, spacing: 8) { + Image(systemName: symbol).foregroundStyle(color) + Text(message) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + .accessibilityElement(children: .combine) + } + + private func message(for result: ActivationFeedbackState.Feedback) -> String { + switch result { + case .activated(let skipped) where skipped > 0: + return String(localized: "已跳过 \(skipped) 个存在已知阻塞问题的服务器。", table: "ServerDetails") + case .activated: + return String(localized: "激活成功", table: "ServerDetails") + case .rejected(let failure): + return AppPresentation.message(for: failure) + case .failed(let message): + return message + } + } +} diff --git a/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift b/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift index 2a970540..88460886 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift @@ -16,7 +16,8 @@ struct CatalogNodeDetailView: View { var body: some View { if let node { - NodeDetailView(detail: presentation(for: node)) { + let detail = presentation(for: node) + NodeDetailView(detail: detail) { Button( activation.pendingTargetID == nodeID ? String(localized: "激活中…", table: "ServerDetails") @@ -26,7 +27,7 @@ struct CatalogNodeDetailView: View { do { _ = try await activation.activate(nodeID, via: workflow) } catch { - errors.present(error) + // 激活状态记录失败,详情在 feedback 区域显示结果。 } } } @@ -62,6 +63,9 @@ struct CatalogNodeDetailView: View { } } } feedback: { + ActivationDetailFeedbackView( + messages: detail.activationMessages, + completion: activation.completion?.targetID == nodeID ? activation.completion : nil) if let subscription = subscriptionSummary(for: node) { SubscriptionRefreshFeedbackView( isRefreshing: workflow.refreshingSubscriptionIDs.contains(subscription.id), @@ -98,17 +102,6 @@ struct CatalogNodeDetailView: View { detail.appendPluginParameters(parameters.options.rawText) } } - if activation.feedbackTargetID == nodeID, let feedback = activation.feedback { - let message: String? - switch feedback { - case .rejected(let failure): message = AppPresentation.message(for: failure) - case .failed(let value): message = value - case .activated(let skipped) where skipped > 0: - message = String(localized: "已跳过 \(skipped) 个存在已知阻塞问题的服务器。", table: "ServerDetails") - case .activated: message = nil - } - if let message { detail.subtitle += " · " + message } - } return detail } diff --git a/ShadowsocksX-NG2/Tests/Activation/ActivationDetailFeedbackTests.swift b/ShadowsocksX-NG2/Tests/Activation/ActivationDetailFeedbackTests.swift new file mode 100644 index 00000000..de632f35 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Activation/ActivationDetailFeedbackTests.swift @@ -0,0 +1,41 @@ +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/Activation/ActivationFeedbackStateTests.swift b/ShadowsocksX-NG2/Tests/Activation/ActivationFeedbackStateTests.swift index 0ca14338..16a33e2e 100644 --- a/ShadowsocksX-NG2/Tests/Activation/ActivationFeedbackStateTests.swift +++ b/ShadowsocksX-NG2/Tests/Activation/ActivationFeedbackStateTests.swift @@ -35,9 +35,15 @@ final class ActivationFeedbackStateTests: XCTestCase { func testActivatedOutcomeRecordsSkippedFeedbackAndClearsPending() async throws { let model = ActivationFeedbackState() activator.result = .activated(skippedInvalid: 2) + let startedAt = Date() let outcome = try await model.activate(NodeID(rawValue: "a"), via: workflow) XCTAssertEqual(outcome, .activated(skippedInvalid: 2)) XCTAssertEqual(model.feedback, .activated(skippedInvalid: 2)) + let completion = try XCTUnwrap(model.completion) + XCTAssertEqual(completion.targetID, NodeID(rawValue: "a")) + XCTAssertEqual(completion.feedback, model.feedback) + XCTAssertGreaterThanOrEqual(completion.completedAt, startedAt) + XCTAssertLessThanOrEqual(completion.completedAt, Date()) XCTAssertNil(model.pendingTargetID) XCTAssertFalse(model.isPending) } @@ -49,6 +55,7 @@ final class ActivationFeedbackStateTests: XCTestCase { let outcome = try await model.activate(id, via: workflow) XCTAssertEqual(outcome, .rejectedActivation(.targetNotFound(id))) XCTAssertEqual(model.feedback, .rejected(.targetNotFound(id))) + XCTAssertEqual(model.completion?.feedback, model.feedback) } func testUnexpectedErrorRecordsFailedMessageAndRethrows() async throws { @@ -62,6 +69,7 @@ final class ActivationFeedbackStateTests: XCTestCase { XCTAssertEqual(error as? CatalogError, .nodeNotFound(id)) } XCTAssertEqual(model.feedback, .failed(message: "节点不存在(可能已被删除)")) + XCTAssertEqual(model.completion?.feedback, model.feedback) XCTAssertNil(model.pendingTargetID) } @@ -73,7 +81,7 @@ final class ActivationFeedbackStateTests: XCTestCase { var ignoredOutcome: ActivationCommandOutcome?? activator.whileInFlight = { observedPending = model.pendingTargetID - observedFeedbackCleared = model.feedback == nil + observedFeedbackCleared = model.feedback == nil && model.completion == nil // 首命令 pending 中的再入:单飞忽略,同步返回 nil。 do { ignoredOutcome = .some( @@ -97,6 +105,7 @@ final class ActivationFeedbackStateTests: XCTestCase { _ = try await model.activate(NodeID(rawValue: "a"), via: workflow) XCTAssertEqual(model.feedback, .rejected(.noActiveTarget)) + let firstCompletionID = model.completion?.id var feedbackAtSecondCommandStart: ActivationFeedbackState.Feedback? = .activated( skippedInvalid: 0) activator.whileInFlight = { feedbackAtSecondCommandStart = model.feedback } @@ -104,6 +113,7 @@ final class ActivationFeedbackStateTests: XCTestCase { _ = try await model.activate(NodeID(rawValue: "b"), via: workflow) XCTAssertNil(feedbackAtSecondCommandStart, "第二命令开始时应已清空旧反馈") XCTAssertEqual(model.feedback, .activated(skippedInvalid: 1)) + XCTAssertNotEqual(model.completion?.id, firstCompletionID) } } diff --git a/ShadowsocksX-NG2/Tests/Servers/NodeDetailPresentationTests.swift b/ShadowsocksX-NG2/Tests/Servers/NodeDetailPresentationTests.swift index 231ea543..471f515e 100644 --- a/ShadowsocksX-NG2/Tests/Servers/NodeDetailPresentationTests.swift +++ b/ShadowsocksX-NG2/Tests/Servers/NodeDetailPresentationTests.swift @@ -14,6 +14,9 @@ struct NodeDetailPresentationTests { isActiveTarget: true) #expect(detail.title == "测试节点") #expect(detail.subtitle.contains(String(localized: "活动目标"))) + #expect(!detail.subtitle.contains("激活时将跳过")) + #expect(detail.activationMessages.count == 1) + #expect(detail.activationMessages[0].contains("1")) #expect(detail.properties.count == 1) #expect(detail.properties[0].items.map(\.value) == ["detail-id", "2", "1", "—", "—"]) } @@ -68,7 +71,8 @@ struct NodeDetailPresentationTests { let detail = NodeDetailPresentation( node: node(isGroup: false, invalidReasons: [.pluginNotProvided(program: "v2ray-plugin")]), server: server, eligibility: nil, isActiveTarget: false) - #expect(detail.subtitle.contains("插件文件不可执行,请检查文件类型和执行权限。")) + #expect(!detail.subtitle.contains("插件文件不可执行")) + #expect(detail.activationMessages == ["插件文件不可执行,请检查文件类型和执行权限。"]) } private func node(isGroup: Bool, invalidReasons: [LeafInvalidationReason] = []) -> CatalogTreeNode