feat: add GUI-scoped subscription automatic updates

This commit is contained in:
Qiu Yuzhou
2026-10-25 19:41:22 +08:00
parent 2241d3f7f9
commit f871826ca3
22 changed files with 1048 additions and 32 deletions
+9
View File
@@ -61,6 +61,15 @@ _避免用词_:导出(导出指配置组或诊断报告等文件快照)。
**订阅资料(Subscription information)**:
供应商随订阅提供的已用流量、剩余流量、服务到期时间与流量重置时间等事实。它不包含服务器连接资料、凭据或完整订阅响应。
**订阅自动更新(Subscription automatic update)**:
GUI 应用运行期间,按订阅的生效设置定期获取远程配置的行为。仅保留菜单栏也属于应用运行。
**供应商自动更新策略(Provider automatic update policy)**:
订阅供应商提供的自动更新设置(关闭、每天或每周)及是否允许用户覆盖的约束。允许覆盖时用户可选择本地设置或跟随供应商;禁止覆盖时供应商设置生效。
**下次计划更新时间(Next scheduled update time)**:
订阅下一次自动更新到期的时刻,与最后成功更新时间不同。自动更新关闭时没有该计划。
**订阅组(Subscription group)**:
一个订阅拥有的配置子树,内容和结构以远程资料为准,用户不能在本地覆盖其成员、名称或连接字段。
@@ -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)
}
}
}
}
@@ -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)
}
}
@@ -55,6 +55,11 @@ final class CatalogWorkflow: ObservableObject {
let dependencies: CatalogWorkflowDependencies
/// 启动时发现的 Legacy 快照(导入编排用;秘密值不进 projection)。
var discoveredLegacySnapshot: LegacySnapshot?
var subscriptionRequests: [NodeID: (token: UUID, task: Task<Void, Never>)] = [:]
var automaticUpdatesRunning = false
var automaticUpdateTimer: Task<Void, Never>?
var automaticUpdateBatch: Task<Void, Never>?
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)
@@ -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))
}
}
@@ -323,6 +323,7 @@ struct SubscriptionSummary: Identifiable, Equatable {
let serverCount: Int
var information: SubscriptionInformation?
var lastSucceededAt: Date?
var autoUpdate = SubscriptionAutoUpdate()
}
/// 非持久化的刷新回执。每次操作有独立身份,失败事实不含秘密值。
@@ -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
}
}
@@ -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 {
@@ -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)
}
@@ -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"
@@ -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"
@@ -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")
}
}
}
@@ -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)
}
}
}
@@ -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)))
}
}
}
@@ -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)
}
}
}
@@ -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、秘密、凭据引用
@@ -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())
}
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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)))
}
}
@@ -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: - 统一导入结果取用助手
@@ -0,0 +1,11 @@
# 订阅自动更新由 GUI 应用运行期间执行
订阅自动更新由 GUI 应用负责,仅保留菜单栏或静默启动时也执行;不交由生命周期独立的后台代理服务执行,也不为更新唤醒电脑。应用重新启动或电脑唤醒时,已到期订阅只补更新一次,以满足 GUI 存活边界并避免追赶多个错过周期。
供应商可提供关闭、每天或每周的设置,并决定是否允许用户覆盖;禁止覆盖时供应商设置生效,允许覆盖时用户可显式覆盖或恢复跟随供应商。供应商未提供配置时默认关闭,默认间隔为每天;供应商要求开启时,首次成功获取该配置即可启用,无需用户再次开启。
供应商策略与用户覆盖分别保存。供应商暂时禁止覆盖时保留用户选择,重新允许覆盖后恢复;成功响应缺失策略视为撤回,格式错误或更新失败则保留上次有效策略。修改订阅 URL 时清除旧供应商策略、保留用户选择,避免把旧地址的约束施加到新地址。
在现有 `x_shadowsocksx_ng`、`schema_version: 1` 中增加可选 `auto_update` 对象;自动更新策略与分组树独立解析,避免一个扩展功能的错误破坏另一个功能。
每天、每周分别表示本次更新结束后 24 小时、7 天,再加入基础间隔 0~10% 的随机延后。手动和自动更新无论成功或失败,都重新安排下一次,不额外短时重试;关闭时不产生自动计划。下次计划时间持久保存,重启不重新抽取抖动;最后更新时间仍仅表示最后成功更新。