refactor: centralize subscription automatic update transitions

This commit is contained in:
Qiu Yuzhou
2026-10-25 22:06:39 +08:00
parent 34caebf322
commit f3ba330cf4
10 changed files with 151 additions and 82 deletions
@@ -16,7 +16,7 @@ extension CatalogWorkflow {
/// 成功)。返回订阅 summary(状态在首次刷新中落定)。
@discardableResult
func createSubscription(
urlString: String, autoUpdate: SubscriptionAutoUpdate = SubscriptionAutoUpdate()
urlString: String, autoUpdate: SubscriptionAutoUpdateSettings = .init()
) async throws -> SubscriptionSummary {
let url = try Self.validatedSubscriptionURL(urlString)
var record = SubscriptionRecord(
@@ -24,10 +24,8 @@ extension CatalogWorkflow {
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())
record.autoUpdate.save(
autoUpdate, at: 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)
@@ -91,10 +89,10 @@ extension CatalogWorkflow {
}
}
/// 编辑订阅 URL:订阅/固定分组/远端身份
/// 命名空间全部保留;最后成功快照保留到新 URL 刷新成功,随后立即刷新。
func editSubscriptionURL(
_ id: NodeID, urlString: String, autoUpdate: SubscriptionAutoUpdate? = nil
/// 保存订阅地址与自动更新选择,保留订阅、固定分组与远端身份。
/// 仅地址变化时立即刷新;设置保存不获取远端内容或触发运行时收敛。
func saveSubscription(
_ id: NodeID, urlString: String, autoUpdate: SubscriptionAutoUpdateSettings? = nil
) async throws {
guard let record = subscriptionRecord(withID: id) else {
throw SubscriptionFormError.notFound
@@ -105,42 +103,16 @@ extension CatalogWorkflow {
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].autoUpdate.save(
autoUpdate, sourceChanged: changedURL,
at: dependencies.subscriptionNow(), jitter: dependencies.subscriptionJitter())
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()
}
func cancelSubscriptionRequest(_ id: NodeID) {
subscriptionRequests.removeValue(forKey: id)?.task.cancel()
setRefreshInFlight(id, false)
@@ -208,9 +180,8 @@ 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())
subscriptions[index].autoUpdate.refreshSucceeded(
policy: snapshot.autoUpdatePolicy, at: now, jitter: dependencies.subscriptionJitter())
}
}
} catch {
@@ -228,8 +199,8 @@ extension CatalogWorkflow {
if let index = subscriptions.firstIndex(where: { $0.id == subscriptionID }) {
let now = dependencies.subscriptionNow()
subscriptions[index].status = .failed(at: now, failure: failure)
subscriptions[index].autoUpdate.schedule(
from: now, jitter: dependencies.subscriptionJitter())
subscriptions[index].autoUpdate.refreshFailed(
at: now, jitter: dependencies.subscriptionJitter())
}
}
}
@@ -82,7 +82,7 @@ struct ServersView: View {
onCreated: selectCreatedNode)
}
.sheet(item: $subscriptionEditTarget) { summary in
EditSubscriptionURLSheet(workflow: workflow, errors: errors, summary: summary)
EditSubscriptionSheet(workflow: workflow, errors: errors, summary: summary)
}
.sheet(isPresented: $isPresentingAddSubscription) {
AddSubscriptionSheet(workflow: workflow, errors: errors)
@@ -17,7 +17,7 @@ struct AddSubscriptionSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var urlString = ""
@State private var autoUpdate = SubscriptionAutoUpdate()
@State private var autoUpdate = SubscriptionAutoUpdateSettings()
var body: some View {
VStack(alignment: .leading, spacing: 16) {
@@ -55,16 +55,16 @@ struct AddSubscriptionSheet: View {
}
}
/// 「编辑订阅 URL」表单(保留订阅与固定分组身份);保存后立即刷新。
/// 订阅编辑表单(保留订阅与固定分组身份);仅地址变化时立即刷新。
/// URL 明文经显式命令读取。
struct EditSubscriptionURLSheet: View {
struct EditSubscriptionSheet: View {
let workflow: CatalogWorkflow
let errors: ErrorAlertPresenter
let summary: SubscriptionSummary
@Environment(\.dismiss) private var dismiss
@State private var urlString = ""
@State private var autoUpdate = SubscriptionAutoUpdate()
@State private var autoUpdate = SubscriptionAutoUpdateSettings()
@State private var loaded = false
var body: some View {
@@ -78,7 +78,7 @@ struct EditSubscriptionURLSheet: View {
prompt: Text(verbatim: "https://example.com/subscription.json")
.foregroundStyle(.placeholder)
)
SubscriptionAutoUpdateForm(settings: $autoUpdate)
SubscriptionAutoUpdateForm(settings: $autoUpdate, provider: summary.autoUpdate.provider)
HStack {
Spacer()
Button("取消", role: .cancel) { dismiss() }
@@ -94,7 +94,7 @@ struct EditSubscriptionURLSheet: View {
loaded = true
do {
urlString = try workflow.subscriptionURL(for: summary.id)
autoUpdate = summary.autoUpdate
autoUpdate = summary.autoUpdate.settings
} catch {
errors.present(error)
}
@@ -104,7 +104,7 @@ struct EditSubscriptionURLSheet: View {
private func save() {
Task {
do {
try await workflow.editSubscriptionURL(
try await workflow.saveSubscription(
summary.id, urlString: urlString, autoUpdate: autoUpdate)
dismiss()
} catch {
@@ -115,12 +115,15 @@ struct EditSubscriptionURLSheet: View {
}
private struct SubscriptionAutoUpdateForm: View {
@Binding var settings: SubscriptionAutoUpdate
@Binding var settings: SubscriptionAutoUpdateSettings
var provider: SubscriptionUpdatePolicy?
private var providerMode: SubscriptionUpdateMode { provider?.mode ?? .off }
var body: some View {
GroupBox("自动更新") {
VStack(alignment: .leading, spacing: 10) {
if settings.allowsUserOverride {
if provider?.allowUserOverride != false {
Picker(
"设置来源",
selection: Binding(
@@ -146,10 +149,10 @@ private struct SubscriptionAutoUpdateForm: View {
if settings.userOverride != .off { settings.userOverride = interval }
}
} else {
Text(settings.effectiveMode.displayName).foregroundStyle(.secondary)
Text(providerMode.displayName).foregroundStyle(.secondary)
}
} else {
LabeledContent("供应商设置", value: settings.effectiveMode.displayName)
LabeledContent("供应商设置", value: providerMode.displayName)
Text("供应商禁止覆盖自动更新设置")
.font(.callout).foregroundStyle(.secondary)
}
@@ -27,12 +27,18 @@ enum SubscriptionUpdatePolicyResult: Equatable, Sendable {
case valid(SubscriptionUpdatePolicy)
}
/// Provider constraints hide, but never erase, the user's saved override.
struct SubscriptionAutoUpdate: Codable, Equatable, Sendable {
var provider: SubscriptionUpdatePolicy?
/// Editable choices; provider facts and the persisted deadline are not form input.
struct SubscriptionAutoUpdateSettings: Equatable, Sendable {
var userOverride: SubscriptionUpdateMode?
var interval: SubscriptionUpdateMode = .daily
var nextUpdateAt: Date?
}
/// Provider constraints hide, but never erase, the user's saved override.
struct SubscriptionAutoUpdate: Codable, Equatable, Sendable {
private(set) var provider: SubscriptionUpdatePolicy?
private(set) var userOverride: SubscriptionUpdateMode?
private(set) var interval: SubscriptionUpdateMode = .daily
private(set) var nextUpdateAt: Date?
var allowsUserOverride: Bool { provider?.allowUserOverride != false }
@@ -41,15 +47,40 @@ struct SubscriptionAutoUpdate: Codable, Equatable, Sendable {
return userOverride ?? provider?.mode ?? .off
}
mutating func apply(_ result: SubscriptionUpdatePolicyResult) {
switch result {
var settings: SubscriptionAutoUpdateSettings {
.init(userOverride: userOverride, interval: interval)
}
/// A source change releases its policy only after applying the old constraints.
mutating func save(
_ settings: SubscriptionAutoUpdateSettings?, sourceChanged: Bool = false,
at date: Date, jitter: Double
) {
let previousMode = effectiveMode
if let settings, allowsUserOverride {
userOverride = settings.userOverride
interval = settings.interval == .weekly ? .weekly : .daily
}
if sourceChanged { provider = nil }
if previousMode != effectiveMode { schedule(from: date, jitter: jitter) }
}
mutating func refreshSucceeded(
policy: SubscriptionUpdatePolicyResult, at date: Date, jitter: Double
) {
switch policy {
case .absent: provider = nil
case .valid(let policy): provider = policy
case .invalid: break
}
schedule(from: date, jitter: jitter)
}
mutating func schedule(from date: Date, jitter: Double) {
mutating func refreshFailed(at date: Date, jitter: Double) {
schedule(from: date, jitter: jitter)
}
private mutating func schedule(from date: Date, jitter: Double) {
nextUpdateAt = effectiveMode.interval.map {
date.addingTimeInterval($0 * (1 + min(max(jitter, 0), 0.1)))
}
@@ -152,7 +152,7 @@ struct CatalogWorkflowLibraryTests {
try await requireTestCondition("old URL fetch entered") { fetcher.didEnterFetch }
let edit = Task {
do {
try await workflow.editSubscriptionURL(record.id, urlString: "https://new.example/sub")
try await workflow.saveSubscription(record.id, urlString: "https://new.example/sub")
} catch { Issue.record(error) }
}
try await withTestTask(edit, release: fetcher.releaseAll) {
@@ -184,8 +184,11 @@ struct CatalogWorkflowLibraryTests {
try FileManager.default.removeItem(at: gate)
try Data().write(to: gate)
await #expect(throws: (any Error).self) {
try await workflow.editSubscriptionURL(subscription.id, urlString: "https://new.example/sub")
try await workflow.saveSubscription(
subscription.id, urlString: "https://new.example/sub",
autoUpdate: .init(userOverride: .daily))
}
#expect(workflow.subscriptions.first?.autoUpdate == subscription.autoUpdate)
#expect(try workflow.subscriptionURL(for: subscription.id) == "https://old.example/sub")
#expect(fetcher.requestCount == requests)
#expect(runtime.convergeCount == 0)
@@ -160,7 +160,7 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase {
// 新地址先失败:快照保留、身份不动。
fetcher.setBehavior(.failure(.httpStatus(code: 404)))
try await workflow.editSubscriptionURL(
try await workflow.saveSubscription(
record.id, urlString: "https://other.example.com/v2.json")
XCTAssertEqual(workflow.subscriptions[0].id, originalRecordID, "订阅身份保留")
guard case .failed = workflow.subscriptions[0].status else {
@@ -182,7 +182,7 @@ final class CatalogWorkflowSubscriptionTests: XCTestCase {
func testEditURLRequiresHTTPS() async throws {
let record = try await seedSuccessfulSubscription()
await expectThrowsAsync(
{ try await workflow.editSubscriptionURL(record.id, urlString: "http://p.example.com/x") },
{ try await workflow.saveSubscription(record.id, urlString: "http://p.example.com/x") },
onThrow: { error in
XCTAssertEqual(error as? SubscriptionFormError, .invalidURL)
})
@@ -0,0 +1,53 @@
import Foundation
import Testing
@testable import ShadowsocksX_NG2
struct SubscriptionAutoUpdateTests {
@Test func savingSettingsPreservesPlanUntilEffectiveModeChanges() {
let now = Date(timeIntervalSince1970: 1_700_000_000)
var state = SubscriptionAutoUpdate()
state.save(.init(userOverride: .daily, interval: .off), at: now, jitter: 0.1)
#expect(state.interval == .daily)
#expect(state.nextUpdateAt == now.addingTimeInterval(95_040))
state.save(.init(userOverride: .daily, interval: .weekly), at: now, jitter: 0)
#expect(state.interval == .weekly)
#expect(state.nextUpdateAt == now.addingTimeInterval(95_040))
state.save(.init(userOverride: .off, interval: .weekly), at: now, jitter: 0)
#expect(state.nextUpdateAt == nil)
#expect(state.interval == .weekly)
}
@Test func changingSourceReleasesPolicyAndRetainsTheLockedUserChoice() {
let now = Date(timeIntervalSince1970: 1_700_000_000)
var state = SubscriptionAutoUpdate()
state.save(.init(userOverride: .weekly, interval: .weekly), at: now, jitter: 0)
state.refreshSucceeded(
policy: .valid(.init(mode: .daily, allowUserOverride: false)), at: now, jitter: 0)
state.save(
.init(userOverride: .off), sourceChanged: true,
at: now.addingTimeInterval(100), jitter: 0)
#expect(state.provider == nil)
#expect(state.userOverride == .weekly)
#expect(state.interval == .weekly)
#expect(state.nextUpdateAt == now.addingTimeInterval(604_900))
}
@Test func refreshResultsDistinguishWithdrawalFromInvalidPolicyAndFailure() {
let now = Date(timeIntervalSince1970: 1_700_000_000)
let policy = SubscriptionUpdatePolicy(mode: .daily, allowUserOverride: false)
var state = SubscriptionAutoUpdate()
state.refreshSucceeded(policy: .valid(policy), at: now, jitter: 0)
#expect(state.nextUpdateAt == now.addingTimeInterval(86_400))
state.refreshSucceeded(policy: .invalid, at: now.addingTimeInterval(100), jitter: 0.1)
#expect(state.provider == policy)
#expect(state.nextUpdateAt == now.addingTimeInterval(95_140))
state.refreshFailed(at: now.addingTimeInterval(200), jitter: 0)
#expect(state.provider == policy)
#expect(state.nextUpdateAt == now.addingTimeInterval(86_600))
state.refreshSucceeded(policy: .absent, at: now.addingTimeInterval(300), jitter: 0)
#expect(state.provider == nil)
#expect(state.nextUpdateAt == nil)
}
}
@@ -15,7 +15,8 @@ extension SubscriptionAutoUpdateWorkflowTests {
await workflow.refreshSubscription(first.id)
#expect(fetcher.requestCount == 1)
fixture.now = fixture.now.addingTimeInterval(100)
try workflow.saveSubscriptionAutoUpdate(first.id, settings: .init(userOverride: .weekly))
try await workflow.saveSubscription(
first.id, urlString: "https://example.com/sub", autoUpdate: .init(userOverride: .weekly))
fixture.now = fixture.now.addingTimeInterval(200)
}
#expect(
@@ -44,7 +45,7 @@ extension SubscriptionAutoUpdateWorkflowTests {
var current: [SubscriptionSummary] = []
try await withTestTask(request, release: fetcher.release.release) {
try #require(await fetcher.entered.wait())
try await workflow.editSubscriptionURL(first.id, urlString: "https://new.example.com/sub")
try await workflow.saveSubscription(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)
@@ -31,7 +31,8 @@ struct SubscriptionAutoUpdateWorkflowTests {
try await withAutoUpdateFixture { fixture in
fixture.jitter = jitter
let first = try await fixture.create()
try fixture.workflow.saveSubscriptionAutoUpdate(first.id, settings: .init(userOverride: mode))
try await fixture.workflow.saveSubscription(
first.id, urlString: "https://example.com/sub", autoUpdate: .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))
@@ -52,19 +53,21 @@ struct SubscriptionAutoUpdateWorkflowTests {
@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))
try await fixture.workflow.saveSubscription(
first.id, urlString: "https://example.com/sub",
autoUpdate: .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))
try await fixture.workflow.saveSubscription(
first.id, urlString: "https://example.com/sub", autoUpdate: .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())
try await fixture.workflow.saveSubscription(
first.id, urlString: "https://example.com/sub", autoUpdate: .init())
#expect(fixture.summary.autoUpdate.userOverride == nil)
#expect(fixture.summary.autoUpdate.effectiveMode == .daily)
fixture.fetcher.setBehavior(.success(SubscriptionDocs.flat(serverCount: 0)))
@@ -112,14 +115,16 @@ struct SubscriptionAutoUpdateWorkflowTests {
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))
try await fixture.workflow.saveSubscription(
first.id, urlString: "https://example.com/sub", autoUpdate: .init(userOverride: .daily))
#expect(fixture.summary.autoUpdate.nextUpdateAt == plan)
try fixture.workflow.saveSubscriptionAutoUpdate(
first.id, settings: .init(userOverride: .weekly, interval: .weekly))
try await fixture.workflow.saveSubscription(
first.id, urlString: "https://example.com/sub",
autoUpdate: .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))
try await fixture.workflow.saveSubscription(
first.id, urlString: "https://example.com/sub",
autoUpdate: .init(userOverride: .off, interval: .weekly))
#expect(fixture.summary.autoUpdate.nextUpdateAt == nil)
#expect(fixture.summary.autoUpdate.interval == .weekly)
#expect(fixture.fetcher.requestCount == requests)
@@ -206,7 +211,9 @@ final class AutoUpdateFixture {
subscriptionJitter: { [unowned self] in self.jitter }, subscriptionWait: wait)
}
func create(settings: SubscriptionAutoUpdate = .init()) async throws -> SubscriptionSummary {
func create(settings: SubscriptionAutoUpdateSettings = .init()) async throws
-> SubscriptionSummary
{
try await workflow.createSubscription(
urlString: "https://example.com/sub", autoUpdate: settings)
}
@@ -29,7 +29,7 @@ struct SubscriptionRefreshFeedbackTests {
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(
try await fixture.workflow.saveSubscription(
summary.id, urlString: "https://example.com/new.json")
let success = try #require(fixture.workflow.subscriptionRefreshCompletions[summary.id])
#expect(success.failure == nil)