mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-26 03:39:49 +00:00
refactor(servers): move activation hints into detail feedback
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user