refactor(servers): move activation hints into detail feedback

This commit is contained in:
Qiu Yuzhou
2026-10-14 22:18:15 +08:00
parent e535e31b16
commit 9adbdccd5c
9 changed files with 183 additions and 22 deletions
@@ -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)
}
}
@@ -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 {
@@ -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(
@@ -1,6 +1,17 @@
{
"sourceLanguage": "zh-Hans",
"strings": {
"激活成功": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Activation succeeded"
}
}
}
},
"信息": {
"extractionState": "manual",
"localizations": {
@@ -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
}
}
}
@@ -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
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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