From f871826ca3048558bd886cdf8b80e6489fbc7436 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Sun, 25 Oct 2026 19:41:22 +0800 Subject: [PATCH] feat: add GUI-scoped subscription automatic updates --- GLOSSARY.md | 9 + .../CatalogWorkflow+AutomaticUpdates.swift | 69 ++++++ .../CatalogWorkflow+Subscriptions.swift | 117 +++++++-- .../CatalogWorkflow/CatalogWorkflow.swift | 6 + .../CatalogWorkflowDependencies.swift | 5 + .../CatalogWorkflowModels.swift | 1 + .../Composition/ConnectivityLifecycle.swift | 20 +- .../App/Composition/MainApp.swift | 1 + .../Presentation/NodeDetailPresentation.swift | 17 +- .../App/Resources/Localizable.xcstrings | 88 +++++++ .../App/Resources/ServerDetails.xcstrings | 77 ++++++ .../SubscriptionInformationPresentation.swift | 10 + .../UI/Subscriptions/SubscriptionsView.swift | 58 ++++- .../Subscription/SubscriptionAutoUpdate.swift | 57 +++++ .../Subscription/SubscriptionDocument.swift | 21 +- .../Subscription/SubscriptionRecord.swift | 7 + .../SubscriptionAutoUpdateParserTests.swift | 68 ++++++ ...scriptionAutoUpdatePresentationTests.swift | 63 +++++ ...riptionAutoUpdateWorkflowTests+Races.swift | 140 +++++++++++ .../SubscriptionAutoUpdateWorkflowTests.swift | 223 ++++++++++++++++++ .../Tests/Support/CatalogFixtures.swift | 12 +- ...i-scoped-subscription-automatic-updates.md | 11 + 22 files changed, 1048 insertions(+), 32 deletions(-) create mode 100644 ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+AutomaticUpdates.swift create mode 100644 ShadowsocksX-NG2/Domain/Subscription/SubscriptionAutoUpdate.swift create mode 100644 ShadowsocksX-NG2/Tests/Subscription/SubscriptionAutoUpdateParserTests.swift create mode 100644 ShadowsocksX-NG2/Tests/Subscription/SubscriptionAutoUpdatePresentationTests.swift create mode 100644 ShadowsocksX-NG2/Tests/Subscription/SubscriptionAutoUpdateWorkflowTests+Races.swift create mode 100644 ShadowsocksX-NG2/Tests/Subscription/SubscriptionAutoUpdateWorkflowTests.swift create mode 100644 docs/adr/0052-gui-scoped-subscription-automatic-updates.md diff --git a/GLOSSARY.md b/GLOSSARY.md index 419d1cbc..c6c53637 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -61,6 +61,15 @@ _避免用词_:导出(导出指配置组或诊断报告等文件快照)。 **订阅资料(Subscription information)**: 供应商随订阅提供的已用流量、剩余流量、服务到期时间与流量重置时间等事实。它不包含服务器连接资料、凭据或完整订阅响应。 +**订阅自动更新(Subscription automatic update)**: +GUI 应用运行期间,按订阅的生效设置定期获取远程配置的行为。仅保留菜单栏也属于应用运行。 + +**供应商自动更新策略(Provider automatic update policy)**: +订阅供应商提供的自动更新设置(关闭、每天或每周)及是否允许用户覆盖的约束。允许覆盖时用户可选择本地设置或跟随供应商;禁止覆盖时供应商设置生效。 + +**下次计划更新时间(Next scheduled update time)**: +订阅下一次自动更新到期的时刻,与最后成功更新时间不同。自动更新关闭时没有该计划。 + **订阅组(Subscription group)**: 一个订阅拥有的配置子树,内容和结构以远程资料为准,用户不能在本地覆盖其成员、名称或连接字段。 diff --git a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+AutomaticUpdates.swift b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+AutomaticUpdates.swift new file mode 100644 index 00000000..1aca948e --- /dev/null +++ b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+AutomaticUpdates.swift @@ -0,0 +1,69 @@ +import Foundation + +extension CatalogWorkflow { + /// Owned by the GUI process, independently of workspace presentation. + func startSubscriptionAutomaticUpdates() { + automaticUpdatesRunning = true + scheduleAutomaticUpdateCheck() + } + + func stopSubscriptionAutomaticUpdates() { + automaticUpdatesRunning = false + automaticUpdateTimer?.cancel() + automaticUpdateTimer = nil + automaticUpdateBatch?.cancel() + for id in Array(subscriptionRequests.keys) { cancelSubscriptionRequest(id) } + } + + /// Startup and wake use the persisted deadline directly, without catch-up jitter. + func subscriptionAutomaticUpdatesDidWake() { + guard automaticUpdatesRunning else { return } + scheduleAutomaticUpdateCheck() + } + + func refreshDueSubscriptions() async { + guard !checkingDueSubscriptions else { return } + checkingDueSubscriptions = true + defer { checkingDueSubscriptions = false } + let due = subscriptions.filter { + $0.autoUpdate.effectiveMode != .off + && ($0.autoUpdate.nextUpdateAt.map { $0 <= dependencies.subscriptionNow() } ?? false) + } + for summary in due { + guard !Task.isCancelled else { return } + guard let current = subscriptions.first(where: { $0.id == summary.id }), + current.autoUpdate.effectiveMode != .off, + let date = current.autoUpdate.nextUpdateAt, date <= dependencies.subscriptionNow() + else { continue } + await refreshSubscription(summary.id) + } + } + + func scheduleAutomaticUpdateCheck(includeOverdue: Bool = true) { + automaticUpdateTimer?.cancel() + automaticUpdateTimer = nil + guard automaticUpdatesRunning, automaticUpdateBatch == nil, + let deadline = subscriptions.filter({ + $0.autoUpdate.effectiveMode != .off && !refreshingSubscriptionIDs.contains($0.id) + }).compactMap(\.autoUpdate.nextUpdateAt).filter({ + includeOverdue || $0 > dependencies.subscriptionNow() + }).min() + else { return } + let delay = max(0, deadline.timeIntervalSince(dependencies.subscriptionNow())) + let wait = dependencies.subscriptionWait + automaticUpdateTimer = Task { [weak self] in + do { + try await wait(delay) + try Task.checkCancellation() + } catch { return } + guard let self, self.automaticUpdatesRunning else { return } + self.automaticUpdateBatch = Task { [weak self] in + guard let self else { return } + await self.refreshDueSubscriptions() + self.automaticUpdateBatch = nil + // An unchanged overdue plan (for example a failed save) is not a retry loop. + self.scheduleAutomaticUpdateCheck(includeOverdue: false) + } + } + } +} diff --git a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift index 43157209..c83adcd0 100644 --- a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift +++ b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Subscriptions.swift @@ -15,13 +15,19 @@ extension CatalogWorkflow { /// 首次刷新。首次刷新失败留空分组加错误态(状态在刷新内标记,创建本身总是 /// 成功)。返回订阅 summary(状态在首次刷新中落定)。 @discardableResult - func createSubscription(urlString: String) async throws -> SubscriptionSummary { + func createSubscription( + urlString: String, autoUpdate: SubscriptionAutoUpdate = SubscriptionAutoUpdate() + ) async throws -> SubscriptionSummary { let url = try Self.validatedSubscriptionURL(urlString) - let record = SubscriptionRecord( + var record = SubscriptionRecord( id: .fresh(), groupID: .fresh(), url: url.absoluteString, status: .never) + record.autoUpdate.userOverride = autoUpdate.userOverride + record.autoUpdate.interval = autoUpdate.interval == .weekly ? .weekly : .daily + record.autoUpdate.schedule( + from: dependencies.subscriptionNow(), jitter: dependencies.subscriptionJitter()) try commitSubscriptionDocument { catalog, subscriptions in try catalog.addGroup(url.host ?? "", source: .subscription, id: record.groupID, now: Date()) subscriptions.append(record) @@ -36,30 +42,45 @@ extension CatalogWorkflow { /// 立即更新单个订阅。不抛错:失败标记到订阅状态(无静默, /// 有结构化状态可呈现)。同一订阅不重入:进行中的刷新期间重复触发直接跳过。 func refreshSubscription(_ id: NodeID) async { - guard !refreshingSubscriptionIDs.contains(id), - subscriptions.contains(where: { $0.id == id }) + guard subscriptionRequests[id] == nil, let source = subscriptionRecord(withID: id), + !Task.isCancelled else { return } + let token = UUID() setSubscriptionRefreshCompletion(nil, for: id) setRefreshInFlight(id, true) - defer { setRefreshInFlight(id, false) } + let request = Task { await runSubscriptionRefresh(source, token: token) } + subscriptionRequests[id] = (token, request) + await withTaskCancellationHandler { + await request.value + } onCancel: { + request.cancel() + } + } + + private func runSubscriptionRefresh(_ source: SubscriptionRecord, token: UUID) async { + let id = source.id + defer { + if subscriptionRequests[id]?.token == token { + subscriptionRequests[id] = nil + setRefreshInFlight(id, false) + scheduleAutomaticUpdateCheck() + } + } do { - try await performRefresh(id) + try await performRefresh(source, token: token) setSubscriptionRefreshFailure(nil, for: id) publishSubscriptionServerRefresh(id) - if subscriptions.contains(where: { $0.id == id }) { - setSubscriptionRefreshCompletion(SubscriptionRefreshCompletion(), for: id) - } + setSubscriptionRefreshCompletion(SubscriptionRefreshCompletion(), for: id) } catch is CancellationError { - // 取消不构成失败:快照与状态都不动。 + // Cancellation preserves the last committed result and plan. } catch { - guard !Task.isCancelled else { return } + guard !Task.isCancelled, subscriptionRequests[id]?.token == token, + subscriptionRecord(withID: id)?.url == source.url + else { return } let failure = SubscriptionRefreshFailure.from(error: error) setSubscriptionRefreshFailure(failure, for: id) await markRefreshFailed(subscriptionID: id, failure: failure) - if subscriptions.contains(where: { $0.id == id }) { - setSubscriptionRefreshCompletion( - SubscriptionRefreshCompletion(failure: failure), for: id) - } + setSubscriptionRefreshCompletion(SubscriptionRefreshCompletion(failure: failure), for: id) } } @@ -72,19 +93,57 @@ extension CatalogWorkflow { /// 编辑订阅 URL:订阅/固定分组/远端身份 /// 命名空间全部保留;最后成功快照保留到新 URL 刷新成功,随后立即刷新。 - func editSubscriptionURL(_ id: NodeID, urlString: String) async throws { + func editSubscriptionURL( + _ id: NodeID, urlString: String, autoUpdate: SubscriptionAutoUpdate? = nil + ) async throws { guard let record = subscriptionRecord(withID: id) else { throw SubscriptionFormError.notFound } let url = try Self.validatedSubscriptionURL(urlString) + let changedURL = record.url != url.absoluteString try dependencies.coordinator.commitSubscriptions { subscriptions in guard let index = subscriptions.firstIndex(where: { $0.id == id }) else { throw SubscriptionFormError.notFound } + let previousMode = subscriptions[index].autoUpdate.effectiveMode + if let autoUpdate, subscriptions[index].autoUpdate.allowsUserOverride { + subscriptions[index].autoUpdate.userOverride = autoUpdate.userOverride + subscriptions[index].autoUpdate.interval = autoUpdate.interval == .weekly ? .weekly : .daily + } subscriptions[index].url = url.absoluteString + if changedURL { subscriptions[index].autoUpdate.provider = nil } + if previousMode != subscriptions[index].autoUpdate.effectiveMode { + subscriptions[index].autoUpdate.schedule( + from: dependencies.subscriptionNow(), jitter: dependencies.subscriptionJitter()) + } + } + if changedURL { cancelSubscriptionRequest(id) } + republishCommittedState() + if changedURL { await refreshSubscription(record.id) } + } + + /// Settings-only saves never reconcile the proxy runtime or fetch the source. + func saveSubscriptionAutoUpdate(_ id: NodeID, settings: SubscriptionAutoUpdate) throws { + guard let record = subscriptionRecord(withID: id) else { + throw SubscriptionFormError.notFound + } + guard record.autoUpdate.allowsUserOverride else { return } + try dependencies.coordinator.commitSubscriptions { subscriptions in + guard let index = subscriptions.firstIndex(where: { $0.id == id }) else { return } + let previousMode = subscriptions[index].autoUpdate.effectiveMode + subscriptions[index].autoUpdate.userOverride = settings.userOverride + subscriptions[index].autoUpdate.interval = settings.interval == .weekly ? .weekly : .daily + if previousMode != subscriptions[index].autoUpdate.effectiveMode { + subscriptions[index].autoUpdate.schedule( + from: dependencies.subscriptionNow(), jitter: dependencies.subscriptionJitter()) + } } republishCommittedState() - await refreshSubscription(record.id) + } + + func cancelSubscriptionRequest(_ id: NodeID) { + subscriptionRequests.removeValue(forKey: id)?.task.cancel() + setRefreshInFlight(id, false) } /// 删除订阅:递归清除订阅源、固定分组与远端成员并清理凭据; @@ -99,6 +158,7 @@ extension CatalogWorkflow { removedEntries = try catalog.removeSubscriptionSubtree(of: record.groupID) subscriptions.removeAll { $0.id == id } } + cancelSubscriptionRequest(id) setSubscriptionRefreshCompletion(nil, for: id) return RemovalOutcome(removedNodeIDs: Set(removedEntries.map(\.id))) } @@ -114,12 +174,14 @@ extension CatalogWorkflow { // MARK: - 刷新内核 /// 完整获取 → 解析校验 → 原子提交快照与成功状态。 - private func performRefresh(_ id: NodeID) async throws { - guard let record = subscriptionRecord(withID: id) else { return } - let urlString = try subscriptionURL(for: record.id) + private func performRefresh(_ record: SubscriptionRecord, token: UUID) async throws { + let urlString = record.url let url = try Self.validatedSubscriptionURL(urlString) let data = try await dependencies.subscriptionFetcher.fetch(url) try Task.checkCancellation() + guard subscriptionRequests[record.id]?.token == token, + subscriptionRecord(withID: record.id)?.url == record.url + else { throw CancellationError() } let snapshot = try SubscriptionDocumentParser.parse(data, subscriptionID: record.id) let summary = subscriptions.first(where: { $0.id == record.id }) @@ -135,7 +197,7 @@ extension CatalogWorkflow { try Task.checkCancellation() do { try commitSubscriptionDocument { catalog, subscriptions in - let now = Date() + let now = dependencies.subscriptionNow() let resolved = Self.catalogSnapshot(snapshot.root, catalogGroupID: summary.groupID) try Task.checkCancellation() let name = snapshot.root.name.isEmpty ? fallbackName : snapshot.root.name @@ -146,6 +208,9 @@ extension CatalogWorkflow { subscriptions[index].information = snapshot.information.isEmpty ? nil : snapshot.information subscriptions[index].lastSucceededAt = now + subscriptions[index].autoUpdate.apply(snapshot.autoUpdatePolicy) + subscriptions[index].autoUpdate.schedule( + from: now, jitter: dependencies.subscriptionJitter()) } } } catch { @@ -161,13 +226,16 @@ extension CatalogWorkflow { ) async { try? commitSubscriptionDocument { _, subscriptions in if let index = subscriptions.firstIndex(where: { $0.id == subscriptionID }) { - subscriptions[index].status = .failed(at: Date(), failure: failure) + let now = dependencies.subscriptionNow() + subscriptions[index].status = .failed(at: now, failure: failure) + subscriptions[index].autoUpdate.schedule( + from: now, jitter: dependencies.subscriptionJitter()) } } } /// 订阅原始记录查询(module 内部;投影不含完整 URL)。 - private func subscriptionRecord(withID id: NodeID) -> SubscriptionRecord? { + func subscriptionRecord(withID id: NodeID) -> SubscriptionRecord? { dependencies.coordinator.committedSubscriptions.first { $0.id == id } } @@ -192,7 +260,8 @@ extension CatalogWorkflow { status: record.status, serverCount: serverCount, information: record.information, - lastSucceededAt: record.lastSucceededAt) + lastSucceededAt: record.lastSucceededAt, + autoUpdate: record.autoUpdate) } } diff --git a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow.swift b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow.swift index cade7ee5..45ec7abe 100644 --- a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow.swift +++ b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow.swift @@ -55,6 +55,11 @@ final class CatalogWorkflow: ObservableObject { let dependencies: CatalogWorkflowDependencies /// 启动时发现的 Legacy 快照(导入编排用;秘密值不进 projection)。 var discoveredLegacySnapshot: LegacySnapshot? + var subscriptionRequests: [NodeID: (token: UUID, task: Task)] = [:] + var automaticUpdatesRunning = false + var automaticUpdateTimer: Task? + var automaticUpdateBatch: Task? + var checkingDueSubscriptions = false /// 组合根注入依赖束;workflow 不自行创建任何生产 adapter。 init(dependencies: CatalogWorkflowDependencies) { @@ -265,6 +270,7 @@ final class CatalogWorkflow: ObservableObject { plugins: dependencies.plugins) subscriptions = Self.subscriptionSummaries( coordinator.committedSubscriptions, catalog: coordinator.committedCatalog) + scheduleAutomaticUpdateCheck() } // MARK: - module 内部发布缝(扩展文件经此更新只读投影,setter 保持 private) diff --git a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowDependencies.swift b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowDependencies.swift index 37275223..18aab650 100644 --- a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowDependencies.swift +++ b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowDependencies.swift @@ -20,4 +20,9 @@ struct CatalogWorkflowDependencies { let postLegacyImport: ((LegacyImportOutcome) async -> Void)? /// 激活缝:生产 adapter 为 `ProxyRuntimeController`,测试注入假 adapter。 let activator: Activating + var subscriptionNow: () -> Date = { Date() } + var subscriptionJitter: () -> Double = { Double.random(in: 0...0.1) } + var subscriptionWait: (TimeInterval) async throws -> Void = { + try await Task.sleep(for: .seconds($0)) + } } diff --git a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowModels.swift b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowModels.swift index 26f2dbd1..7e5cb977 100644 --- a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowModels.swift +++ b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflowModels.swift @@ -323,6 +323,7 @@ struct SubscriptionSummary: Identifiable, Equatable { let serverCount: Int var information: SubscriptionInformation? var lastSucceededAt: Date? + var autoUpdate = SubscriptionAutoUpdate() } /// 非持久化的刷新回执。每次操作有独立身份,失败事实不含秘密值。 diff --git a/ShadowsocksX-NG2/App/Composition/ConnectivityLifecycle.swift b/ShadowsocksX-NG2/App/Composition/ConnectivityLifecycle.swift index 1a1566bf..ac9e77ff 100644 --- a/ShadowsocksX-NG2/App/Composition/ConnectivityLifecycle.swift +++ b/ShadowsocksX-NG2/App/Composition/ConnectivityLifecycle.swift @@ -37,7 +37,25 @@ final class ConnectivityLifecycle: NSObject, NSApplicationDelegate { @MainActor final class ConnectivityApplicationDelegate: NSObject, NSApplicationDelegate { var lifecycle: ConnectivityLifecycle? + var catalogWorkflow: CatalogWorkflow? + + func applicationDidFinishLaunching(_ notification: Notification) { + catalogWorkflow?.startSubscriptionAutomaticUpdates() + NSWorkspace.shared.notificationCenter.addObserver( + self, selector: #selector(didWake), name: NSWorkspace.didWakeNotification, object: nil) + } + + @objc private func didWake(_ notification: Notification) { + catalogWorkflow?.subscriptionAutomaticUpdatesDidWake() + } + + func applicationWillTerminate(_ notification: Notification) { + catalogWorkflow?.stopSubscriptionAutomaticUpdates() + NSWorkspace.shared.notificationCenter.removeObserver(self) + } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - lifecycle?.applicationShouldTerminate(sender) ?? .terminateNow + catalogWorkflow?.stopSubscriptionAutomaticUpdates() + return lifecycle?.applicationShouldTerminate(sender) ?? .terminateNow } } diff --git a/ShadowsocksX-NG2/App/Composition/MainApp.swift b/ShadowsocksX-NG2/App/Composition/MainApp.swift index 4eedf981..21443581 100644 --- a/ShadowsocksX-NG2/App/Composition/MainApp.swift +++ b/ShadowsocksX-NG2/App/Composition/MainApp.swift @@ -54,6 +54,7 @@ struct ShadowsocksXNG2App: App { // 诊断导出的来源;wrapper 侧不注册,仍走 stderr → agent.log 收敛。 RuntimeLog.setSink(RuntimeEventStore.shared) appDelegate.lifecycle = connectivityLifecycle + appDelegate.catalogWorkflow = composition.catalogWorkflow } var body: some Scene { diff --git a/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift b/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift index 4e620acc..ee44bb8c 100644 --- a/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift +++ b/ShadowsocksX-NG2/App/Presentation/NodeDetailPresentation.swift @@ -118,6 +118,22 @@ struct NodeDetailPresentation { label: String(localized: "最后更新时间", table: "ServerDetails"), value: subscriptionTimestamp(date, locale: locale, timeZone: timeZone))) } + items.append( + Property( + label: String(localized: "自动更新", table: "ServerDetails"), + value: summary.autoUpdate.effectiveMode.displayName)) + if !summary.autoUpdate.allowsUserOverride { + items.append( + Property( + label: String(localized: "配置限制", table: "ServerDetails"), + value: String(localized: "供应商禁止覆盖自动更新设置", table: "ServerDetails"))) + } + if summary.autoUpdate.effectiveMode != .off, let date = summary.autoUpdate.nextUpdateAt { + items.append( + Property( + label: String(localized: "下次更新时间", table: "ServerDetails"), + value: subscriptionTimestamp(date, locale: locale, timeZone: timeZone))) + } if let used = summary.information?.bytesUsed { items.append( Property( @@ -142,7 +158,6 @@ struct NodeDetailPresentation { label: String(localized: "重置时间", table: "ServerDetails"), value: subscriptionTimestamp(date, locale: locale, timeZone: timeZone))) } - guard !items.isEmpty else { return nil } return Properties( id: .subscription, title: String(localized: "订阅", table: "ServerDetails"), items: items) } diff --git a/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings b/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings index d14c1cf8..fb8db8ca 100644 --- a/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings +++ b/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings @@ -9110,6 +9110,94 @@ } } } + }, + "自动更新": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatic updates" + } + } + } + }, + "设置来源": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings source" + } + } + } + }, + "跟随供应商": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Follow provider" + } + } + } + }, + "自定义": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom" + } + } + } + }, + "启用自动更新": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable automatic updates" + } + } + } + }, + "更新间隔": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update interval" + } + } + } + }, + "供应商设置": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Provider settings" + } + } + } + }, + "供应商禁止覆盖自动更新设置": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The provider does not allow overriding automatic update settings" + } + } + } } }, "version": "1.0" diff --git a/ShadowsocksX-NG2/App/Resources/ServerDetails.xcstrings b/ShadowsocksX-NG2/App/Resources/ServerDetails.xcstrings index 0fbd3d7c..65daaace 100644 --- a/ShadowsocksX-NG2/App/Resources/ServerDetails.xcstrings +++ b/ShadowsocksX-NG2/App/Resources/ServerDetails.xcstrings @@ -485,6 +485,83 @@ } } } + }, + "关闭": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" + } + } + } + }, + "每天": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Daily" + } + } + } + }, + "每周": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Weekly" + } + } + } + }, + "自动更新": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatic updates" + } + } + } + }, + "配置限制": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Configuration restrictions" + } + } + } + }, + "供应商禁止覆盖自动更新设置": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The provider does not allow overriding automatic update settings" + } + } + } + }, + "下次更新时间": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Next update" + } + } + } } }, "version": "1.0" diff --git a/ShadowsocksX-NG2/App/UI/Subscriptions/SubscriptionInformationPresentation.swift b/ShadowsocksX-NG2/App/UI/Subscriptions/SubscriptionInformationPresentation.swift index 2474e11e..7e3e51d4 100644 --- a/ShadowsocksX-NG2/App/UI/Subscriptions/SubscriptionInformationPresentation.swift +++ b/ShadowsocksX-NG2/App/UI/Subscriptions/SubscriptionInformationPresentation.swift @@ -16,3 +16,13 @@ extension SubscriptionInformation { .formatted(.byteCount(style: .binary, spellsOutZero: false).locale(locale)) } } + +extension SubscriptionUpdateMode { + var displayName: String { + switch self { + case .off: String(localized: "关闭", table: "ServerDetails") + case .daily: String(localized: "每天", table: "ServerDetails") + case .weekly: String(localized: "每周", table: "ServerDetails") + } + } +} diff --git a/ShadowsocksX-NG2/App/UI/Subscriptions/SubscriptionsView.swift b/ShadowsocksX-NG2/App/UI/Subscriptions/SubscriptionsView.swift index c413e3ec..541f32cd 100644 --- a/ShadowsocksX-NG2/App/UI/Subscriptions/SubscriptionsView.swift +++ b/ShadowsocksX-NG2/App/UI/Subscriptions/SubscriptionsView.swift @@ -17,6 +17,7 @@ struct AddSubscriptionSheet: View { @Environment(\.dismiss) private var dismiss @State private var urlString = "" + @State private var autoUpdate = SubscriptionAutoUpdate() var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -29,6 +30,7 @@ struct AddSubscriptionSheet: View { prompt: Text(verbatim: "https://example.com/subscription.json") .foregroundStyle(.placeholder) ) + SubscriptionAutoUpdateForm(settings: $autoUpdate) HStack { Spacer() Button("取消", role: .cancel) { dismiss() } @@ -44,7 +46,7 @@ struct AddSubscriptionSheet: View { private func create() { Task { do { - _ = try await workflow.createSubscription(urlString: urlString) + _ = try await workflow.createSubscription(urlString: urlString, autoUpdate: autoUpdate) dismiss() } catch { errors.present(error) @@ -62,6 +64,7 @@ struct EditSubscriptionURLSheet: View { @Environment(\.dismiss) private var dismiss @State private var urlString = "" + @State private var autoUpdate = SubscriptionAutoUpdate() @State private var loaded = false var body: some View { @@ -75,10 +78,11 @@ struct EditSubscriptionURLSheet: View { prompt: Text(verbatim: "https://example.com/subscription.json") .foregroundStyle(.placeholder) ) + SubscriptionAutoUpdateForm(settings: $autoUpdate) HStack { Spacer() Button("取消", role: .cancel) { dismiss() } - Button("保存并刷新") { save() } + Button("保存") { save() } .keyboardShortcut(.defaultAction) .disabled(urlString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } @@ -90,6 +94,7 @@ struct EditSubscriptionURLSheet: View { loaded = true do { urlString = try workflow.subscriptionURL(for: summary.id) + autoUpdate = summary.autoUpdate } catch { errors.present(error) } @@ -99,7 +104,8 @@ struct EditSubscriptionURLSheet: View { private func save() { Task { do { - try await workflow.editSubscriptionURL(summary.id, urlString: urlString) + try await workflow.editSubscriptionURL( + summary.id, urlString: urlString, autoUpdate: autoUpdate) dismiss() } catch { errors.present(error) @@ -107,3 +113,49 @@ struct EditSubscriptionURLSheet: View { } } } + +private struct SubscriptionAutoUpdateForm: View { + @Binding var settings: SubscriptionAutoUpdate + + var body: some View { + GroupBox("自动更新") { + VStack(alignment: .leading, spacing: 10) { + if settings.allowsUserOverride { + Picker( + "设置来源", + selection: Binding( + get: { settings.userOverride != nil }, + set: { settings.userOverride = $0 ? .off : nil } + ) + ) { + Text("跟随供应商").tag(false) + Text("自定义").tag(true) + } + if settings.userOverride != nil { + Toggle( + "启用自动更新", + isOn: Binding( + get: { settings.userOverride != .off }, + set: { settings.userOverride = $0 ? settings.interval : .off } + )) + Picker("更新间隔", selection: $settings.interval) { + Text(SubscriptionUpdateMode.daily.displayName).tag(SubscriptionUpdateMode.daily) + Text(SubscriptionUpdateMode.weekly.displayName).tag(SubscriptionUpdateMode.weekly) + } + .onChange(of: settings.interval) { _, interval in + if settings.userOverride != .off { settings.userOverride = interval } + } + } else { + Text(settings.effectiveMode.displayName).foregroundStyle(.secondary) + } + } else { + LabeledContent("供应商设置", value: settings.effectiveMode.displayName) + Text("供应商禁止覆盖自动更新设置") + .font(.callout).foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(6) + } + } +} diff --git a/ShadowsocksX-NG2/Domain/Subscription/SubscriptionAutoUpdate.swift b/ShadowsocksX-NG2/Domain/Subscription/SubscriptionAutoUpdate.swift new file mode 100644 index 00000000..38dbbe8d --- /dev/null +++ b/ShadowsocksX-NG2/Domain/Subscription/SubscriptionAutoUpdate.swift @@ -0,0 +1,57 @@ +import Foundation + +enum SubscriptionUpdateMode: String, Codable, CaseIterable, Sendable { + case off, daily, weekly + + var interval: TimeInterval? { + switch self { + case .off: nil + case .daily: 86_400 + case .weekly: 604_800 + } + } +} + +struct SubscriptionUpdatePolicy: Codable, Equatable, Sendable { + var mode: SubscriptionUpdateMode + var allowUserOverride: Bool + + enum CodingKeys: String, CodingKey { + case mode + case allowUserOverride = "allow_user_override" + } +} + +enum SubscriptionUpdatePolicyResult: Equatable, Sendable { + case absent, invalid + case valid(SubscriptionUpdatePolicy) +} + +/// Provider constraints hide, but never erase, the user's saved override. +struct SubscriptionAutoUpdate: Codable, Equatable, Sendable { + var provider: SubscriptionUpdatePolicy? + var userOverride: SubscriptionUpdateMode? + var interval: SubscriptionUpdateMode = .daily + var nextUpdateAt: Date? + + var allowsUserOverride: Bool { provider?.allowUserOverride != false } + + var effectiveMode: SubscriptionUpdateMode { + if let provider, !provider.allowUserOverride { return provider.mode } + return userOverride ?? provider?.mode ?? .off + } + + mutating func apply(_ result: SubscriptionUpdatePolicyResult) { + switch result { + case .absent: provider = nil + case .valid(let policy): provider = policy + case .invalid: break + } + } + + mutating func schedule(from date: Date, jitter: Double) { + nextUpdateAt = effectiveMode.interval.map { + date.addingTimeInterval($0 * (1 + min(max(jitter, 0), 0.1))) + } + } +} diff --git a/ShadowsocksX-NG2/Domain/Subscription/SubscriptionDocument.swift b/ShadowsocksX-NG2/Domain/Subscription/SubscriptionDocument.swift index 9c1b228a..fa6457d4 100644 --- a/ShadowsocksX-NG2/Domain/Subscription/SubscriptionDocument.swift +++ b/ShadowsocksX-NG2/Domain/Subscription/SubscriptionDocument.swift @@ -40,6 +40,7 @@ struct SubscriptionSnapshot: Equatable, Sendable { /// 根分组内容:落入订阅固定分组(固定分组身份客户端所有,不取自远端)。 let root: Group var information = SubscriptionInformation() + var autoUpdatePolicy: SubscriptionUpdatePolicyResult = .absent } /// 快照解析失败(整份拒绝,不做部分提交; 刷新失败族)。 @@ -88,10 +89,12 @@ enum SubscriptionDocumentParser { root: SubscriptionSnapshot.Group( id: NodeID(rawValue: "\(subscriptionID.rawValue):root"), name: "", children: leaves.map { .server($0.leaf) }), - information: SubscriptionInformation.parse(data)) + information: SubscriptionInformation.parse(data), + autoUpdatePolicy: root.extensionNS?.policy ?? .absent) } var snapshot = buildSnapshot(tree: tree, leaves: leaves, subscriptionID: subscriptionID) snapshot.information = SubscriptionInformation.parse(data) + snapshot.autoUpdatePolicy = root.extensionNS?.policy ?? .absent return snapshot } @@ -326,9 +329,25 @@ private struct ServerDTO: Decodable { private struct LenientExtension: Decodable { var value: ExtensionDTO? + var policy: SubscriptionUpdatePolicyResult = .invalid + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case autoUpdate = "auto_update" + } init(from decoder: Decoder) throws { value = try? ExtensionDTO(from: decoder) + guard let container = try? decoder.container(keyedBy: CodingKeys.self), + (try? container.decode(Int.self, forKey: .schemaVersion)) == 1 + else { return } + guard container.contains(.autoUpdate) else { + policy = .absent + return + } + if let parsed = try? container.decode(SubscriptionUpdatePolicy.self, forKey: .autoUpdate) { + policy = .valid(parsed) + } } } diff --git a/ShadowsocksX-NG2/Domain/Subscription/SubscriptionRecord.swift b/ShadowsocksX-NG2/Domain/Subscription/SubscriptionRecord.swift index a3dad25a..92ecfad3 100644 --- a/ShadowsocksX-NG2/Domain/Subscription/SubscriptionRecord.swift +++ b/ShadowsocksX-NG2/Domain/Subscription/SubscriptionRecord.swift @@ -14,6 +14,13 @@ struct SubscriptionRecord: Codable, Equatable, Sendable, Identifiable { var information: SubscriptionInformation? /// 独立于最近尝试状态,旧文件缺失时不猜测。 var lastSucceededAt: Date? + /// Optional envelope keeps existing encrypted documents compatible and disabled. + var autoUpdateState: SubscriptionAutoUpdate? + + var autoUpdate: SubscriptionAutoUpdate { + get { autoUpdateState ?? SubscriptionAutoUpdate() } + set { autoUpdateState = newValue } + } } /// 最近一次刷新结果。失败只保存白名单事实,不保存订阅 URL、秘密、凭据引用 diff --git a/ShadowsocksX-NG2/Tests/Subscription/SubscriptionAutoUpdateParserTests.swift b/ShadowsocksX-NG2/Tests/Subscription/SubscriptionAutoUpdateParserTests.swift new file mode 100644 index 00000000..0c5043ad --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Subscription/SubscriptionAutoUpdateParserTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing + +@testable import ShadowsocksX_NG2 + +struct SubscriptionAutoUpdateParserTests { + @Test(arguments: SubscriptionUpdateMode.allCases, [false, true]) + func acceptsOnlyCompletePolicies(mode: SubscriptionUpdateMode, allowsOverride: Bool) throws { + let snapshot = try parse( + #"{"schema_version":1,"auto_update":{"mode":"\#(mode.rawValue)","allow_user_override":\#(allowsOverride)}}"# + ) + #expect( + snapshot.autoUpdatePolicy == .valid(.init(mode: mode, allowUserOverride: allowsOverride))) + } + + @Test(arguments: [ + #"{}"#, #"null"#, #"false"#, #"[]"#, + #"{"mode":"daily"}"#, #"{"allow_user_override":true}"#, + #"{"mode":"hourly","allow_user_override":true}"#, + #"{"mode":1,"allow_user_override":true}"#, + #"{"mode":"daily","allow_user_override":1}"#, + #"{"mode":"daily","allow_user_override":"true"}"#, + ]) + func malformedPoliciesDoNotRejectServers(policy: String) throws { + let snapshot = try parse(#"{"schema_version":1,"auto_update":\#(policy)}"#) + #expect(snapshot.autoUpdatePolicy == .invalid) + #expect(snapshot.root.children.count == 1) + } + + @Test func invalidTreeAndPolicyAreIndependent() throws { + let validPolicy = try parse( + #"{"schema_version":1,"groups":false,"auto_update":{"mode":"weekly","allow_user_override":false}}"# + ) + #expect(validPolicy.autoUpdatePolicy == .valid(.init(mode: .weekly, allowUserOverride: false))) + #expect(validPolicy.root.children.count == 1) + let validTree = try parse( + #""" + {"schema_version":1, + "root_group_id":"root", + "groups":[{"id":"root", + "name":"Provider tree", + "children":[]}], + "auto_update":{}} + """# + ) + #expect(validTree.root.name == "Provider tree") + #expect(validTree.autoUpdatePolicy == .invalid) + } + + @Test func unsupportedVersionDoesNotAdoptPolicyAndMissingPolicyWithdraws() throws { + #expect( + try parse(#"{"schema_version":2,"auto_update":{"mode":"daily","allow_user_override":false}}"#) + .autoUpdatePolicy == .invalid) + #expect(try parse(#"{"schema_version":1}"#).autoUpdatePolicy == .absent) + let absent = try SubscriptionDocumentParser.parse( + Data(#"{"version":1,"servers":[]}"#.utf8), subscriptionID: .fresh()) + #expect(absent.autoUpdatePolicy == .absent) + } + + private func parse(_ namespace: String) throws -> SubscriptionSnapshot { + let data = Data( + """ + {"version":1,"servers":[{"server":"example.com","server_port":8388, + "method":"aes-256-gcm","password":"password"}],"x_shadowsocksx_ng":\(namespace)} + """.utf8) + return try SubscriptionDocumentParser.parse(data, subscriptionID: .fresh()) + } +} diff --git a/ShadowsocksX-NG2/Tests/Subscription/SubscriptionAutoUpdatePresentationTests.swift b/ShadowsocksX-NG2/Tests/Subscription/SubscriptionAutoUpdatePresentationTests.swift new file mode 100644 index 00000000..d265f601 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Subscription/SubscriptionAutoUpdatePresentationTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing + +@testable import ShadowsocksX_NG2 + +@MainActor +struct SubscriptionAutoUpdatePresentationTests { + @Test( + arguments: SubscriptionUpdateMode.allCases, + [(false, false), (false, true), (true, false), (true, true)]) + func detailsShowEffectiveModeRestrictionsAndPlanInOrder( + mode: SubscriptionUpdateMode, flags: (Bool, Bool) + ) async throws { + let (allowsOverride, hasSucceeded) = flags + try await withAutoUpdateFixture { fixture in + fixture.respond(mode: mode, allowsOverride: allowsOverride) + var summary = try await fixture.create() + if !hasSucceeded { summary.lastSucceededAt = nil } + summary.information = SubscriptionInformation(bytesUsed: 100) + let node = try #require(fixture.workflow.tree.node(withID: summary.groupID)) + let detail = NodeDetailPresentation( + node: node, server: nil, eligibility: nil, isActiveTarget: false, subscription: summary) + let rows = try #require(detail.properties.first { $0.id == .subscription }).items + var labels: [String] = [] + if hasSucceeded { labels.append("最后更新时间") } + labels.append("自动更新") + if !allowsOverride { labels.append("配置限制") } + if mode != .off { labels.append("下次更新时间") } + labels.append("已用流量") + #expect(rows.map(\.label) == labels) + #expect(rows.first { $0.label == "自动更新" }?.value == mode.displayName) + #expect(rows.first { $0.label == "配置限制" }?.value == (allowsOverride ? nil : "供应商禁止覆盖自动更新设置")) + } + } + + @Test func fileImportAndGroupShareDoNotPropagateAutomaticUpdates() async throws { + try await withAutoUpdateFixture { fixture in + let data = Data( + #""" + {"version":1, + "servers":[{"server":"example.com", + "server_port":8388, + "method":"aes-256-gcm", + "password":"p"}], + "x_shadowsocksx_ng":{"schema_version":1, + "auto_update":{"mode":"daily", + "allow_user_override":false}}} + """# + .utf8) + fixture.fetcher.setBehavior(.success(data)) + let summary = try await fixture.create() + let draft = try fixture.workflow.configurationGroupExportDraft(for: summary.groupID) + let json = try #require(String(data: draft.data, encoding: .utf8)) + #expect(!json.contains("auto_update")) + #expect(!json.contains("nextUpdateAt")) + let imported = await fixture.workflow.importServers( + from: [.file(name: "provider.json", data: data)], into: nil) + let groupID = try #require(imported.sources.first?.result.importedGroupID) + #expect(fixture.workflow.tree.node(withID: groupID)?.isManual == true) + #expect(fixture.workflow.subscriptions.count == 1) + } + } +} diff --git a/ShadowsocksX-NG2/Tests/Subscription/SubscriptionAutoUpdateWorkflowTests+Races.swift b/ShadowsocksX-NG2/Tests/Subscription/SubscriptionAutoUpdateWorkflowTests+Races.swift new file mode 100644 index 00000000..20fef62a --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Subscription/SubscriptionAutoUpdateWorkflowTests+Races.swift @@ -0,0 +1,140 @@ +import Foundation +import Testing + +@testable import ShadowsocksX_NG2 + +extension SubscriptionAutoUpdateWorkflowTests { + @Test func inFlightSettingsUseLatestModeAndCancellationPreservesPlan() async throws { + try await withAutoUpdateFixture { fixture in + let first = try await fixture.create(settings: .init(userOverride: .daily)) + let fetcher = GatedFetcher() + let workflow = fixture.makeWorkflow(fetcher: fetcher) + let request = Task { await workflow.refreshSubscription(first.id) } + await fetcher.waitUntilEntered() + await workflow.refreshSubscription(first.id) + #expect(fetcher.requestCount == 1) + fixture.now = fixture.now.addingTimeInterval(100) + try workflow.saveSubscriptionAutoUpdate(first.id, settings: .init(userOverride: .weekly)) + fixture.now = fixture.now.addingTimeInterval(200) + fetcher.releaseAll() + await request.value + #expect( + workflow.subscriptions.first?.autoUpdate.nextUpdateAt + == fixture.now.addingTimeInterval(604_800)) + let cancelling = GatedFetcher() + let reloaded = fixture.makeWorkflow(fetcher: cancelling) + let before = reloaded.subscriptions + let cancelled = Task { await reloaded.refreshSubscription(first.id) } + await cancelling.waitUntilEntered() + cancelled.cancel() + cancelling.releaseAll() + await cancelled.value + #expect(reloaded.subscriptions == before) + } + } + + @Test func changingURLRejectsOldResponseAndClearsPolicyEvenWhenNewFetchFails() async throws { + try await withAutoUpdateFixture { fixture in + fixture.respond(mode: .daily, allowsOverride: false) + let first = try await fixture.create( + settings: .init(userOverride: .weekly, interval: .weekly)) + let fetcher = URLChangeFetcher() + let workflow = fixture.makeWorkflow(fetcher: fetcher) + let request = Task { await workflow.refreshSubscription(first.id) } + await fetcher.entered.wait() + try await workflow.editSubscriptionURL(first.id, urlString: "https://new.example.com/sub") + #expect(workflow.subscriptions.first?.id == first.id) + #expect(workflow.subscriptions.first?.groupID == first.groupID) + #expect(workflow.subscriptions.first?.autoUpdate.provider == nil) + #expect(workflow.subscriptions.first?.autoUpdate.userOverride == .weekly) + #expect(workflow.subscriptions.first?.lastSucceededAt == first.lastSucceededAt) + let current = workflow.subscriptions + fetcher.release.release() + await request.value + #expect(workflow.subscriptions == current) + #expect(try workflow.subscriptionURL(for: first.id) == "https://new.example.com/sub") + } + } + + @Test func deletionRejectsLateResponse() async throws { + try await withAutoUpdateFixture { fixture in + let first = try await fixture.create() + let fetcher = GatedFetcher() + let workflow = fixture.makeWorkflow(fetcher: fetcher) + let request = Task { await workflow.refreshSubscription(first.id) } + await fetcher.waitUntilEntered() + _ = try await workflow.removeSubscription(first.id) + fetcher.releaseAll() + await request.value + #expect(workflow.subscriptions.isEmpty) + #expect(workflow.refreshingSubscriptionIDs.isEmpty) + } + } + + @Test func dueSubscriptionsRunSequentiallyAndOverlapWithManualRefreshIsSkipped() async throws { + try await withAutoUpdateFixture { fixture in + let first = try await fixture.create(settings: .init(userOverride: .daily)) + _ = try await fixture.create(settings: .init(userOverride: .daily)) + fixture.now = fixture.now.addingTimeInterval(100_000) + let fetcher = GatedFetcher() + let workflow = fixture.makeWorkflow(fetcher: fetcher) + let batch = Task { await workflow.refreshDueSubscriptions() } + await fetcher.waitUntilEntered() + #expect(fetcher.requestCount == 1) + await workflow.refreshSubscription(first.id) + #expect(fetcher.requestCount == 1) + fetcher.releaseAll() + await batch.value + #expect(fetcher.requestCount == 2) + } + } + + @Test func schedulerStartsWithOverduePlanAndStopsBeforeFurtherRequests() async throws { + try await withAutoUpdateFixture { fixture in + _ = try await fixture.create(settings: .init(userOverride: .daily)) + fixture.now = fixture.now.addingTimeInterval(1_000_000) + let fetcher = GatedFetcher() + let scheduled = AsyncGate() + let workflow = fixture.makeWorkflow( + fetcher: fetcher, + wait: { delay in + if delay > 0 { + scheduled.release() + try await Task.sleep(for: .seconds(delay)) + } + }) + workflow.startSubscriptionAutomaticUpdates() + await fetcher.waitUntilEntered() + workflow.subscriptionAutomaticUpdatesDidWake() + #expect(fetcher.requestCount == 1) + fetcher.releaseAll() + await scheduled.wait() + let plan = workflow.subscriptions.first?.autoUpdate.nextUpdateAt + #expect(plan == fixture.now.addingTimeInterval(86_400)) + workflow.stopSubscriptionAutomaticUpdates() + fixture.now = fixture.now.addingTimeInterval(1_000_000) + workflow.subscriptionAutomaticUpdatesDidWake() + #expect(fetcher.requestCount == 1) + } + } +} + +private struct URLChangeFetcher: SubscriptionFetching { + let entered = AsyncGate() + let release = AsyncGate() + + func fetch(_ url: URL) async throws -> Data { + if url.host == "new.example.com" { throw SubscriptionFetchError.httpStatus(code: 503) } + entered.release() + await release.wait() + return Data( + #""" + {"version":1, + "servers":[], + "x_shadowsocksx_ng":{"schema_version":1, + "auto_update":{"mode":"daily", + "allow_user_override":false}}} + """# + .utf8) + } +} diff --git a/ShadowsocksX-NG2/Tests/Subscription/SubscriptionAutoUpdateWorkflowTests.swift b/ShadowsocksX-NG2/Tests/Subscription/SubscriptionAutoUpdateWorkflowTests.swift new file mode 100644 index 00000000..bd4ce5bc --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Subscription/SubscriptionAutoUpdateWorkflowTests.swift @@ -0,0 +1,223 @@ +import Foundation +import Testing + +@testable import ShadowsocksX_NG2 + +@MainActor +struct SubscriptionAutoUpdateWorkflowTests { + @Test func newSubscriptionsDefaultToOffAndDailyWithoutAPlan() async throws { + try await withAutoUpdateFixture { fixture in + let summary = try await fixture.create() + #expect(summary.autoUpdate.effectiveMode == .off) + #expect(summary.autoUpdate.interval == .daily) + #expect(summary.autoUpdate.nextUpdateAt == nil) + } + } + + @Test func providerPolicyEnablesUpdatesWithoutAGroupTree() async throws { + try await withAutoUpdateFixture { fixture in + fixture.respond(mode: .daily, allowsOverride: false) + let summary = try await fixture.create() + #expect(summary.autoUpdate.effectiveMode == .daily) + #expect(!summary.autoUpdate.allowsUserOverride) + #expect(summary.autoUpdate.nextUpdateAt == fixture.now.addingTimeInterval(86_400)) + } + } + + @Test(arguments: [SubscriptionUpdateMode.daily, .weekly], [0.0, 0.1]) + func schedulesFromCompletionAndRetainsPlanAcrossReload( + mode: SubscriptionUpdateMode, jitter: Double + ) async throws { + try await withAutoUpdateFixture { fixture in + fixture.jitter = jitter + let first = try await fixture.create() + try fixture.workflow.saveSubscriptionAutoUpdate(first.id, settings: .init(userOverride: mode)) + let seconds: TimeInterval = + mode == .daily ? (jitter == 0 ? 86_400 : 95_040) : (jitter == 0 ? 604_800 : 665_280) + #expect(fixture.summary.autoUpdate.nextUpdateAt == fixture.now.addingTimeInterval(seconds)) + fixture.now = fixture.now.addingTimeInterval(100) + await fixture.workflow.refreshSubscription(first.id) + let success = fixture.summary + #expect(success.autoUpdate.nextUpdateAt == fixture.now.addingTimeInterval(seconds)) + fixture.now = fixture.now.addingTimeInterval(200) + fixture.fetcher.setBehavior(.failure(.httpStatus(code: 503))) + await fixture.workflow.refreshSubscription(first.id) + #expect(fixture.summary.autoUpdate.nextUpdateAt == fixture.now.addingTimeInterval(seconds)) + #expect(fixture.summary.lastSucceededAt == success.lastSucceededAt) + let reloaded = fixture.makeWorkflow() + #expect(reloaded.subscriptions == fixture.workflow.subscriptions) + } + } + + @Test func forcedPolicyPreservesOverrideThenFollowingClearsIt() async throws { + try await withAutoUpdateFixture { fixture in + let first = try await fixture.create() + try fixture.workflow.saveSubscriptionAutoUpdate( + first.id, settings: .init(userOverride: .weekly, interval: .weekly)) + fixture.respond(mode: .off, allowsOverride: false) + await fixture.workflow.refreshSubscription(first.id) + #expect(fixture.summary.autoUpdate.effectiveMode == .off) + #expect(fixture.summary.autoUpdate.userOverride == .weekly) + try fixture.workflow.saveSubscriptionAutoUpdate( + first.id, settings: .init(userOverride: .daily)) + #expect(fixture.summary.autoUpdate.userOverride == .weekly) + fixture.respond(mode: .daily, allowsOverride: true) + await fixture.workflow.refreshSubscription(first.id) + #expect(fixture.summary.autoUpdate.effectiveMode == .weekly) + try fixture.workflow.saveSubscriptionAutoUpdate(first.id, settings: .init()) + #expect(fixture.summary.autoUpdate.userOverride == nil) + #expect(fixture.summary.autoUpdate.effectiveMode == .daily) + fixture.fetcher.setBehavior(.success(SubscriptionDocs.flat(serverCount: 0))) + await fixture.workflow.refreshSubscription(first.id) + #expect(fixture.summary.autoUpdate.provider == nil) + #expect(fixture.summary.autoUpdate.effectiveMode == .off) + #expect(fixture.summary.autoUpdate.nextUpdateAt == nil) + } + } + + @Test func invalidOrFailedResponseRetainsPolicyButMissingPolicyWithdrawsIt() async throws { + try await withAutoUpdateFixture { fixture in + fixture.respond(mode: .daily, allowsOverride: false) + let first = try await fixture.create() + let policy = fixture.summary.autoUpdate.provider + fixture.fetcher.setBehavior( + .success( + Data( + #"{"version":1,"servers":[],"x_shadowsocksx_ng":{"schema_version":1,"auto_update":{}}}"# + .utf8))) + await fixture.workflow.refreshSubscription(first.id) + #expect(fixture.summary.autoUpdate.provider == policy) + if case .succeeded = fixture.summary.status { + } else { + Issue.record("Valid servers must still commit") + } + fixture.fetcher.setBehavior(.failure(.httpStatus(code: 503))) + await fixture.workflow.refreshSubscription(first.id) + #expect(fixture.summary.autoUpdate.provider == policy) + fixture.fetcher.setBehavior(.success(SubscriptionDocs.flat(serverCount: 0))) + await fixture.workflow.refreshSubscription(first.id) + #expect(fixture.summary.autoUpdate.provider == nil) + } + } + + @Test func settingsOnlyReplanWhenEffectiveModeChangesAndNeverConvergeRuntime() async throws { + try await withAutoUpdateFixture { fixture in + fixture.respond(mode: .daily, allowsOverride: true) + let first = try await fixture.create() + for _ in 0..<100 { + if case .syncing = fixture.coordinator.syncStatus { await Task.yield() } else { break } + } + if case .syncing = fixture.coordinator.syncStatus { Issue.record("Runtime did not settle") } + let converges = fixture.runtime.convergeCount + let plan = fixture.summary.autoUpdate.nextUpdateAt + let requests = fixture.fetcher.requestCount + fixture.now = fixture.now.addingTimeInterval(100) + try fixture.workflow.saveSubscriptionAutoUpdate( + first.id, settings: .init(userOverride: .daily)) + #expect(fixture.summary.autoUpdate.nextUpdateAt == plan) + try fixture.workflow.saveSubscriptionAutoUpdate( + first.id, settings: .init(userOverride: .weekly, interval: .weekly)) + #expect(fixture.summary.autoUpdate.nextUpdateAt == fixture.now.addingTimeInterval(604_800)) + try fixture.workflow.saveSubscriptionAutoUpdate( + first.id, settings: .init(userOverride: .off, interval: .weekly)) + #expect(fixture.summary.autoUpdate.nextUpdateAt == nil) + #expect(fixture.summary.autoUpdate.interval == .weekly) + #expect(fixture.fetcher.requestCount == requests) + #expect(fixture.runtime.convergeCount == converges) + } + } + + @Test func dueRefreshRunsOnceAfterManyMissedCyclesAndFailuresDoNotBlockOthers() async throws { + try await withAutoUpdateFixture { fixture in + let first = try await fixture.create(settings: .init(userOverride: .daily)) + let second = try await fixture.create(settings: .init(userOverride: .daily)) + let requests = fixture.fetcher.requestCount + await fixture.workflow.refreshDueSubscriptions() + #expect(fixture.fetcher.requestCount == requests) + fixture.now = fixture.now.addingTimeInterval(1_000_000) + fixture.fetcher.setBehavior(.failure(.httpStatus(code: 503))) + await fixture.workflow.refreshDueSubscriptions() + #expect(fixture.fetcher.requestCount == requests + 2) + await fixture.workflow.refreshDueSubscriptions() + #expect(fixture.fetcher.requestCount == requests + 2) + #expect(fixture.workflow.subscriptions.map(\.id) == [first.id, second.id]) + #expect( + fixture.workflow.subscriptions.allSatisfy { + $0.autoUpdate.nextUpdateAt == fixture.now.addingTimeInterval(86_400) + }) + } + } + + @Test func legacyRecordWithoutEnvelopeLoadsDisabledWithoutFetching() async throws { + try await withAutoUpdateFixture { fixture in + let first = try await fixture.create() + let record = try #require(fixture.coordinator.committedSubscriptions.first) + var json = try #require( + JSONSerialization.jsonObject(with: JSONEncoder().encode(record)) as? [String: Any]) + json.removeValue(forKey: "autoUpdateState") + let old = try JSONDecoder().decode( + SubscriptionRecord.self, from: JSONSerialization.data(withJSONObject: json)) + #expect(old.autoUpdate.effectiveMode == .off) + #expect(old.autoUpdate.interval == .daily) + let requests = fixture.fetcher.requestCount + let reload = fixture.makeWorkflow() + reload.startSubscriptionAutomaticUpdates() + await reload.refreshDueSubscriptions() + reload.stopSubscriptionAutomaticUpdates() + #expect(reload.subscriptions.first?.id == first.id) + #expect(fixture.fetcher.requestCount == requests) + } + } +} + +@MainActor +func withAutoUpdateFixture(_ body: (AutoUpdateFixture) async throws -> Void) async throws { + let fixture = AutoUpdateFixture() + defer { + fixture.workflow.stopSubscriptionAutomaticUpdates() + try? FileManager.default.removeItem(at: fixture.directory) + } + try await body(fixture) +} + +@MainActor +final class AutoUpdateFixture { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + var now = Date(timeIntervalSince1970: 1_700_000_000) + var jitter = 0.0 + let runtime = FakeCatalogRuntime() + let fetcher = FakeSubscriptionFetcher(behavior: .success(SubscriptionDocs.flat(serverCount: 0))) + lazy var store = makeTestCatalogStore(fileURL: directory.appendingPathComponent("catalog.json")) + lazy var coordinator = CatalogCommitCoordinator(fileStore: store, runtime: runtime) + lazy var workflow = makeWorkflow(coordinator: coordinator) + var summary: SubscriptionSummary { workflow.subscriptions[0] } + + func makeWorkflow( + coordinator: CatalogCommitCoordinator? = nil, fetcher: SubscriptionFetching? = nil, + wait: @escaping (TimeInterval) async throws -> Void = { + try await Task.sleep(for: .seconds($0)) + } + ) -> CatalogWorkflow { + makeCatalogWorkflow( + coordinator: coordinator + ?? CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime()), + subscriptionFetcher: fetcher ?? self.fetcher, + subscriptionNow: { [unowned self] in self.now }, + subscriptionJitter: { [unowned self] in self.jitter }, subscriptionWait: wait) + } + + func create(settings: SubscriptionAutoUpdate = .init()) async throws -> SubscriptionSummary { + try await workflow.createSubscription( + urlString: "https://example.com/sub", autoUpdate: settings) + } + + func respond(mode: SubscriptionUpdateMode, allowsOverride: Bool) { + fetcher.setBehavior( + .success( + Data( + """ + {"version":1,"servers":[],"x_shadowsocksx_ng":{"schema_version":1, + "auto_update":{"mode":"\(mode.rawValue)","allow_user_override":\(allowsOverride)}}} + """.utf8))) + } +} diff --git a/ShadowsocksX-NG2/Tests/Support/CatalogFixtures.swift b/ShadowsocksX-NG2/Tests/Support/CatalogFixtures.swift index dd05a5c9..8a834d6d 100644 --- a/ShadowsocksX-NG2/Tests/Support/CatalogFixtures.swift +++ b/ShadowsocksX-NG2/Tests/Support/CatalogFixtures.swift @@ -130,7 +130,12 @@ func makeCatalogWorkflow( behavior: .success(Data())), legacyImportService: LegacyImportService? = nil, postLegacyImport: ((LegacyImportOutcome) async -> Void)? = nil, - activator: Activating = RejectingActivator() + activator: Activating = RejectingActivator(), + subscriptionNow: @escaping () -> Date = { Date() }, + subscriptionJitter: @escaping () -> Double = { Double.random(in: 0...0.1) }, + subscriptionWait: @escaping (TimeInterval) async throws -> Void = { + try await Task.sleep(for: .seconds($0)) + } ) -> CatalogWorkflow { CatalogWorkflow( dependencies: CatalogWorkflowDependencies( @@ -143,7 +148,10 @@ func makeCatalogWorkflow( catalogStore: coordinator.fileStore, marker: InMemoryLegacyImportMarker()), postLegacyImport: postLegacyImport, - activator: activator)) + activator: activator, + subscriptionNow: subscriptionNow, + subscriptionJitter: subscriptionJitter, + subscriptionWait: subscriptionWait)) } // MARK: - 统一导入结果取用助手 diff --git a/docs/adr/0052-gui-scoped-subscription-automatic-updates.md b/docs/adr/0052-gui-scoped-subscription-automatic-updates.md new file mode 100644 index 00000000..390086db --- /dev/null +++ b/docs/adr/0052-gui-scoped-subscription-automatic-updates.md @@ -0,0 +1,11 @@ +# 订阅自动更新由 GUI 应用运行期间执行 + +订阅自动更新由 GUI 应用负责,仅保留菜单栏或静默启动时也执行;不交由生命周期独立的后台代理服务执行,也不为更新唤醒电脑。应用重新启动或电脑唤醒时,已到期订阅只补更新一次,以满足 GUI 存活边界并避免追赶多个错过周期。 + +供应商可提供关闭、每天或每周的设置,并决定是否允许用户覆盖;禁止覆盖时供应商设置生效,允许覆盖时用户可显式覆盖或恢复跟随供应商。供应商未提供配置时默认关闭,默认间隔为每天;供应商要求开启时,首次成功获取该配置即可启用,无需用户再次开启。 + +供应商策略与用户覆盖分别保存。供应商暂时禁止覆盖时保留用户选择,重新允许覆盖后恢复;成功响应缺失策略视为撤回,格式错误或更新失败则保留上次有效策略。修改订阅 URL 时清除旧供应商策略、保留用户选择,避免把旧地址的约束施加到新地址。 + +在现有 `x_shadowsocksx_ng`、`schema_version: 1` 中增加可选 `auto_update` 对象;自动更新策略与分组树独立解析,避免一个扩展功能的错误破坏另一个功能。 + +每天、每周分别表示本次更新结束后 24 小时、7 天,再加入基础间隔 0~10% 的随机延后。手动和自动更新无论成功或失败,都重新安排下一次,不额外短时重试;关闭时不产生自动计划。下次计划时间持久保存,重启不重新抽取抖动;最后更新时间仍仅表示最后成功更新。