feat(servers): add subscription update with inline feedback

This commit is contained in:
Qiu Yuzhou
2026-10-14 21:37:20 +08:00
parent ea66ead10b
commit fba9e0250f
9 changed files with 316 additions and 1 deletions
@@ -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) }
}
}