辅助工具版本检查、后台低频探测、进程页 2 秒刷新、记住菜单栏图标顺序

This commit is contained in:
gentpan committed 2026-09-13 08:06:56 +05:00
1 parent b901bb8b67
commit f9f4201654
13 files changed
+146 -37

No files matched your search

+7
View File
@@ -10,6 +10,13 @@
#### 新增
- 在线升级:启动时和之后每天检查官网版本清单,发现新版本时弹出版本号与更新摘要,可一键安装、以后再说或跳过此版本;下载后依次核对 sha256、包名与版本、开发者签名团队和 Apple 公证,任何一项不通过都放弃安装;旧版先备份再原地替换,失败自动还原,装完自动重启到新版。「设置 · 关于」与应用菜单里可以手动检查更新。
- 辅助工具版本检查:启动时核对正在运行的辅助工具版本,比应用旧时在风扇、合盖运行和辅助工具设置里提示“重新安装”,侧边栏对应项目显示提醒圆点;有新版本时“关于”旁同样显示圆点。
#### 调整
- 连接探测更省电:打开网络详情时按设置的间隔探测;详情关闭、只在菜单栏显示网速时改为每 10 秒低频探测,并允许系统合并唤醒,也可以在设置里关闭后台探测。
- 进程页每 2 秒刷新一次(读取全系统进程需要启动 ps),其他页面仍为每秒。
- 菜单栏图标用 ⌘ 拖动调整的顺序会被记住,重启应用或切换显示项目后保持不变。
## 0.2.0 · 2026-09-13
@@ -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()
@@ -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)
@@ -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)
@@ -117,7 +117,7 @@ private struct LidCard: View {
.disabled(!model.helper.isReady)
}
if !model.helper.isReady {
if model.helper.needsAttention {
HelperRequiredBanner(text: "合盖运行需要修改系统睡眠设置,需安装辅助工具(管理员授权一次)。")
}
@@ -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
}
}
@@ -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
}
}
@@ -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)
}
@@ -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()
@@ -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)
@@ -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"
@@ -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()
}
@@ -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)
}
}
}