mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-31 23:03:43 +00:00
feat: add GUI-scoped subscription automatic updates
This commit is contained in:
@@ -61,6 +61,15 @@ _避免用词_:导出(导出指配置组或诊断报告等文件快照)。
|
||||
**订阅资料(Subscription information)**:
|
||||
供应商随订阅提供的已用流量、剩余流量、服务到期时间与流量重置时间等事实。它不包含服务器连接资料、凭据或完整订阅响应。
|
||||
|
||||
**订阅自动更新(Subscription automatic update)**:
|
||||
GUI 应用运行期间,按订阅的生效设置定期获取远程配置的行为。仅保留菜单栏也属于应用运行。
|
||||
|
||||
**供应商自动更新策略(Provider automatic update policy)**:
|
||||
订阅供应商提供的自动更新设置(关闭、每天或每周)及是否允许用户覆盖的约束。允许覆盖时用户可选择本地设置或跟随供应商;禁止覆盖时供应商设置生效。
|
||||
|
||||
**下次计划更新时间(Next scheduled update time)**:
|
||||
订阅下一次自动更新到期的时刻,与最后成功更新时间不同。自动更新关闭时没有该计划。
|
||||
|
||||
**订阅组(Subscription group)**:
|
||||
一个订阅拥有的配置子树,内容和结构以远程资料为准,用户不能在本地覆盖其成员、名称或连接字段。
|
||||
|
||||
|
||||
+69
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+93
-24
@@ -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% 的随机延后。手动和自动更新无论成功或失败,都重新安排下一次,不额外短时重试;关闭时不产生自动计划。下次计划时间持久保存,重启不重新抽取抖动;最后更新时间仍仅表示最后成功更新。
|
||||
Reference in New Issue
Block a user