feat(updates): 添加自动检查频率菜单

This commit is contained in:
ysicing committed 2026-09-26 13:08:07 +08:00
1 parent e8f054dd10
commit 1db19df74d
16 files changed
+188 -21

No files matched your search

@@ -1050,6 +1050,12 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 ذاكرة حزم P
自动 تلقائي
自动代理配置 إعداد وكيل تلقائي
自动检查更新 الفحص تلقائيًا
运行时静默 الفحص بصمت أثناء التشغيل
启动时 عند بدء التشغيل
每日一次 مرة يوميًا
每周一次 مرة أسبوعيًا
每月一次 مرة شهريًا
从不 أبدًا
自动检测 الكشف تلقائيًا
自动(由路由器分配) تلقائي (من الموجّه)
自定义 مخصص
1 # Copyright (C) 2026 ysicing
1050 自动
1051 自动代理配置
1052 自动检查更新
1053 运行时静默
1054 启动时
1055 每日一次
1056 每周一次
1057 每月一次
1058 从不
1059 自动检测
1060 自动(由路由器分配)
1061 自定义
@@ -1050,6 +1050,12 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Von uv geladener Py
自动 Automatisch
自动代理配置 Automatische Proxy-Konfiguration
自动检查更新 Automatisch nach Updates suchen
运行时静默 Im Betrieb ohne Hinweis
启动时 Beim Start
每日一次 Einmal täglich
每周一次 Einmal wöchentlich
每月一次 Einmal monatlich
从不 Nie
自动检测 Automatisch erkennen
自动(由路由器分配) Automatisch (vom Router)
自定义 Eigene Einstellung
1 # Copyright (C) 2026 ysicing
1050 自动
1051 自动代理配置
1052 自动检查更新
1053 运行时静默
1054 启动时
1055 每日一次
1056 每周一次
1057 每月一次
1058 从不
1059 自动检测
1060 自动(由路由器分配)
1061 自定义
@@ -1050,6 +1050,12 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Cachés de paquetes
自动 Automático
自动代理配置 Configuración automática de proxy
自动检查更新 Comprobar automáticamente
运行时静默 En uso sin avisos
启动时 Al iniciar
每日一次 Una vez al día
每周一次 Una vez a la semana
每月一次 Una vez al mes
从不 Nunca
自动检测 Detectar automáticamente
自动(由路由器分配) Automático (del router)
自定义 Personalizado
1 # Copyright (C) 2026 ysicing
1050 自动
1051 自动代理配置
1052 自动检查更新
1053 运行时静默
1054 启动时
1055 每日一次
1056 每周一次
1057 每月一次
1058 从不
1059 自动检测
1060 自动(由路由器分配)
1061 自定义
@@ -1050,6 +1050,12 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Caches de paquets P
自动 Automatique
自动代理配置 Configuration automatique du proxy
自动检查更新 Rechercher automatiquement
运行时静默 En cours sans notification
启动时 Au démarrage
每日一次 Une fois par jour
每周一次 Une fois par semaine
每月一次 Une fois par mois
从不 Jamais
自动检测 Détecter automatiquement
自动(由路由器分配) Automatique (depuis le routeur)
自定义 Personnalisé
1 # Copyright (C) 2026 ysicing
1050 自动
1051 自动代理配置
1052 自动检查更新
1053 运行时静默
1054 启动时
1055 每日一次
1056 每周一次
1057 每月一次
1058 从不
1059 自动检测
1060 自动(由路由器分配)
1061 自定义
@@ -1050,6 +1050,12 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uvのPythonパッ
自动 自動
自动代理配置 自動プロキシ構成
自动检查更新 アップデートを自動確認
运行时静默 実行中に静かに確認
启动时 起動時
每日一次 毎日1回
每周一次 週に1回
每月一次 月に1回
从不 確認しない
自动检测 自動検出
自动(由路由器分配) 自動(ルーターから割り当て)
自定义 カスタム
1 # Copyright (C) 2026 ysicing
1050 自动
1051 自动代理配置
1052 自动检查更新
1053 运行时静默
1054 启动时
1055 每日一次
1056 每周一次
1057 每月一次
1058 从不
1059 自动检测
1060 自动(由路由器分配)
1061 自定义
@@ -1050,6 +1050,12 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv의 Python 패키
自动 자동
自动代理配置 자동 프록시 구성
自动检查更新 업데이트 자동 확인
运行时静默 실행 중 조용히 확인
启动时 시작할 때
每日一次 하루에 한 번
每周一次 일주일에 한 번
每月一次 한 달에 한 번
从不 확인하지 않음
自动检测 자동 감지
自动(由路由器分配) 자동(라우터에서 할당)
自定义 사용자 설정
1 # Copyright (C) 2026 ysicing
1050 自动
1051 自动代理配置
1052 自动检查更新
1053 运行时静默
1054 启动时
1055 每日一次
1056 每周一次
1057 每月一次
1058 从不
1059 自动检测
1060 自动(由路由器分配)
1061 自定义
@@ -1051,6 +1051,12 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv 下載的 Python
自动 自動
自动代理配置 自動代理配置
自动检查更新 自動檢查更新
运行时静默 執行期間靜默檢查
启动时 啟動時
每日一次 每日一次
每周一次 每週一次
每月一次 每月一次
从不 從不
自动检测 自動檢測
自动(由路由器分配) 自動(由路由器分配)
自定义 自定義
1 # Copyright (c) 2026 GiantAccel, LLC
1051 自动
1052 自动代理配置
1053 自动检查更新
1054 运行时静默
1055 启动时
1056 每日一次
1057 每周一次
1058 每月一次
1059 从不
1060 自动检测
1061 自动(由路由器分配)
1062 自定义
@@ -1149,6 +1149,12 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Python package and
自动 Auto
自动代理配置 Auto proxy config
自动检查更新 Check Automatically
运行时静默 Check quietly while running
启动时 At launch
每日一次 Once a day
每周一次 Once a week
每月一次 Once a month
从不 Never
自动(由路由器分配) Automatic (from router)
自定义 Custom
自定义模式下 CPU 达到 {}°C 会自动交还系统控制;退出应用时风扇恢复自动。 In Custom mode, fans return to system control when the CPU reaches {}°C, and to automatic when you quit.
@@ -243,7 +243,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
}
}
/// 启动 10 秒后检查一次,之后每小时看一眼是否已满一天(上次失败则直接重试)
/// 启动 10 秒后按策略检查,运行中每小时判断周期是否到期。
private func startUpdateChecks() {
model.updates.onPrompt = { [weak self] in
self?.menuBar.dismissPopovers()
@@ -251,7 +251,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
self?.updateWindow.show()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self] in
self?.model.updates.checkIfNeeded()
self?.model.updates.checkIfNeeded(atLaunch: true)
}
updateTimer = Timer.scheduledTimer(withTimeInterval: 60 * 60, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.model.updates.checkIfNeeded() }
@@ -15,11 +15,11 @@ You choose whether and where to export diagnostics; they are not uploaded automa
## 2. Update checks and installation statistics
When automatic update checks are enabled, XStats sends its current version and the SHA-256 hash of a random installation identifier to the XStats update service after launch and approximately daily thereafter. Manual checks send the same information. China-region devices try `x-stats.china.12306.work` first; others try `xstats-apps.12306.work` first. The other endpoint is tried only if the preferred one fails. The original random value stays in local preferences and is excluded from WebDAV backups.
When automatic update checks are enabled, XStats sends its current version and the SHA-256 hash of a random installation identifier to the XStats update service according to the schedule selected in Settings → About: quietly while running, at launch, daily, weekly, or monthly. Choosing Never limits these requests to manual checks. Quiet checks still contact the service but do not show an update prompt. China-region devices try `x-stats.china.12306.work` first; others try `xstats-apps.12306.work` first. The other endpoint is tried only if the preferred one fails. The original random value stays in local preferences and is excluded from WebDAV backups.
The update service's application database stores the hash, current version, first and most recent check times, and cumulative check count. These aggregate records support deduplicated installation counts, version distribution, and activity statistics. They have no automatic expiry. The update service does not create a separate record for every request or store request IP addresses in its application database. It uses IP addresses in memory for one-minute rate limiting. Network infrastructure may process connection metadata under its own rules.
You can turn off automatic checks in Settings → About. You decide whether to download and install an available update. To request access to or deletion of an associated aggregate record, email us below; we may need the hash of your local installation identifier to locate it.
You can set automatic checks to Never in Settings → About. You decide whether to download and install an available update. To request access to or deletion of an associated aggregate record, email us below; we may need the hash of your local installation identifier to locate it.
## 3. Other network features
@@ -15,11 +15,11 @@ CPU、GPU、内存、网络、温度、风扇、进程等监控数据来自本
## 2. 更新检查与安装统计
启用自动检查更新时,应用启动后及此后约每天向 XStats 更新服务发送当前版本和随机安装标识的 SHA-256 哈希,并获取版本清单;手动检查时也会发送。中国地区优先连接 `x-stats.china.12306.work`,其他地区优先连接 `xstats-apps.12306.work`,首选失败时才尝试另一端。原始随机值仅保存在本机偏好设置中,不在 WebDAV 备份中。
启用自动检查更新时,应用按你在“设置 → 关于”选择的策略(运行时静默、启动时、每日、每周或每月)向 XStats 更新服务发送当前版本和随机安装标识的 SHA-256 哈希,并获取版本清单;选择“从不”时仅在你手动检查后发送。“运行时静默”仍会检查,但不会弹出新版本提示。中国地区优先连接 `x-stats.china.12306.work`,其他地区优先连接 `xstats-apps.12306.work`,首选失败时才尝试另一端。原始随机值仅保存在本机偏好设置中,不在 WebDAV 备份中。
更新服务的业务数据库保存哈希、当前版本、首次及最近检查时间、累计检查次数,用于去重统计安装量、版本分布和活跃情况。此汇总记录不设自动到期清理;更新服务不建立逐次请求明细,应用服务不将请求 IP 写入业务数据库。请求 IP 会在内存中用于一分钟限流。网络基础设施可能按其自身规则处理连接元数据。
你可以在“设置 → 关于”关闭自动检查更新;发现新版本后,由你决定是否下载安装。若希望查询或删除关联的服务端汇总记录,请通过下方邮箱联系我们;为了定位记录,可能需要你提供本机安装标识的哈希。
你可以在“设置 → 关于”将自动检查更新设为“从不”;发现新版本后,由你决定是否下载安装。若希望查询或删除关联的服务端汇总记录,请通过下方邮箱联系我们;为了定位记录,可能需要你提供本机安装标识的哈希。
## 3. 其他联网功能
@@ -163,8 +163,34 @@ struct UpdateSettings: View {
}
}
GroupRow {
SettingRow(title: tr("自动检查更新"), subtitle: tr("启动时和之后每天检查一次,发现新版本时显示更新摘要")) {
DSToggle(isOn: $settings.autoCheckUpdates, label: tr("自动检查更新"))
SettingRow(title: tr("自动检查更新")) {
Menu {
ForEach(UpdateCheckSchedule.allCases) { schedule in
Button {
settings.updateCheckSchedule = schedule
} label: {
if settings.updateCheckSchedule == schedule {
Label(schedule.title, systemImage: "checkmark")
} else {
Text(schedule.title)
}
}
}
} label: {
HStack(spacing: DS.Space.s2) {
Text(settings.updateCheckSchedule.title)
.lineLimit(1)
Image(systemName: "chevron.down")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(DS.Palette.textSecondary)
.accessibilityHidden(true)
}
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.menuIndicator(.hidden)
.accessibilityLabel(tr("自动检查更新"))
.accessibilityValue(settings.updateCheckSchedule.title)
.fixedSize()
}
}
if let release = updates.release {
@@ -394,6 +394,43 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
}
}
/// 自动更新检查策略;手动检查始终可用。
public enum UpdateCheckSchedule: String, CaseIterable, Identifiable, Sendable {
case quietRuntime, atLaunch, daily, weekly, monthly, never
public var id: String { rawValue }
var title: String {
switch self {
case .quietRuntime: tr("运行时静默")
case .atLaunch: tr("启动时")
case .daily: tr("每日一次")
case .weekly: tr("每周一次")
case .monthly: tr("每月一次")
case .never: tr("从不")
}
}
var promptsForUpdates: Bool { self != .quietRuntime && self != .never }
func shouldCheck(lastChecked: Date?, now: Date, atLaunch: Bool) -> Bool {
switch self {
case .never: return false
case .atLaunch: return atLaunch
case .quietRuntime, .daily, .weekly, .monthly:
guard let lastChecked else { return true }
let next: Date
switch self {
case .quietRuntime, .daily: next = lastChecked.addingTimeInterval(24 * 60 * 60)
case .weekly: next = lastChecked.addingTimeInterval(7 * 24 * 60 * 60)
case .monthly: next = Calendar.current.date(byAdding: .month, value: 1, to: lastChecked) ?? lastChecked
case .atLaunch, .never: return false
}
return now >= next
}
}
}
/// UserDefaults 持久化的偏好设置
@MainActor
@Observable
@@ -569,9 +606,16 @@ public final class AppSettings {
public var historyEnabled: Bool {
didSet { defaults.set(historyEnabled, forKey: Keys.historyEnabled) }
}
/// 启动时与每天检查一次新版本
/// 旧版布尔设置只用于兼容旧备份;新界面以检查策略为准。
public var autoCheckUpdates: Bool {
didSet { defaults.set(autoCheckUpdates, forKey: Keys.autoCheckUpdates) }
get { updateCheckSchedule != .never }
set { updateCheckSchedule = newValue ? .daily : .never }
}
public var updateCheckSchedule: UpdateCheckSchedule {
didSet {
defaults.set(updateCheckSchedule.rawValue, forKey: Keys.updateCheckSchedule)
defaults.set(autoCheckUpdates, forKey: Keys.autoCheckUpdates)
}
}
/// 可再生的缓存也先移到废纸篓(可恢复,但不会立即释放空间)
public var cleanPrefersTrash: Bool {
@@ -664,7 +708,9 @@ public final class AppSettings {
probeInBackground = defaults.object(forKey: Keys.probeInBackground) as? Bool ?? true
probeTarget = defaults.string(forKey: Keys.probeTarget).flatMap(ProbeTarget.init(rawValue:)) ?? .cloudflare
publicIPLookup = defaults.object(forKey: Keys.publicIPLookup) as? Bool ?? true
autoCheckUpdates = defaults.object(forKey: Keys.autoCheckUpdates) as? Bool ?? true
updateCheckSchedule = defaults.string(forKey: Keys.updateCheckSchedule)
.flatMap(UpdateCheckSchedule.init(rawValue:))
?? ((defaults.object(forKey: Keys.autoCheckUpdates) as? Bool ?? true) ? .daily : .never)
historyEnabled = defaults.object(forKey: Keys.historyEnabled) as? Bool ?? true
language = defaults.string(forKey: Keys.language).flatMap(AppLanguage.init(rawValue:)) ?? .system
let storedHotKeys = defaults.data(forKey: Keys.hotKeys).flatMap { try? JSONDecoder().decode([String: HotKey].self, from: $0) } ?? [:]
@@ -758,6 +804,7 @@ public final class AppSettings {
static let probeTarget = "probeTarget"
static let publicIPLookup = "publicIPLookup"
static let autoCheckUpdates = "autoCheckUpdates"
static let updateCheckSchedule = "updateCheckSchedule"
static let historyEnabled = "historyEnabled"
static let hotKeys = "hotKeys"
static let language = "language"
@@ -51,6 +51,7 @@ public struct SettingsDocument: Codable, Equatable, Sendable {
public var hotKeys: [String: HotKey]?
public var historyEnabled: Bool?
public var autoCheckUpdates: Bool?
public var updateCheckSchedule: String?
public var cleanPrefersTrash: Bool?
public init() {}
@@ -96,6 +97,7 @@ extension AppSettings {
doc.hotKeys = Dictionary(uniqueKeysWithValues: hotKeys.map { ($0.key.rawValue, $0.value) })
doc.historyEnabled = historyEnabled
doc.autoCheckUpdates = autoCheckUpdates
doc.updateCheckSchedule = updateCheckSchedule.rawValue
doc.cleanPrefersTrash = cleanPrefersTrash
return doc
}
@@ -154,7 +156,11 @@ extension AppSettings {
})
})
assign(\.historyEnabled, doc.historyEnabled)
assign(\.autoCheckUpdates, doc.autoCheckUpdates)
if let schedule = doc.updateCheckSchedule.flatMap(UpdateCheckSchedule.init(rawValue:)) {
assign(\.updateCheckSchedule, schedule)
} else {
assign(\.autoCheckUpdates, doc.autoCheckUpdates)
}
assign(\.cleanPrefersTrash, doc.cleanPrefersTrash)
}
@@ -37,8 +37,6 @@ public final class UpdateController {
@ObservationIgnored private let defaults: UserDefaults
@ObservationIgnored private var task: Task<Void, Never>?
static let checkInterval: TimeInterval = 24 * 60 * 60
init(settings: AppSettings, defaults: UserDefaults = .standard) {
self.settings = settings
self.defaults = defaults
@@ -67,14 +65,14 @@ public final class UpdateController {
// MARK: 检查
/// 启动后与每天一次的自动检查;距离上次检查不足一天时跳过
func checkIfNeeded() {
guard settings.autoCheckUpdates, !isBusy else { return }
if let lastChecked, Date().timeIntervalSince(lastChecked) < Self.checkInterval { return }
check(userInitiated: false)
/// 启动时或运行期间按用户选择的策略检查;失败后由后续调度重试。
func checkIfNeeded(atLaunch: Bool = false) {
let schedule = settings.updateCheckSchedule
guard !isBusy, schedule.shouldCheck(lastChecked: lastChecked, now: Date(), atLaunch: atLaunch) else { return }
check(userInitiated: false, suppressPrompt: !schedule.promptsForUpdates)
}
func check(userInitiated: Bool) {
func check(userInitiated: Bool, suppressPrompt: Bool = false) {
guard !isBusy else { return }
phase = .checking
task = Task {
@@ -94,7 +92,7 @@ public final class UpdateController {
phase = .available
Log.update.notice("发现新版本 \(latest.version, privacy: .public)")
// 手动检查时总是提示;自动检查时跳过用户选择忽略的版本
if userInitiated || latest.version != skippedVersion { onPrompt() }
if userInitiated || (!suppressPrompt && latest.version != skippedVersion) { onPrompt() }
case .failure(let error):
Log.update.error("检查更新失败:\(error.localizedDescription, privacy: .public)")
// 自动检查失败不打扰用户,只在关于页里显示
@@ -343,6 +343,48 @@ private func isolatedDefaults() -> UserDefaults {
@MainActor
@Suite struct UpdateControllerTests {
@Test func updateSchedulesRespectLaunchAndElapsedIntervals() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let oneDayAgo = now.addingTimeInterval(-24 * 60 * 60)
let sixDaysAgo = now.addingTimeInterval(-6 * 24 * 60 * 60)
let sevenDaysAgo = now.addingTimeInterval(-7 * 24 * 60 * 60)
let monthAgo = Calendar.current.date(byAdding: .month, value: -1, to: now)!
#expect(UpdateCheckSchedule.never.shouldCheck(lastChecked: nil, now: now, atLaunch: true) == false)
#expect(UpdateCheckSchedule.atLaunch.shouldCheck(lastChecked: now, now: now, atLaunch: true))
#expect(!UpdateCheckSchedule.atLaunch.shouldCheck(lastChecked: nil, now: now, atLaunch: false))
#expect(UpdateCheckSchedule.daily.shouldCheck(lastChecked: oneDayAgo, now: now, atLaunch: false))
#expect(UpdateCheckSchedule.quietRuntime.shouldCheck(lastChecked: oneDayAgo, now: now, atLaunch: false))
#expect(!UpdateCheckSchedule.quietRuntime.promptsForUpdates)
#expect(!UpdateCheckSchedule.weekly.shouldCheck(lastChecked: sixDaysAgo, now: now, atLaunch: false))
#expect(UpdateCheckSchedule.weekly.shouldCheck(lastChecked: sevenDaysAgo, now: now, atLaunch: false))
#expect(UpdateCheckSchedule.monthly.shouldCheck(lastChecked: monthAgo, now: now, atLaunch: false))
}
@Test func legacyAutomaticUpdatePreferenceMigratesToSchedule() {
let defaults = isolatedDefaults()
defaults.set(false, forKey: "autoCheckUpdates")
#expect(AppSettings(defaults: defaults).updateCheckSchedule == .never)
defaults.set(true, forKey: "autoCheckUpdates")
let settings = AppSettings(defaults: defaults)
#expect(settings.updateCheckSchedule == .daily)
settings.updateCheckSchedule = .monthly
#expect(AppSettings(defaults: defaults).updateCheckSchedule == .monthly)
}
@Test func updateScheduleBackupRestoresNewAndLegacyFormats() {
let source = AppSettings(defaults: isolatedDefaults())
source.updateCheckSchedule = .weekly
let target = AppSettings(defaults: isolatedDefaults())
target.apply(source.exportDocument())
#expect(target.updateCheckSchedule == .weekly)
var legacy = SettingsDocument()
legacy.autoCheckUpdates = false
target.apply(legacy)
#expect(target.updateCheckSchedule == .never)
}
@Test func automaticCheckRespectsSettingAndInterval() {
let defaults = isolatedDefaults()
let settings = AppSettings(defaults: defaults)