mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-29 05:33:45 +00:00
feat(servers): add subscription update with inline feedback
This commit is contained in:
@@ -47,12 +47,16 @@ extension CatalogWorkflow {
|
||||
guard !refreshingSubscriptionIDs.contains(id),
|
||||
subscriptions.contains(where: { $0.id == id })
|
||||
else { return }
|
||||
setSubscriptionRefreshCompletion(nil, for: id)
|
||||
setRefreshInFlight(id, true)
|
||||
defer { setRefreshInFlight(id, false) }
|
||||
do {
|
||||
try await performRefresh(id)
|
||||
setSubscriptionRefreshFailure(nil, for: id)
|
||||
publishSubscriptionServerRefresh(id)
|
||||
if subscriptions.contains(where: { $0.id == id }) {
|
||||
setSubscriptionRefreshCompletion(SubscriptionRefreshCompletion(), for: id)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
// 取消不构成失败:快照与状态都不动。
|
||||
} catch {
|
||||
@@ -60,6 +64,10 @@ extension CatalogWorkflow {
|
||||
let result = Self.refreshFailureResult(from: error)
|
||||
setSubscriptionRefreshFailure(result, for: id)
|
||||
await markRefreshFailed(subscriptionID: id, failure: result.failure)
|
||||
if subscriptions.contains(where: { $0.id == id }) {
|
||||
setSubscriptionRefreshCompletion(
|
||||
SubscriptionRefreshCompletion(failure: result.failure), for: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +106,7 @@ extension CatalogWorkflow {
|
||||
for ref in [record.urlRef] + Self.credentialRefs(of: removedEntries) {
|
||||
try? dependencies.credentials.delete(ref)
|
||||
}
|
||||
setSubscriptionRefreshCompletion(nil, for: id)
|
||||
return RemovalOutcome(removedNodeIDs: Set(removedEntries.map(\.id)))
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ final class CatalogWorkflow: ObservableObject {
|
||||
@Published private(set) var subscriptions: [SubscriptionSummary] = []
|
||||
/// 正在刷新的订阅身份(并发守卫的只读观察面)。
|
||||
@Published private(set) var refreshingSubscriptionIDs: Set<NodeID> = []
|
||||
/// 当前会话的刷新结果;不从持久化状态重建,供各入口共用结束时间。
|
||||
@Published private(set) var subscriptionRefreshCompletions:
|
||||
[NodeID: SubscriptionRefreshCompletion] = [:]
|
||||
/// 最近一次订阅刷新失败的 transient projection。这里保留完整凭据回滚
|
||||
/// outcome;持久化目录只保存安全的 typed failure facts。
|
||||
@Published private(set) var subscriptionRefreshFailures:
|
||||
@@ -421,6 +424,12 @@ final class CatalogWorkflow: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func setSubscriptionRefreshCompletion(
|
||||
_ completion: SubscriptionRefreshCompletion?, for id: NodeID
|
||||
) {
|
||||
subscriptionRefreshCompletions[id] = completion
|
||||
}
|
||||
|
||||
func setSubscriptionRefreshFailure(
|
||||
_ result: SubscriptionRefreshFailureResult?, for id: NodeID
|
||||
) {
|
||||
|
||||
@@ -331,6 +331,19 @@ struct SubscriptionRefreshFailureResult: Equatable, Sendable {
|
||||
let credentialRollback: CredentialRollbackOutcome?
|
||||
}
|
||||
|
||||
/// 非持久化的刷新回执。每次操作有独立身份,失败事实不含秘密值。
|
||||
struct SubscriptionRefreshCompletion: Identifiable, Equatable {
|
||||
let id: UUID
|
||||
let completedAt: Date
|
||||
let failure: SubscriptionRefreshFailure?
|
||||
|
||||
init(id: UUID = UUID(), completedAt: Date = Date(), failure: SubscriptionRefreshFailure? = nil) {
|
||||
self.id = id
|
||||
self.completedAt = completedAt
|
||||
self.failure = failure
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit failure at the subscription workflow seam. The underlying storage
|
||||
/// error is intentionally reduced to a safe category, while the complete
|
||||
/// rollback outcome remains available in memory for the current operation.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -408,6 +408,61 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"更新": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Update"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"更新中…": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Updating…"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"正在更新订阅…": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Updating subscription…"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"订阅更新成功": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Subscription updated successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"订阅更新失败:%@": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Subscription update failed: %@"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "1.0"
|
||||
|
||||
@@ -32,6 +32,16 @@ struct CatalogNodeDetailView: View {
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!(eligibility?.canActivate ?? false) || activation.isPending)
|
||||
if let subscription = subscriptionSummary(for: node) {
|
||||
Button(
|
||||
workflow.refreshingSubscriptionIDs.contains(subscription.id)
|
||||
? String(localized: "更新中…", table: "ServerDetails")
|
||||
: String(localized: "更新", table: "ServerDetails")
|
||||
) {
|
||||
Task { await workflow.refreshSubscription(subscription.id) }
|
||||
}
|
||||
.disabled(workflow.refreshingSubscriptionIDs.contains(subscription.id))
|
||||
}
|
||||
if let module = workflow.connectivity {
|
||||
ConnectivityTestButton(module: module, node: node)
|
||||
}
|
||||
@@ -51,6 +61,14 @@ struct CatalogNodeDetailView: View {
|
||||
parameters.reload(load: workflow.serverDetailPluginOptions)
|
||||
}
|
||||
}
|
||||
} feedback: {
|
||||
if let subscription = subscriptionSummary(for: node) {
|
||||
SubscriptionRefreshFeedbackView(
|
||||
isRefreshing: workflow.refreshingSubscriptionIDs.contains(subscription.id),
|
||||
completion: workflow.subscriptionRefreshCompletions[subscription.id]
|
||||
)
|
||||
.id(subscription.id)
|
||||
}
|
||||
} supplement: {
|
||||
if let module = workflow.connectivity {
|
||||
ConnectivityDetailView(module: module, node: node, workflow: workflow)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 只接受展示值与外部动作;不查询节点类型、工作流或凭据。
|
||||
struct NodeDetailView<Actions: View, Supplement: View>: View {
|
||||
struct NodeDetailView<Actions: View, Feedback: View, Supplement: View>: View {
|
||||
let detail: NodeDetailPresentation
|
||||
@ViewBuilder let actions: Actions
|
||||
@ViewBuilder let feedback: Feedback
|
||||
@ViewBuilder let supplement: Supplement
|
||||
|
||||
var body: some View {
|
||||
@@ -27,6 +28,7 @@ struct NodeDetailView<Actions: View, Supplement: View>: View {
|
||||
}
|
||||
HStack(spacing: 16) { actions }
|
||||
.buttonStyle(.link)
|
||||
feedback
|
||||
}
|
||||
Divider()
|
||||
ForEach(Array(detail.properties.enumerated()), id: \.offset) { _, group in
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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) {
|
||||
if isRefreshing {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView().controlSize(.small)
|
||||
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)
|
||||
}
|
||||
.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 {
|
||||
if let failure = result.failure {
|
||||
let reason = AppPresentation.message(for: failure)
|
||||
return String(localized: "订阅更新失败:\(reason)", table: "ServerDetails")
|
||||
}
|
||||
return String(localized: "订阅更新成功", table: "ServerDetails")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import ShadowsocksX_NG2
|
||||
|
||||
@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() }
|
||||
let summary = try await fixture.workflow.createSubscription(
|
||||
urlString: "https://example.com/sub.json")
|
||||
let completion = try #require(fixture.workflow.subscriptionRefreshCompletions[summary.id])
|
||||
#expect(completion.failure == nil)
|
||||
#expect(fixture.workflow.refreshingSubscriptionIDs.isEmpty)
|
||||
#expect(fixture.reload().subscriptionRefreshCompletions.isEmpty)
|
||||
await fixture.workflow.refreshAllSubscriptions()
|
||||
let next = try #require(fixture.workflow.subscriptionRefreshCompletions[summary.id])
|
||||
#expect(next.id != completion.id)
|
||||
}
|
||||
|
||||
@Test func failedRefreshPublishesSafeFailureAndNextRefreshReplacesIt() async throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.removeFiles() }
|
||||
let summary = try await fixture.workflow.createSubscription(
|
||||
urlString: "https://example.com/sub.json")
|
||||
fixture.fetcher.setBehavior(.failure(.httpStatus(code: 503)))
|
||||
await fixture.workflow.refreshSubscription(summary.id)
|
||||
let failure = try #require(fixture.workflow.subscriptionRefreshCompletions[summary.id])
|
||||
#expect(failure.failure == .httpStatus(code: 503))
|
||||
fixture.fetcher.setBehavior(.success(SubscriptionDocs.flat(serverCount: 1)))
|
||||
try await fixture.workflow.editSubscriptionURL(
|
||||
summary.id, urlString: "https://example.com/new.json")
|
||||
let success = try #require(fixture.workflow.subscriptionRefreshCompletions[summary.id])
|
||||
#expect(success.failure == nil)
|
||||
#expect(success.id != failure.id)
|
||||
_ = try await fixture.workflow.removeSubscription(summary.id)
|
||||
#expect(fixture.workflow.subscriptionRefreshCompletions[summary.id] == nil)
|
||||
}
|
||||
|
||||
@Test func failureFeedbackSurvivesFailureToPersistStatus() async throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.removeFiles() }
|
||||
let summary = try await fixture.workflow.createSubscription(
|
||||
urlString: "https://example.com/sub.json")
|
||||
try FileManager.default.removeItem(at: fixture.directory)
|
||||
try Data("not a directory".utf8).write(to: fixture.directory)
|
||||
fixture.fetcher.setBehavior(.failure(.httpStatus(code: 503)))
|
||||
await fixture.workflow.refreshSubscription(summary.id)
|
||||
#expect(
|
||||
fixture.workflow.subscriptionRefreshCompletions[summary.id]?.failure == .httpStatus(code: 503)
|
||||
)
|
||||
#expect(fixture.workflow.subscriptions.first?.status == summary.status)
|
||||
}
|
||||
|
||||
@Test func cancellationAndDuplicateRequestDoNotPublishCompletion() async throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.removeFiles() }
|
||||
let summary = try await fixture.workflow.createSubscription(
|
||||
urlString: "https://example.com/sub.json")
|
||||
let gated = GatedFetcher()
|
||||
let workflow = fixture.reload(fetcher: gated)
|
||||
let task = Task { await workflow.refreshSubscription(summary.id) }
|
||||
for _ in 0..<1000 {
|
||||
if gated.didEnterFetch { break }
|
||||
await Task.yield()
|
||||
}
|
||||
#expect(gated.didEnterFetch)
|
||||
#expect(workflow.refreshingSubscriptionIDs.contains(summary.id))
|
||||
#expect(workflow.subscriptionRefreshCompletions[summary.id] == nil)
|
||||
await workflow.refreshSubscription(summary.id)
|
||||
#expect(gated.requestCount == 1)
|
||||
task.cancel()
|
||||
gated.releaseAll()
|
||||
await task.value
|
||||
#expect(workflow.subscriptionRefreshCompletions[summary.id] == nil)
|
||||
#expect(workflow.refreshingSubscriptionIDs.isEmpty)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class Fixture {
|
||||
let directory: URL
|
||||
let credentials = InMemoryCredentialStore()
|
||||
let fetcher = FakeSubscriptionFetcher(behavior: .success(SubscriptionDocs.flat(serverCount: 1)))
|
||||
let workflow: CatalogWorkflow
|
||||
|
||||
init() throws {
|
||||
directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("subscription-feedback-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
workflow = makeCatalogWorkflow(
|
||||
coordinator: CatalogCommitCoordinator(
|
||||
fileStore: CatalogFileStore(fileURL: directory.appendingPathComponent("catalog.json")),
|
||||
runtime: FakeCatalogRuntime()),
|
||||
credentials: credentials, subscriptionFetcher: fetcher)
|
||||
}
|
||||
|
||||
func reload(fetcher override: SubscriptionFetching? = nil) -> CatalogWorkflow {
|
||||
makeCatalogWorkflow(
|
||||
coordinator: CatalogCommitCoordinator(
|
||||
fileStore: CatalogFileStore(fileURL: directory.appendingPathComponent("catalog.json")),
|
||||
runtime: FakeCatalogRuntime()),
|
||||
credentials: credentials, subscriptionFetcher: override ?? fetcher)
|
||||
}
|
||||
|
||||
func removeFiles() { try? FileManager.default.removeItem(at: directory) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user