Files
OpenStats/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarController.swift
T
gentpan 8875b6e709 每项独立菜单栏与详情弹窗、网络详情与 DNS、侧边栏主窗口与统一外观
- 菜单栏可每项独立(点击弹出该项窄详情)或合并为一个(点击打开主窗口),详情区块可在设置中勾选
- 网络详情:镜像流量历史、ICMP 连接探测格子、接口与 Wi-Fi、本地与公网 IPv4 / IPv6、SVG 国旗归属地与 ASN、各进程流量
- DNS:刷新缓存,一键切换自动 / Cloudflare / Google / 腾讯 / 阿里云或手动填写;辅助工具协议升至 3,服务名与地址二次校验
- 主窗口:侧边栏切换仪表盘、各指标详情与工具页,宽高可调,卡片按比例伸缩
- 统一外观:白底蓝 / 黑底蓝,窗口、设置与弹窗共用底色,去掉玻璃效果
- 新增网络解析、DNS 校验与国旗代码校验的单元测试;更新 README、架构文档与隐私说明
2026-09-13 05:39:10 +05:00

141 lines
6.0 KiB
Swift

import AppKit
import SwiftUI
/// 菜单栏图标:每项独立时一个指标一个图标,点击弹出该项详情;合并时只有一个图标,点击打开主窗口
@MainActor
final class MenuBarController: NSObject {
private let model: AppModel
private var items: [(key: MenuBarItem?, statusItem: NSStatusItem)] = []
private var panels: [MenuBarItem: StatusPanel] = [:]
private var layoutSignature: [MenuBarItem?] = []
/// 开发调试:启动后展开并固定某个弹窗
var pinsNextPopover = false
init(model: AppModel) {
self.model = model
super.init()
}
// MARK: 图标
/// 布局或显示项目变化时重建;否则只重绘图片
func update() {
let settings = model.settings
let enabled = settings.orderedMenuBarItems
// 没有任何项目时保留一个图标,确保仍能打开主窗口
let signature: [MenuBarItem?] = settings.menuBarLayout == .separate && !enabled.isEmpty ? enabled : [nil]
if signature != layoutSignature {
rebuild(signature)
}
refreshImages()
}
private func rebuild(_ signature: [MenuBarItem?]) {
dismissPopovers()
for entry in items { NSStatusBar.system.removeStatusItem(entry.statusItem) }
items = []
panels = panels.filter { key, _ in signature.contains(key) }
layoutSignature = signature
// 新建的状态栏图标出现在已有图标左侧,倒序创建才能保持从左到右的顺序
for key in signature.reversed() {
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem.button {
button.target = self
button.action = #selector(statusItemClicked(_:))
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
button.imagePosition = .imageOnly
}
items.insert((key, statusItem), at: 0)
}
}
func refreshImages() {
let settings = model.settings
let reading = MenuBarReading(model: model)
for (index, entry) in items.enumerated() {
let itemsToDraw = entry.key.map { [$0] } ?? settings.orderedMenuBarItems
var entryReading = reading
// 防休眠标记只画在最左侧的图标里
entryReading.keepAwake = reading.keepAwake && index == 0
entry.statusItem.button?.image = MenuBarRenderer.image(reading: entryReading,
items: itemsToDraw,
style: { settings.style(for: $0) },
networkStyle: settings.networkStyle,
colorizeHighLoad: settings.colorizeHighLoad,
fahrenheit: settings.useFahrenheit)
entry.statusItem.button?.toolTip = reading.tooltip(items: itemsToDraw, fahrenheit: settings.useFahrenheit)
}
}
// MARK: 点击
@objc private func statusItemClicked(_ sender: NSStatusBarButton) {
guard let entry = items.first(where: { $0.statusItem.button === sender }) else { return }
if NSApp.currentEvent?.type == .rightMouseUp {
showContextMenu(for: entry.statusItem)
} else if let key = entry.key {
togglePopover(key, button: sender)
} else {
model.openMainWindow(nil)
}
}
func togglePopover(_ item: MenuBarItem, button: NSStatusBarButton? = nil) {
guard let button = button ?? items.first(where: { $0.key == item })?.statusItem.button,
let window = button.window else { return }
for (key, panel) in panels where key != item { panel.dismiss() }
let frame = window.convertToScreen(button.convert(button.bounds, to: nil))
let panel = self.panel(for: item)
panel.toggle(below: frame, on: window.screen)
if pinsNextPopover, panel.isVisible {
panel.isPinned = true
pinsNextPopover = false
}
}
func dismissPopovers() {
for panel in panels.values { panel.dismiss() }
}
func refreshPopoverHeight() {
for panel in panels.values { panel.refreshHeight() }
}
private func panel(for item: MenuBarItem) -> StatusPanel {
if let panel = panels[item] { return panel }
let model = self.model
let panel = StatusPanel(width: DS.Size.popoverWidth,
content: { PopoverRootView(item: item).environment(model) },
measuring: { PopoverRootView(item: item).environment(model).environment(\.isSnapshot, true) })
panel.onVisibilityChange = { [weak self] visible in
guard let self else { return }
if visible {
self.model.openPopover = item
self.model.helper.refreshStatus()
} else if self.model.openPopover == item {
self.model.openPopover = nil
}
self.items.first { $0.key == item }?.statusItem.button?.highlight(visible)
}
panels[item] = panel
return panel
}
private func showContextMenu(for statusItem: NSStatusItem) {
dismissPopovers()
let menu = NSMenu()
menu.addItem(withTitle: "打开 OpenStats", action: #selector(openMainWindow), keyEquivalent: "").target = self
menu.addItem(withTitle: "设置…", action: #selector(openSettings), keyEquivalent: ",").target = self
menu.addItem(.separator())
menu.addItem(withTitle: "退出 OpenStats", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
statusItem.menu = menu
statusItem.button?.performClick(nil)
statusItem.menu = nil
}
@objc private func openMainWindow() { model.openMainWindow(nil) }
@objc private func openSettings() { model.openSettings() }
}