- 菜单栏可每项独立(点击弹出该项窄详情)或合并为一个(点击打开主窗口),详情区块可在设置中勾选 - 网络详情:镜像流量历史、ICMP 连接探测格子、接口与 Wi-Fi、本地与公网 IPv4 / IPv6、SVG 国旗归属地与 ASN、各进程流量 - DNS:刷新缓存,一键切换自动 / Cloudflare / Google / 腾讯 / 阿里云或手动填写;辅助工具协议升至 3,服务名与地址二次校验 - 主窗口:侧边栏切换仪表盘、各指标详情与工具页,宽高可调,卡片按比例伸缩 - 统一外观:白底蓝 / 黑底蓝,窗口、设置与弹窗共用底色,去掉玻璃效果 - 新增网络解析、DNS 校验与国旗代码校验的单元测试;更新 README、架构文档与隐私说明
153 lines
5.6 KiB
Swift
153 lines
5.6 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
/// 菜单栏下方的浮动详情弹窗。点击外部、按 Esc 或切换应用时关闭。
|
|
/// 高度按内容自动计算,屏幕放得下就不出现滚动。
|
|
@MainActor
|
|
final class StatusPanel: NSPanel {
|
|
var onVisibilityChange: ((Bool) -> Void)?
|
|
/// 开发调试用:固定面板,不因点击外部或失去焦点而收起
|
|
var isPinned = false
|
|
|
|
private var globalMonitor: Any?
|
|
private var localMonitor: Any?
|
|
private var anchor: NSRect = .zero
|
|
private weak var anchorScreen: NSScreen?
|
|
private var lastDismissal = Date.distantPast
|
|
|
|
/// 面板隐藏时不保留 SwiftUI 视图,避免后台随数据刷新重绘
|
|
private let makeContent: () -> NSView
|
|
/// 平铺布局(不含滚动容器)的视图,只用来测量内容的自然高度
|
|
private let makeMeasuringContent: () -> NSView
|
|
private let width: CGFloat
|
|
|
|
init<Content: View, Measuring: View>(width: CGFloat, content: @escaping () -> Content, measuring: @escaping () -> Measuring) {
|
|
self.width = width
|
|
makeContent = { NSHostingView(rootView: content()) }
|
|
makeMeasuringContent = { NSHostingView(rootView: measuring()) }
|
|
super.init(contentRect: NSRect(x: 0, y: 0, width: width, height: DS.Size.panelMinHeight),
|
|
styleMask: [.borderless, .nonactivatingPanel],
|
|
backing: .buffered,
|
|
defer: true)
|
|
isFloatingPanel = true
|
|
level = .statusBar
|
|
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle]
|
|
isOpaque = false
|
|
backgroundColor = .clear
|
|
hasShadow = true
|
|
hidesOnDeactivate = false
|
|
isReleasedWhenClosed = false
|
|
animationBehavior = .utilityWindow
|
|
isMovable = false
|
|
}
|
|
|
|
override var canBecomeKey: Bool { true }
|
|
|
|
func toggle(below anchor: NSRect, on screen: NSScreen?) {
|
|
if isVisible {
|
|
dismiss()
|
|
} else if Date().timeIntervalSince(lastDismissal) > 0.3 {
|
|
// 面板打开时点击菜单栏图标:按下时面板已因失焦关闭,松开时不应再次打开
|
|
present(below: anchor, on: screen)
|
|
}
|
|
}
|
|
|
|
func present(below anchor: NSRect, on screen: NSScreen?) {
|
|
self.anchor = anchor
|
|
anchorScreen = screen
|
|
|
|
let content = makeContent()
|
|
setFrame(targetFrame(), display: false)
|
|
content.frame = NSRect(origin: .zero, size: frame.size)
|
|
contentView = PanelContainer.make(containing: content, cornerRadius: DS.Radius.xl)
|
|
|
|
alphaValue = 0
|
|
makeKeyAndOrderFront(nil)
|
|
NSAnimationContext.runAnimationGroup { context in
|
|
context.duration = 0.12
|
|
animator().alphaValue = 1
|
|
}
|
|
installMonitors()
|
|
onVisibilityChange?(true)
|
|
}
|
|
|
|
/// 内容区块变化后重新计算高度,保持顶部对齐。
|
|
/// 直接设置而不做窗口动画:动画期间 SwiftUI 会逐帧重排,看起来像抖动
|
|
func refreshHeight() {
|
|
guard isVisible else { return }
|
|
let frame = targetFrame()
|
|
guard abs(frame.height - self.frame.height) > 1 else { return }
|
|
setFrame(frame, display: true)
|
|
invalidateShadow()
|
|
}
|
|
|
|
func dismiss() {
|
|
guard isVisible, !isPinned else { return }
|
|
lastDismissal = Date()
|
|
removeMonitors()
|
|
NSAnimationContext.runAnimationGroup({ context in
|
|
context.duration = 0.1
|
|
animator().alphaValue = 0
|
|
}, completionHandler: { [weak self] in
|
|
MainActor.assumeIsolated {
|
|
self?.orderOut(nil)
|
|
self?.contentView = nil
|
|
self?.alphaValue = 1
|
|
}
|
|
})
|
|
onVisibilityChange?(false)
|
|
}
|
|
|
|
override func resignKey() {
|
|
super.resignKey()
|
|
// 弹出菜单时面板也会失去 key,稍后确认没有其他 key 窗口再关闭
|
|
DispatchQueue.main.async { [weak self] in
|
|
MainActor.assumeIsolated {
|
|
guard let self, self.isVisible, !self.isKeyWindow, self.attachedSheet == nil else { return }
|
|
if NSApp.keyWindow?.level == .popUpMenu { return }
|
|
self.dismiss()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: 尺寸
|
|
|
|
private func targetFrame() -> NSRect {
|
|
let visible = (anchorScreen ?? NSScreen.main)?.visibleFrame ?? .zero
|
|
let margin = DS.Space.s2
|
|
let top = min(anchor.minY - DS.Size.panelGap, visible.maxY)
|
|
let available = max(DS.Size.panelMinHeight, top - visible.minY - margin)
|
|
|
|
let natural = makeMeasuringContent().fittingSize.height
|
|
let height = min(max(natural, DS.Size.panelMinHeight), available)
|
|
|
|
var x = anchor.midX - width / 2
|
|
x = min(max(x, visible.minX + margin), visible.maxX - width - margin)
|
|
return NSRect(x: x, y: top - height, width: width, height: height)
|
|
}
|
|
|
|
// MARK: 事件
|
|
|
|
private func installMonitors() {
|
|
removeMonitors()
|
|
globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
|
|
MainActor.assumeIsolated { self?.dismiss() }
|
|
}
|
|
localMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { [weak self] event in
|
|
// Esc
|
|
if event.keyCode == 53, event.window === self {
|
|
MainActor.assumeIsolated { self?.dismiss() }
|
|
return nil
|
|
}
|
|
return event
|
|
}
|
|
}
|
|
|
|
private func removeMonitors() {
|
|
if let globalMonitor { NSEvent.removeMonitor(globalMonitor) }
|
|
if let localMonitor { NSEvent.removeMonitor(localMonitor) }
|
|
globalMonitor = nil
|
|
localMonitor = nil
|
|
}
|
|
}
|