From fba9e0250f8df39a168cd295c2c96d32cb830673 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Wed, 14 Oct 2026 21:37:20 +0800 Subject: [PATCH] feat(servers): add subscription update with inline feedback --- .../CatalogWorkflow+Subscriptions.swift | 9 ++ .../CatalogWorkflow/CatalogWorkflow.swift | 9 ++ .../CatalogWorkflowModels.swift | 13 ++ .../SubscriptionRefreshFeedbackState.swift | 25 ++++ .../App/Resources/ServerDetails.xcstrings | 55 ++++++++ .../UI/Servers/CatalogNodeDetailView.swift | 18 +++ .../App/UI/Servers/NodeDetailView.swift | 4 +- .../SubscriptionRefreshFeedbackView.swift | 51 +++++++ .../SubscriptionRefreshFeedbackTests.swift | 133 ++++++++++++++++++ 9 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 ShadowsocksX-NG2/App/Presentation/SubscriptionRefreshFeedbackState.swift create mode 100644 ShadowsocksX-NG2/App/UI/Servers/SubscriptionRefreshFeedbackView.swift create mode 100644 ShadowsocksX-NG2/Tests/Subscription/SubscriptionRefreshFeedbackTests.swift diff --git a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift index 8f28475b..5f9d72ac 100644 --- a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift +++ b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift @@ -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))) } diff --git a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow.swift b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow.swift index 11399942..46bd1fb1 100644 --- a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow.swift +++ b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow.swift @@ -24,6 +24,9 @@ final class CatalogWorkflow: ObservableObject { @Published private(set) var subscriptions: [SubscriptionSummary] = [] /// 正在刷新的订阅身份(并发守卫的只读观察面)。 @Published private(set) var refreshingSubscriptionIDs: Set = [] + /// 当前会话的刷新结果;不从持久化状态重建,供各入口共用结束时间。 + @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 ) { diff --git a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowModels.swift b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowModels.swift index c5b3c0c3..7f8c2616 100644 --- a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowModels.swift +++ b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowModels.swift @@ -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. diff --git a/ShadowsocksX-NG2/App/Presentation/SubscriptionRefreshFeedbackState.swift b/ShadowsocksX-NG2/App/Presentation/SubscriptionRefreshFeedbackState.swift new file mode 100644 index 00000000..97670111 --- /dev/null +++ b/ShadowsocksX-NG2/App/Presentation/SubscriptionRefreshFeedbackState.swift @@ -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) + } +} diff --git a/ShadowsocksX-NG2/App/Resources/ServerDetails.xcstrings b/ShadowsocksX-NG2/App/Resources/ServerDetails.xcstrings index 834e436f..59b368db 100644 --- a/ShadowsocksX-NG2/App/Resources/ServerDetails.xcstrings +++ b/ShadowsocksX-NG2/App/Resources/ServerDetails.xcstrings @@ -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" diff --git a/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift b/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift index dee4419c..2a970540 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift @@ -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) diff --git a/ShadowsocksX-NG2/App/UI/Servers/NodeDetailView.swift b/ShadowsocksX-NG2/App/UI/Servers/NodeDetailView.swift index 26624e0a..0f8b4c2e 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/NodeDetailView.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/NodeDetailView.swift @@ -1,9 +1,10 @@ import SwiftUI /// 只接受展示值与外部动作;不查询节点类型、工作流或凭据。 -struct NodeDetailView: View { +struct NodeDetailView: 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: View { } HStack(spacing: 16) { actions } .buttonStyle(.link) + feedback } Divider() ForEach(Array(detail.properties.enumerated()), id: \.offset) { _, group in diff --git a/ShadowsocksX-NG2/App/UI/Servers/SubscriptionRefreshFeedbackView.swift b/ShadowsocksX-NG2/App/UI/Servers/SubscriptionRefreshFeedbackView.swift new file mode 100644 index 00000000..4fe09d85 --- /dev/null +++ b/ShadowsocksX-NG2/App/UI/Servers/SubscriptionRefreshFeedbackView.swift @@ -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") + } +} diff --git a/ShadowsocksX-NG2/Tests/Subscription/SubscriptionRefreshFeedbackTests.swift b/ShadowsocksX-NG2/Tests/Subscription/SubscriptionRefreshFeedbackTests.swift new file mode 100644 index 00000000..b37fc4c2 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Subscription/SubscriptionRefreshFeedbackTests.swift @@ -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) } + } +}