diff --git a/CHANGELOG.md b/CHANGELOG.md index 935cdeb..fc72cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ #### 新增 - 在线升级:启动时和之后每天检查官网版本清单,发现新版本时弹出版本号与更新摘要,可一键安装、以后再说或跳过此版本;下载后依次核对 sha256、包名与版本、开发者签名团队和 Apple 公证,任何一项不通过都放弃安装;旧版先备份再原地替换,失败自动还原,装完自动重启到新版。「设置 · 关于」与应用菜单里可以手动检查更新。 +- 辅助工具版本检查:启动时核对正在运行的辅助工具版本,比应用旧时在风扇、合盖运行和辅助工具设置里提示“重新安装”,侧边栏对应项目显示提醒圆点;有新版本时“关于”旁同样显示圆点。 + +#### 调整 + +- 连接探测更省电:打开网络详情时按设置的间隔探测;详情关闭、只在菜单栏显示网速时改为每 10 秒低频探测,并允许系统合并唤醒,也可以在设置里关闭后台探测。 +- 进程页每 2 秒刷新一次(读取全系统进程需要启动 ps),其他页面仍为每秒。 +- 菜单栏图标用 ⌘ 拖动调整的顺序会被记住,重启应用或切换显示项目后保持不变。 ## 0.2.0 · 2026-09-13 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift index c81f26c..de38ea9 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift @@ -35,6 +35,7 @@ public final class AppController: NSObject, NSApplicationDelegate { } model.quit = { NSApp.terminate(nil) } model.helper.refreshStatus() + Task { await model.helper.verifyVersion() } model.helper.onReconnect = { [weak self] in Task { [weak self] in await self?.model.fans.reapply() @@ -46,7 +47,7 @@ public final class AppController: NSObject, NSApplicationDelegate { observeModel() observeProbeSettings() applyAppearance() - model.network.updateProbing(networkShown: model.networkShown) + updateNetworkVisibility() Task { await model.geo.updateIfNeeded() } startUpdateChecks() @@ -143,9 +144,9 @@ public final class AppController: NSObject, NSApplicationDelegate { _ = model.settings.hiddenPopoverSections _ = model.keepAwake.isActive _ = model.settings.appearance - _ = model.networkShown _ = model.isNetworkDetailVisible _ = model.settings.probeEnabled + _ = model.settings.probeInBackground } onChange: { [weak self] in Task { @MainActor in guard let self else { return } @@ -153,8 +154,7 @@ public final class AppController: NSObject, NSApplicationDelegate { self.applyAppearance() self.menuBar.update() self.menuBar.refreshPopoverHeight() - self.model.network.updateProbing(networkShown: self.model.networkShown) - self.model.network.setDetailVisible(self.model.isNetworkDetailVisible) + self.updateNetworkVisibility() self.observeModel() } } @@ -180,6 +180,11 @@ public final class AppController: NSObject, NSApplicationDelegate { } } + private func updateNetworkVisibility() { + model.network.setVisibility(inMenuBar: model.settings.menuBarItems.contains(.network), + detailVisible: model.isNetworkDetailVisible) + } + private func applyAppearance() { switch model.settings.appearance { case .system: NSApp.appearance = nil @@ -202,7 +207,7 @@ public final class AppController: NSObject, NSApplicationDelegate { MainActor.assumeIsolated { guard let self else { return } self.menuBar.dismissPopovers() - self.model.network.setPaused(true, networkShown: self.model.networkShown) + self.model.network.setPaused(true) Task { await self.model.hub.setPaused(true) } } }) @@ -211,7 +216,7 @@ public final class AppController: NSObject, NSApplicationDelegate { workspaceObservers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in MainActor.assumeIsolated { guard let self else { return } - self.model.network.setPaused(false, networkShown: self.model.networkShown) + self.model.network.setPaused(false) Task { await self.model.hub.setPaused(false) await self.model.fans.reapply() diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarController.swift index e19ae8b..b3c9d72 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarController.swift @@ -33,7 +33,14 @@ final class MenuBarController: NSObject { private func rebuild(_ signature: [MenuBarItem?]) { dismissPopovers() + // 移除图标时系统会清掉它记住的位置;先记下用户 ⌘ 拖动后的位置,重建后放回 + let defaults = UserDefaults.standard + let positions = items.compactMap { entry -> (String, Any)? in + let key = Self.positionKey(entry.key) + return defaults.object(forKey: key).map { (key, $0) } + } for entry in items { NSStatusBar.system.removeStatusItem(entry.statusItem) } + for (key, value) in positions { defaults.set(value, forKey: key) } items = [] panels = panels.filter { key, _ in signature.contains(key) } layoutSignature = signature @@ -41,6 +48,8 @@ final class MenuBarController: NSObject { // 新建的状态栏图标出现在已有图标左侧,倒序创建才能保持从左到右的顺序 for key in signature.reversed() { let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + // 按项目命名后,系统会记住每个图标被 ⌘ 拖动后的位置,下次启动保持原顺序 + statusItem.autosaveName = Self.autosaveName(key) if let button = statusItem.button { button.target = self button.action = #selector(statusItemClicked(_:)) @@ -51,6 +60,14 @@ final class MenuBarController: NSObject { } } + private static func autosaveName(_ key: MenuBarItem?) -> String { + "OpenStats.\(key?.rawValue ?? "combined")" + } + + private static func positionKey(_ key: MenuBarItem?) -> String { + "NSStatusItem Preferred Position \(autosaveName(key))" + } + func refreshImages() { let settings = model.settings let reading = MenuBarReading(model: model) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Components.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Components.swift index 1376532..ae97a1d 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Components.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Components.swift @@ -216,6 +216,8 @@ struct SidebarButton: View { let title: String let symbol: String let isSelected: Bool + /// 需要注意时在右侧显示一个圆点(有新版本、辅助工具需要重新安装) + var badge: Tone? let action: () -> Void @State private var hovering = false @@ -227,6 +229,10 @@ struct SidebarButton: View { .frame(width: DS.Size.iconStandalone) Text(title).dsFont(.sm, weight: isSelected ? .semibold : .regular) Spacer() + if let badge { + Circle().fill(badge.color).frame(width: DS.Space.s2, height: DS.Space.s2) + .accessibilityLabel("需要注意") + } } .foregroundStyle(isSelected ? DS.Palette.primary : hovering ? DS.Palette.textPrimary : DS.Palette.textSecondary) .padding(.leading, DS.Space.s3) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/KeepAwakePage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/KeepAwakePage.swift index e2d65c8..e003526 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/KeepAwakePage.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/KeepAwakePage.swift @@ -117,7 +117,7 @@ private struct LidCard: View { .disabled(!model.helper.isReady) } - if !model.helper.isReady { + if model.helper.needsAttention { HelperRequiredBanner(text: "合盖运行需要修改系统睡眠设置,需安装辅助工具(管理员授权一次)。") } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift index 0ed4072..8eefc1d 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift @@ -109,6 +109,14 @@ private struct MainSidebar: View { .frame(maxWidth: .infinity, alignment: .leading) } + private func badge(for tab: PanelTab) -> Tone? { + switch tab { + case .settingsAbout: model.updates.release != nil ? .primary : nil + case .settingsHelper: model.helper.isReady && model.helper.isOutdated ? .warning : nil + default: nil + } + } + @ViewBuilder private func group(_ title: String, _ tabs: [PanelTab]) -> some View { Text(title) @@ -117,7 +125,7 @@ private struct MainSidebar: View { .padding(.horizontal, DS.Space.s2) .padding(.top, DS.Space.s2) ForEach(tabs) { tab in - SidebarButton(title: tab.title, symbol: tab.symbol, isSelected: model.settings.panelTab == tab) { + SidebarButton(title: tab.title, symbol: tab.symbol, isSelected: model.settings.panelTab == tab, badge: badge(for: tab)) { model.settings.panelTab = tab } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ThermalPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ThermalPage.swift index fc048ca..0efac71 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ThermalPage.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/ThermalPage.swift @@ -106,7 +106,7 @@ private struct FanCard: View { CustomLevelControl(fans: fans) } - if !model.helper.isReady { + if model.helper.needsAttention { HelperRequiredBanner(text: "调节风扇需要安装辅助工具,仅需管理员授权一次。") } @@ -202,6 +202,10 @@ struct HelperRequiredBanner: View { case .requiresApproval: Button("去批准") { model.helper.openLoginItemsSettings() } .buttonStyle(DSButtonStyle(kind: .primary)) + case .enabled where model.helper.isOutdated: + Button("重新安装") { Task { await model.helper.reinstall() } } + .buttonStyle(DSButtonStyle(kind: .primary)) + .disabled(model.helper.isWorking) case .enabled, .unavailable: EmptyView() } @@ -212,6 +216,7 @@ struct HelperRequiredBanner: View { switch model.helper.status { case .requiresApproval: "请在“系统设置 › 通用 › 登录项”中允许 OpenStats 的后台项目。" case .unavailable(let reason): reason + case .enabled where model.helper.isOutdated: HelperClient.outdatedMessage default: text } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift index e7a6dfd..9085939 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift @@ -323,12 +323,12 @@ struct NetworkSettings: View { SettingsGroup(caption: "连接探测") { GroupRow(showsDivider: false) { - SettingRow(title: "定时探测网络", subtitle: "用 ping 测量延迟与丢包,在网络详情里以格子显示;只在菜单栏显示网络项或打开网络详情时运行") { + SettingRow(title: "定时探测网络", subtitle: "用 ping 测量延迟与丢包,在网络详情里以格子显示") { DSToggle(isOn: $settings.probeEnabled, label: "定时探测网络") } } GroupRow { - SettingRow(title: "探测间隔") { + SettingRow(title: "探测间隔", subtitle: "打开网络详情时使用") { SegmentedControl(selection: $settings.probeSeconds, options: AppSettings.probeOptions.map { ($0, "\($0) 秒") }) .frame(width: DS.Size.sidebarWidth) @@ -348,6 +348,13 @@ struct NetworkSettings: View { } } .disabled(!settings.probeEnabled) + GroupRow { + SettingRow(title: "后台低频探测", + subtitle: "详情关闭时,菜单栏显示网络项期间每 \(NetworkController.backgroundProbeSeconds) 秒探测一次,打开详情就能看到最近的连接情况;关闭后只在打开详情时探测,更省电") { + DSToggle(isOn: $settings.probeInBackground, label: "后台低频探测") + } + } + .disabled(!settings.probeEnabled) } SettingsGroup(caption: "公网 IP") { @@ -537,6 +544,13 @@ struct HelperSettings: View { if case .unavailable(let reason) = helper.status { InfoBanner(icon: "exclamationmark.triangle.fill", text: reason, tone: .error) } + if helper.isReady && helper.isOutdated { + InfoBanner(icon: "arrow.triangle.2.circlepath", text: HelperClient.outdatedMessage, tone: .warning) { + Button("重新安装") { Task { await helper.reinstall() } } + .buttonStyle(DSButtonStyle(kind: .primary)) + .disabled(helper.isWorking) + } + } if let error = helper.lastError { InfoBanner(icon: "exclamationmark.triangle.fill", text: error, tone: .error) } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift index 5725516..daa13b5 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift @@ -16,8 +16,7 @@ enum SnapshotRenderer { settings.probeSeconds = 1 let model = AppModel(settings: settings) model.isMainWindowVisible = true - model.network.updateProbing(networkShown: true) - model.network.setDetailVisible(true) + model.network.setVisibility(inMenuBar: true, detailVisible: true) // 采集约 12 秒的真实数据,让历史曲线有内容 var demand = MetricsDemand() @@ -34,8 +33,7 @@ enum SnapshotRenderer { await model.hub.start { snapshot in model.handle(snapshot) } try? await Task.sleep(for: .seconds(12)) await model.hub.stop() - model.network.setDetailVisible(false) - model.network.updateProbing(networkShown: false) + model.network.setVisibility(inMenuBar: false, detailVisible: false) model.network.maskForSnapshot() // 清理页展示真实扫描结果(只读,不删除任何文件) model.cleaner.scan() diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index f734126..7930123 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -55,7 +55,9 @@ public final class AppModel { let menu = settings.menuBarItems let thermalPopover = popover == .temperature || popover == .fan - demand.interval = window || popover != nil ? .seconds(1) : .seconds(settings.refreshSeconds) + // 进程页要读全系统进程(启动 ps),每 2 秒刷新一次足够,也更省电 + demand.interval = window && tab == .processes && popover == nil ? .seconds(2) + : window || popover != nil ? .seconds(1) : .seconds(settings.refreshSeconds) demand.memory = true demand.network = true let showing = { (page: PanelTab, item: MenuBarItem) in (window && tab == page) || popover == item } @@ -76,11 +78,6 @@ public final class AppModel { return demand } - /// 连接探测在菜单栏显示网络项或网络详情打开时运行 - var networkShown: Bool { - settings.menuBarItems.contains(.network) || isNetworkDetailVisible - } - /// 网络详情(接口、公网 IP、进程流量)正在显示 var isNetworkDetailVisible: Bool { openPopover == .network || (isMainWindowVisible && settings.panelTab == .network) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index daf3ba4..4596612 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -349,6 +349,10 @@ public final class AppSettings { public var probeEnabled: Bool { didSet { defaults.set(probeEnabled, forKey: Keys.probeEnabled) } } + /// 网络详情关闭时,菜单栏显示网络项期间继续低频探测 + public var probeInBackground: Bool { + didSet { defaults.set(probeInBackground, forKey: Keys.probeInBackground) } + } public var probeSeconds: Int { didSet { defaults.set(probeSeconds, forKey: Keys.probeSeconds) } } @@ -406,6 +410,7 @@ public final class AppSettings { menuBarLayout = defaults.string(forKey: Keys.menuBarLayout).flatMap(MenuBarLayout.init(rawValue:)) ?? .separate hiddenPopoverSections = Set(defaults.stringArray(forKey: Keys.hiddenPopoverSections)?.compactMap(PopoverSection.init(rawValue:)) ?? []) probeEnabled = defaults.object(forKey: Keys.probeEnabled) as? Bool ?? true + probeInBackground = defaults.object(forKey: Keys.probeInBackground) as? Bool ?? true probeSeconds = Self.probeOptions.contains(defaults.integer(forKey: Keys.probeSeconds)) ? defaults.integer(forKey: Keys.probeSeconds) : 2 probeTarget = defaults.string(forKey: Keys.probeTarget).flatMap(ProbeTarget.init(rawValue:)) ?? .cloudflare @@ -458,6 +463,7 @@ public final class AppSettings { static let hiddenPopoverSections = "hiddenPopoverSections" static let probeEnabled = "probeEnabled" static let probeSeconds = "probeSeconds" + static let probeInBackground = "probeInBackground" static let probeTarget = "probeTarget" static let publicIPLookup = "publicIPLookup" static let geoIncludeCity = "geoIncludeCity" diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift index c1503f9..3fd24cd 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift @@ -26,6 +26,8 @@ public final class HelperClient { public private(set) var status: Status = .notInstalled public private(set) var isWorking = false public private(set) var lastError: String? + /// 正在运行的辅助工具比应用旧:登记的是另一份应用包里的辅助工具,或替换应用后旧进程还没退出 + public private(set) var isOutdated = false /// 连接中断后重新连上时回调,用于重新下发风扇 / 防休眠状态 @ObservationIgnored var onReconnect: (() -> Void)? @@ -37,6 +39,11 @@ public final class HelperClient { public var isReady: Bool { status == .enabled } + static let outdatedMessage = "辅助工具版本比应用旧,部分功能可能无法使用,请重新安装一次(需要管理员授权)。" + + /// 未安装、待批准或版本过旧,需要用户处理 + var needsAttention: Bool { !isReady || isOutdated } + public func refreshStatus() { switch service.status { case .enabled: status = .enabled @@ -87,6 +94,29 @@ public final class HelperClient { refreshStatus() } + /// 卸载后重新登记,让 launchd 运行当前应用包里的辅助工具 + public func reinstall() async { + await uninstall() + install() + await verifyVersion(recheckDelay: .zero) + } + + /// 核对辅助工具的协议版本。辅助工具空闲 30 秒后自动退出,下次调用时 launchd 会启动应用包里的新版本, + /// 所以第一次发现版本旧时等它退出后再确认一次,避免刚升级完就误报 + func verifyVersion(recheckDelay: Duration = .seconds(45)) async { + guard let version = await remoteProtocolVersion(), version < HelperConstants.protocolVersion else { + isOutdated = false + return + } + if recheckDelay > .zero { + try? await Task.sleep(for: recheckDelay) + guard let again = await remoteProtocolVersion() else { return } + isOutdated = again < HelperConstants.protocolVersion + } else { + isOutdated = true + } + } + public func openLoginItemsSettings() { SMAppService.openSystemSettingsLoginItems() } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift index f5ebc8f..2f9f3af 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift @@ -28,6 +28,12 @@ public final class NetworkController { @ObservationIgnored private var lastPublicLookup: (date: Date, localIPv4: [String])? @ObservationIgnored private var isPaused = false @ObservationIgnored private var wantsDetail = false + @ObservationIgnored private var inMenuBar = false + /// 正在运行的探测是否为前台频率;前后台切换时重启循环,不必等完后台的长间隔 + @ObservationIgnored private var probingForeground: Bool? + + /// 详情关闭、只在菜单栏显示网速时的低频探测间隔 + static let backgroundProbeSeconds = 10 @ObservationIgnored private let geo: GeoDatabaseController @@ -61,38 +67,45 @@ public final class NetworkController { // MARK: 生命周期 - /// 菜单栏显示网络项或网络详情打开时持续探测 - func updateProbing(networkShown: Bool) { - let shouldRun = settings.probeEnabled && networkShown && !isPaused - if shouldRun, probeTask == nil { - startProbing() - } else if !shouldRun { + /// 网络详情打开时按设置的间隔探测;只在菜单栏显示网络项时按 10 秒低频探测(可在设置里关闭),其余时间停止 + func setVisibility(inMenuBar: Bool, detailVisible: Bool) { + self.inMenuBar = inMenuBar + wantsDetail = detailVisible + applyDetailState() + applyProbeState() + } + + private func applyProbeState() { + let foreground = wantsDetail + let shouldRun = settings.probeEnabled && !isPaused + && (foreground || (inMenuBar && settings.probeInBackground)) + guard shouldRun else { probeTask?.cancel() probeTask = nil + probingForeground = nil + return } + guard probeTask == nil || probingForeground != foreground else { return } + probeTask?.cancel() + probingForeground = foreground + startProbing() } /// 探测目标或间隔变化时重新开始,历史清空避免混入不同目标的数据 func restartProbing() { guard probeTask != nil else { return } probeTask?.cancel() - probeTask = nil probes = History(capacity: Self.probeCapacity) startProbing() } - func setPaused(_ paused: Bool, networkShown: Bool) { + func setPaused(_ paused: Bool) { isPaused = paused - updateProbing(networkShown: networkShown) + applyProbeState() applyDetailState() } /// 网络详情打开时每 2 秒刷新接口信息与进程流量,关闭后停止 - func setDetailVisible(_ visible: Bool) { - wantsDetail = visible - applyDetailState() - } - private func applyDetailState() { let shouldRun = wantsDetail && !isPaused guard shouldRun != (detailTask != nil) else { return } @@ -168,12 +181,13 @@ public final class NetworkController { // MARK: 探测 private func startProbing() { + let foreground = probingForeground ?? true probeTask = Task { [weak self] in var sequence: UInt16 = 0 let clock = ContinuousClock() while !Task.isCancelled { guard let self else { return } - let interval = Double(self.settings.probeSeconds) + let interval = Double(foreground ? self.settings.probeSeconds : Self.backgroundProbeSeconds) if self.details == nil { await self.refreshDetails() } let started = clock.now guard let address = self.probeAddress else { @@ -187,7 +201,9 @@ public final class NetworkController { }.value guard !Task.isCancelled else { return } self.probes.append(ProbeSample(latency: latency)) - try? await Task.sleep(until: started + .seconds(interval), tolerance: .milliseconds(100), clock: clock) + // 后台探测允许系统合并唤醒,更省电 + try? await Task.sleep(until: started + .seconds(interval), + tolerance: foreground ? .milliseconds(100) : .seconds(2), clock: clock) } } }