✨ Initial: FloatingMonitor — macOS 悬浮系统监控
- 实时 CPU/GPU/内存/Swap 监控 + Top 进程 - 5 种计量器样式(圆环/条状/纯文本/扇形/紧凑) - 半透明悬浮窗 + 菜单栏图标控制 - 独立设置窗口,每模块可指定行号+列位布局 - 窗口宽高/透明度/字体全局可调
This commit is contained in:
commit
4ab9a7b28c
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
.build/
|
||||
dmg_build/
|
||||
*.dmg
|
||||
.DS_Store
|
||||
16
Package.swift
Normal file
16
Package.swift
Normal file
@ -0,0 +1,16 @@
|
||||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "FloatingMonitor",
|
||||
platforms: [
|
||||
.macOS(.v14)
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "FloatingMonitor",
|
||||
path: "Sources/FloatingMonitor",
|
||||
resources: []
|
||||
)
|
||||
]
|
||||
)
|
||||
120
Sources/FloatingMonitor/App/AppDelegate.swift
Normal file
120
Sources/FloatingMonitor/App/AppDelegate.swift
Normal file
@ -0,0 +1,120 @@
|
||||
import AppKit
|
||||
|
||||
// MARK: - 通知名
|
||||
|
||||
extension Notification.Name {
|
||||
static let showSettingsPanel = Notification.Name("FloatingMonitor.ShowSettings")
|
||||
static let resizeMonitorWindow = Notification.Name("FloatingMonitor.ResizeWindow")
|
||||
static let refreshIntervalChanged = Notification.Name("FloatingMonitor.RefreshIntervalChanged")
|
||||
}
|
||||
|
||||
// MARK: - AppDelegate
|
||||
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var windowController: FloatingWindowController?
|
||||
private var settingsWindowController: SettingsWindowController?
|
||||
private var statusItem: NSStatusItem?
|
||||
private var statusMenu: NSMenu!
|
||||
|
||||
let systemMonitor = SystemMonitor()
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
|
||||
windowController = FloatingWindowController()
|
||||
let contentView = ContentView(monitor: systemMonitor, windowController: windowController)
|
||||
windowController?.embed(rootView: contentView)
|
||||
|
||||
let cfg = Configuration.shared
|
||||
windowController?.resize(width: cfg.windowWidth, height: cfg.windowHeight)
|
||||
|
||||
systemMonitor.interval = cfg.refreshInterval
|
||||
systemMonitor.topN = cfg.processCount
|
||||
systemMonitor.start()
|
||||
|
||||
setupStatusItem()
|
||||
observeResize()
|
||||
}
|
||||
|
||||
// MARK: - 菜单栏
|
||||
|
||||
private func setupStatusItem() {
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
||||
guard let button = statusItem?.button else { return }
|
||||
|
||||
if let image = NSImage(systemSymbolName: "gauge.with.dots.needle.33percent",
|
||||
accessibilityDescription: "Monitor") {
|
||||
image.isTemplate = true
|
||||
button.image = image
|
||||
}
|
||||
button.toolTip = "Monitor"
|
||||
|
||||
statusMenu = NSMenu()
|
||||
|
||||
let toggleItem = NSMenuItem(title: "显示 Monitor", action: #selector(menuToggleWindow), keyEquivalent: "")
|
||||
toggleItem.target = self
|
||||
statusMenu.addItem(toggleItem)
|
||||
statusMenu.addItem(.separator())
|
||||
|
||||
let settingsItem = NSMenuItem(title: "显示设置…", action: #selector(menuOpenSettings), keyEquivalent: ",")
|
||||
settingsItem.keyEquivalentModifierMask = .command
|
||||
settingsItem.target = self
|
||||
statusMenu.addItem(settingsItem)
|
||||
statusMenu.addItem(.separator())
|
||||
|
||||
let quitItem = NSMenuItem(title: "退出 Monitor", action: #selector(menuQuit), keyEquivalent: "q")
|
||||
quitItem.keyEquivalentModifierMask = .command
|
||||
quitItem.target = self
|
||||
statusMenu.addItem(quitItem)
|
||||
|
||||
statusItem?.menu = statusMenu
|
||||
|
||||
button.target = self
|
||||
button.action = #selector(statusItemClicked)
|
||||
button.sendAction(on: [.leftMouseDown, .rightMouseDown])
|
||||
}
|
||||
|
||||
@objc private func statusItemClicked() {
|
||||
guard let event = NSApp.currentEvent else { return }
|
||||
if event.type == .rightMouseDown {
|
||||
updateToggleMenuItemTitle()
|
||||
} else {
|
||||
toggleWindow()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func menuToggleWindow() { toggleWindow() }
|
||||
@objc private func toggleWindow() { windowController?.toggleVisible() }
|
||||
@objc private func menuOpenSettings() { openSettingsWindow() }
|
||||
@objc private func menuQuit() { NSApp.terminate(nil) }
|
||||
|
||||
private func updateToggleMenuItemTitle() {
|
||||
let visible = windowController?.isVisible ?? false
|
||||
statusMenu.item(at: 0)?.title = visible ? "隐藏 Monitor" : "显示 Monitor"
|
||||
}
|
||||
|
||||
// MARK: - 设置窗口
|
||||
|
||||
private func openSettingsWindow() {
|
||||
if settingsWindowController == nil {
|
||||
settingsWindowController = SettingsWindowController(monitor: systemMonitor)
|
||||
}
|
||||
settingsWindowController?.show()
|
||||
}
|
||||
|
||||
// MARK: - 监听
|
||||
|
||||
private func observeResize() {
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .resizeMonitorWindow, object: nil, queue: .main
|
||||
) { [weak self] notification in
|
||||
guard let size = notification.object as? NSSize else { return }
|
||||
self?.windowController?.resize(width: size.width, height: size.height)
|
||||
}
|
||||
}
|
||||
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||
windowController?.show()
|
||||
return true
|
||||
}
|
||||
}
|
||||
27
Sources/FloatingMonitor/App/FloatingWindow.swift
Normal file
27
Sources/FloatingMonitor/App/FloatingWindow.swift
Normal file
@ -0,0 +1,27 @@
|
||||
import AppKit
|
||||
|
||||
/// 半透明悬浮窗口
|
||||
final class FloatingWindow: NSWindow {
|
||||
override var canBecomeKey: Bool { true }
|
||||
override var canBecomeMain: Bool { false }
|
||||
|
||||
override init(
|
||||
contentRect: NSRect = NSRect(x: 0, y: 0, width: 250, height: 300),
|
||||
styleMask style: NSWindow.StyleMask = [.borderless, .resizable],
|
||||
backing backingStoreType: NSWindow.BackingStoreType = .buffered,
|
||||
defer flag: Bool = false
|
||||
) {
|
||||
super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag)
|
||||
|
||||
self.level = .floating
|
||||
self.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
|
||||
|
||||
self.isOpaque = false
|
||||
self.backgroundColor = .clear
|
||||
self.hasShadow = true
|
||||
self.isMovableByWindowBackground = true
|
||||
self.minSize = NSSize(width: 240, height: 200)
|
||||
self.titlebarAppearsTransparent = true
|
||||
self.titleVisibility = .hidden
|
||||
}
|
||||
}
|
||||
116
Sources/FloatingMonitor/App/FloatingWindowController.swift
Normal file
116
Sources/FloatingMonitor/App/FloatingWindowController.swift
Normal file
@ -0,0 +1,116 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// 悬浮窗口控制器:管理毛玻璃效果 + SwiftUI 视图嵌入
|
||||
final class FloatingWindowController: NSWindowController {
|
||||
private var hostingView: NSHostingView<ContentView>?
|
||||
private var visualEffect: NSVisualEffectView!
|
||||
|
||||
convenience init() {
|
||||
let window = FloatingWindow()
|
||||
window.isReleasedWhenClosed = false
|
||||
self.init(window: window)
|
||||
setupVisualEffect()
|
||||
}
|
||||
|
||||
private func setupVisualEffect() {
|
||||
guard let window = window else { return }
|
||||
|
||||
let ve = NSVisualEffectView()
|
||||
ve.wantsLayer = true
|
||||
ve.material = .hudWindow
|
||||
ve.blendingMode = .behindWindow
|
||||
ve.state = .active
|
||||
ve.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
ve.layer?.cornerRadius = 16
|
||||
ve.layer?.masksToBounds = true
|
||||
ve.layer?.borderWidth = 0
|
||||
ve.layer?.borderColor = NSColor.clear.cgColor
|
||||
ve.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.35).cgColor
|
||||
|
||||
self.visualEffect = ve
|
||||
|
||||
guard let contentView = window.contentView else { return }
|
||||
contentView.addSubview(ve, positioned: .below, relativeTo: contentView.subviews.first)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
ve.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||
ve.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||
ve.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
ve.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
func embed(rootView: ContentView) {
|
||||
guard let window = window, let contentView = window.contentView else { return }
|
||||
|
||||
let hv = NSHostingView(rootView: rootView)
|
||||
hv.wantsLayer = true
|
||||
hv.layer?.cornerRadius = 16
|
||||
hv.layer?.masksToBounds = true
|
||||
hv.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
contentView.addSubview(hv)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
hv.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||
hv.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||
hv.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
hv.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
])
|
||||
|
||||
self.hostingView = hv
|
||||
}
|
||||
|
||||
// MARK: - 显隐
|
||||
|
||||
func toggleVisible() {
|
||||
guard let window = window else { return }
|
||||
if window.isVisible {
|
||||
window.orderOut(nil)
|
||||
} else {
|
||||
positionNearMenuBar()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: false)
|
||||
}
|
||||
}
|
||||
|
||||
func show() {
|
||||
guard let window = window else { return }
|
||||
if !window.isVisible {
|
||||
positionNearMenuBar()
|
||||
}
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: false)
|
||||
}
|
||||
|
||||
func hide() {
|
||||
window?.orderOut(nil)
|
||||
}
|
||||
|
||||
var isVisible: Bool {
|
||||
window?.isVisible ?? false
|
||||
}
|
||||
|
||||
func applyOpacity(_ value: Double) {
|
||||
window?.alphaValue = value
|
||||
}
|
||||
|
||||
func resize(width: CGFloat, height: CGFloat) {
|
||||
guard let window = window else { return }
|
||||
var frame = window.frame
|
||||
frame.size.width = width
|
||||
frame.size.height = height
|
||||
window.setFrame(frame, display: true, animate: true)
|
||||
}
|
||||
|
||||
private func positionNearMenuBar() {
|
||||
guard let window = window, let screen = NSScreen.main else { return }
|
||||
let screenFrame = screen.visibleFrame
|
||||
window.setFrameOrigin(NSPoint(
|
||||
x: screenFrame.maxX - window.frame.width - 24,
|
||||
y: screenFrame.maxY - window.frame.height - 24
|
||||
))
|
||||
}
|
||||
}
|
||||
41
Sources/FloatingMonitor/App/SettingsWindowController.swift
Normal file
41
Sources/FloatingMonitor/App/SettingsWindowController.swift
Normal file
@ -0,0 +1,41 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// 独立的设置窗口控制器(参考语音智能体项目 SettingsWindow 模式)
|
||||
final class SettingsWindowController: NSWindowController {
|
||||
|
||||
private let monitor: SystemMonitor
|
||||
|
||||
init(monitor: SystemMonitor) {
|
||||
self.monitor = monitor
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 340, height: 640),
|
||||
styleMask: [.titled, .closable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Monitor 设置"
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.isReleasedWhenClosed = false
|
||||
window.minSize = NSSize(width: 300, height: 480)
|
||||
super.init(window: window)
|
||||
|
||||
// 嵌入 SwiftUI 视图
|
||||
let settingsView = SettingsView(monitor: monitor)
|
||||
let hostingView = NSHostingView(rootView: settingsView)
|
||||
hostingView.translatesAutoresizingMaskIntoConstraints = false
|
||||
window.contentView = hostingView
|
||||
|
||||
window.center()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func show() {
|
||||
window?.center()
|
||||
window?.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: false)
|
||||
}
|
||||
}
|
||||
174
Sources/FloatingMonitor/Configuration.swift
Normal file
174
Sources/FloatingMonitor/Configuration.swift
Normal file
@ -0,0 +1,174 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// 单个指标的展示样式
|
||||
enum GaugeDisplayStyle: String, CaseIterable, Identifiable, Codable {
|
||||
case bar // 条状百分比
|
||||
case ring // 圆环进度
|
||||
case text // 纯文本百分比
|
||||
case pie // 扇形小圆盘
|
||||
case compact // 紧凑横条 + 文字
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .bar: return "条状百分比"
|
||||
case .ring: return "圆环进度"
|
||||
case .text: return "纯文本"
|
||||
case .pie: return "扇形圆盘"
|
||||
case .compact: return "紧凑横条"
|
||||
}
|
||||
}
|
||||
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .bar: return "rectangle.split.1x2"
|
||||
case .ring: return "circle.dotted"
|
||||
case .text: return "textformat"
|
||||
case .pie: return "chart.pie"
|
||||
case .compact: return "rectangle.compress.vertical"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 模块在窗口中的排列位置
|
||||
enum ModulePosition: String, CaseIterable, Identifiable, Codable {
|
||||
case left // 左列
|
||||
case right // 右列
|
||||
case full // 全宽(独占一行)
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .left: return "左"
|
||||
case .right: return "右"
|
||||
case .full: return "全宽"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置管理者:所有用户偏好通过 UserDefaults 持久化
|
||||
final class Configuration: ObservableObject {
|
||||
static let shared = Configuration()
|
||||
|
||||
@Published var cpuStyle: GaugeDisplayStyle {
|
||||
didSet { save("cpuStyle", cpuStyle.rawValue) }
|
||||
}
|
||||
@Published var gpuStyle: GaugeDisplayStyle {
|
||||
didSet { save("gpuStyle", gpuStyle.rawValue) }
|
||||
}
|
||||
@Published var memoryStyle: GaugeDisplayStyle {
|
||||
didSet { save("memoryStyle", memoryStyle.rawValue) }
|
||||
}
|
||||
@Published var swapStyle: GaugeDisplayStyle {
|
||||
didSet { save("swapStyle", swapStyle.rawValue) }
|
||||
}
|
||||
|
||||
@Published var showCPU: Bool {
|
||||
didSet { save("showCPU", showCPU) }
|
||||
}
|
||||
@Published var showGPU: Bool {
|
||||
didSet { save("showGPU", showGPU) }
|
||||
}
|
||||
@Published var showMemory: Bool {
|
||||
didSet { save("showMemory", showMemory) }
|
||||
}
|
||||
@Published var showSwap: Bool {
|
||||
didSet { save("showSwap", showSwap) }
|
||||
}
|
||||
@Published var showTopProcesses: Bool {
|
||||
didSet { save("showTopProcesses", showTopProcesses) }
|
||||
}
|
||||
|
||||
@Published var processCount: Int {
|
||||
didSet { save("processCount", processCount) }
|
||||
}
|
||||
@Published var refreshInterval: Double {
|
||||
didSet { save("refreshInterval", refreshInterval) }
|
||||
}
|
||||
@Published var opacity: Double {
|
||||
didSet { save("opacity", opacity) }
|
||||
}
|
||||
@Published var fontSize: Double {
|
||||
didSet { save("fontSize", fontSize) }
|
||||
}
|
||||
@Published var windowWidth: Double {
|
||||
didSet { save("windowWidth", windowWidth) }
|
||||
}
|
||||
@Published var windowHeight: Double {
|
||||
didSet { save("windowHeight", windowHeight) }
|
||||
}
|
||||
|
||||
@Published var cpuPos: ModulePosition {
|
||||
didSet { save("cpuPos", cpuPos.rawValue) }
|
||||
}
|
||||
@Published var gpuPos: ModulePosition {
|
||||
didSet { save("gpuPos", gpuPos.rawValue) }
|
||||
}
|
||||
@Published var memoryPos: ModulePosition {
|
||||
didSet { save("memoryPos", memoryPos.rawValue) }
|
||||
}
|
||||
@Published var swapPos: ModulePosition {
|
||||
didSet { save("swapPos", swapPos.rawValue) }
|
||||
}
|
||||
@Published var processesPos: ModulePosition {
|
||||
didSet { save("processesPos", processesPos.rawValue) }
|
||||
}
|
||||
|
||||
// 行号:决定模块在垂直方向上的排列顺序(1-10)
|
||||
@Published var cpuRow: Int {
|
||||
didSet { save("cpuRow", cpuRow) }
|
||||
}
|
||||
@Published var gpuRow: Int {
|
||||
didSet { save("gpuRow", gpuRow) }
|
||||
}
|
||||
@Published var memoryRow: Int {
|
||||
didSet { save("memoryRow", memoryRow) }
|
||||
}
|
||||
@Published var swapRow: Int {
|
||||
didSet { save("swapRow", swapRow) }
|
||||
}
|
||||
@Published var processesRow: Int {
|
||||
didSet { save("processesRow", processesRow) }
|
||||
}
|
||||
|
||||
private init() {
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
cpuStyle = GaugeDisplayStyle(rawValue: defaults.string(forKey: "cpuStyle") ?? "ring") ?? .ring
|
||||
gpuStyle = GaugeDisplayStyle(rawValue: defaults.string(forKey: "gpuStyle") ?? "ring") ?? .ring
|
||||
memoryStyle = GaugeDisplayStyle(rawValue: defaults.string(forKey: "memoryStyle") ?? "bar") ?? .bar
|
||||
swapStyle = GaugeDisplayStyle(rawValue: defaults.string(forKey: "swapStyle") ?? "bar") ?? .bar
|
||||
|
||||
showCPU = defaults.object(forKey: "showCPU") as? Bool ?? true
|
||||
showGPU = defaults.object(forKey: "showGPU") as? Bool ?? true
|
||||
showMemory = defaults.object(forKey: "showMemory") as? Bool ?? true
|
||||
showSwap = defaults.object(forKey: "showSwap") as? Bool ?? true
|
||||
showTopProcesses = defaults.object(forKey: "showTopProcesses") as? Bool ?? true
|
||||
|
||||
processCount = defaults.object(forKey: "processCount") as? Int ?? 5
|
||||
refreshInterval = defaults.object(forKey: "refreshInterval") as? Double ?? 1.5
|
||||
opacity = defaults.object(forKey: "opacity") as? Double ?? 1.0
|
||||
fontSize = defaults.object(forKey: "fontSize") as? Double ?? 12
|
||||
windowWidth = defaults.object(forKey: "windowWidth") as? Double ?? 250
|
||||
windowHeight = defaults.object(forKey: "windowHeight") as? Double ?? 300
|
||||
|
||||
cpuPos = ModulePosition(rawValue: defaults.string(forKey: "cpuPos") ?? "full") ?? .full
|
||||
gpuPos = ModulePosition(rawValue: defaults.string(forKey: "gpuPos") ?? "full") ?? .full
|
||||
memoryPos = ModulePosition(rawValue: defaults.string(forKey: "memoryPos") ?? "full") ?? .full
|
||||
swapPos = ModulePosition(rawValue: defaults.string(forKey: "swapPos") ?? "full") ?? .full
|
||||
processesPos = ModulePosition(rawValue: defaults.string(forKey: "processesPos") ?? "full") ?? .full
|
||||
|
||||
cpuRow = defaults.object(forKey: "cpuRow") as? Int ?? 1
|
||||
gpuRow = defaults.object(forKey: "gpuRow") as? Int ?? 2
|
||||
memoryRow = defaults.object(forKey: "memoryRow") as? Int ?? 3
|
||||
swapRow = defaults.object(forKey: "swapRow") as? Int ?? 4
|
||||
processesRow = defaults.object(forKey: "processesRow") as? Int ?? 5
|
||||
}
|
||||
|
||||
private func save(_ key: String, _ value: Any) {
|
||||
UserDefaults.standard.set(value, forKey: key)
|
||||
}
|
||||
}
|
||||
227
Sources/FloatingMonitor/ContentView.swift
Normal file
227
Sources/FloatingMonitor/ContentView.swift
Normal file
@ -0,0 +1,227 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@ObservedObject var monitor: SystemMonitor
|
||||
@ObservedObject private var config = Configuration.shared
|
||||
|
||||
weak var windowController: FloatingWindowController?
|
||||
|
||||
init(monitor: SystemMonitor, windowController: FloatingWindowController? = nil) {
|
||||
self.monitor = monitor
|
||||
self.windowController = windowController
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// ── 顶部固定区域:图标 + Monitor + 时间 + 关闭按钮 ──
|
||||
headerBar
|
||||
|
||||
Divider()
|
||||
.background(Color.white.opacity(0.1))
|
||||
|
||||
// ── 可滚动内容 ──
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(spacing: 14) {
|
||||
ForEach(Array(moduleRows.enumerated()), id: \.offset) { _, row in
|
||||
row
|
||||
}
|
||||
}
|
||||
.padding(.top, 6)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 240, idealWidth: 280, maxWidth: 400,
|
||||
minHeight: 200, idealHeight: 480, maxHeight: 700)
|
||||
.onAppear {
|
||||
monitor.interval = config.refreshInterval
|
||||
monitor.topN = config.processCount
|
||||
applyOpacity()
|
||||
}
|
||||
.onChange(of: config.processCount) { _, new in
|
||||
monitor.topN = new
|
||||
}
|
||||
.onChange(of: config.opacity) { _, _ in
|
||||
applyOpacity()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .refreshIntervalChanged)) { notification in
|
||||
if let interval = notification.object as? Double {
|
||||
monitor.interval = interval
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 顶部栏(精简版)
|
||||
|
||||
private var headerBar: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "gauge.with.dots.needle.33percent")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundColor(.accentColor)
|
||||
|
||||
Text("Monitor")
|
||||
.font(.system(size: config.fontSize, weight: .semibold))
|
||||
.foregroundColor(.primary.opacity(0.85))
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(timeString)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundColor(.secondary.opacity(0.5))
|
||||
|
||||
Button(action: { windowController?.hide() }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundColor(.secondary.opacity(0.5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("隐藏窗口(点击菜单栏图标重新打开)")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
// MARK: - 各指标
|
||||
|
||||
private var cpuGauge: some View {
|
||||
StyledGauge(
|
||||
title: "CPU", icon: "cpu",
|
||||
value: monitor.info.cpuUsage, unit: "%",
|
||||
detail: "\(monitor.info.cpuCores) 核心 · \(monitor.info.cpuModel)",
|
||||
style: config.cpuStyle, fontSize: config.fontSize
|
||||
)
|
||||
}
|
||||
|
||||
private var gpuGauge: some View {
|
||||
StyledGauge(
|
||||
title: "GPU", icon: "display",
|
||||
value: monitor.info.gpuUsage ?? 0, unit: "%",
|
||||
detail: monitor.info.gpuModel + (monitor.info.gpuUsage == nil ? " (N/A)" : ""),
|
||||
style: config.gpuStyle, fontSize: config.fontSize
|
||||
)
|
||||
}
|
||||
|
||||
private var memoryGauge: some View {
|
||||
StyledGauge(
|
||||
title: "内存", icon: "memorychip",
|
||||
value: monitor.info.memoryUsagePercent, unit: "%",
|
||||
detail: formatBytes(monitor.info.memoryUsed) + " / " + formatBytes(monitor.info.memoryTotal),
|
||||
style: config.memoryStyle, fontSize: config.fontSize
|
||||
)
|
||||
}
|
||||
|
||||
private var swapGauge: some View {
|
||||
StyledGauge(
|
||||
title: "Swap", icon: "arrow.left.arrow.right",
|
||||
value: monitor.info.swapUsagePercent, unit: "%",
|
||||
detail: formatBytes(monitor.info.swapUsed) + " / " + formatBytes(monitor.info.swapTotal),
|
||||
style: config.swapStyle, fontSize: config.fontSize
|
||||
)
|
||||
}
|
||||
|
||||
private var processesSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
ProcessListView(
|
||||
processes: monitor.info.topCPUProcesses,
|
||||
title: "CPU 消耗 Top", icon: "bolt.horizontal",
|
||||
isMemory: false, fontSize: config.fontSize
|
||||
)
|
||||
ProcessListView(
|
||||
processes: monitor.info.topMemoryProcesses,
|
||||
title: "内存消耗 Top", icon: "tray.full",
|
||||
isMemory: true, fontSize: config.fontSize
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 布局引擎(左/右/全宽排列)
|
||||
|
||||
/// 根据行号 + 列位生成布局
|
||||
private var moduleRows: [AnyView] {
|
||||
struct Item {
|
||||
let name: String
|
||||
let row: Int
|
||||
let position: ModulePosition
|
||||
let view: AnyView
|
||||
}
|
||||
|
||||
var items: [Item] = []
|
||||
if config.showCPU {
|
||||
items.append(Item(name: "CPU", row: config.cpuRow, position: config.cpuPos,
|
||||
view: AnyView(cpuGauge)))
|
||||
}
|
||||
if config.showGPU {
|
||||
items.append(Item(name: "GPU", row: config.gpuRow, position: config.gpuPos,
|
||||
view: AnyView(gpuGauge.opacity(monitor.info.gpuUsage == nil ? 0.5 : 1.0))))
|
||||
}
|
||||
if config.showMemory {
|
||||
items.append(Item(name: "内存", row: config.memoryRow, position: config.memoryPos,
|
||||
view: AnyView(memoryGauge)))
|
||||
}
|
||||
if config.showSwap {
|
||||
items.append(Item(name: "Swap", row: config.swapRow, position: config.swapPos,
|
||||
view: AnyView(swapGauge)))
|
||||
}
|
||||
if config.showTopProcesses {
|
||||
items.append(Item(name: "进程", row: config.processesRow, position: config.processesPos,
|
||||
view: AnyView(processesSection)))
|
||||
}
|
||||
|
||||
// 按行号排序
|
||||
items.sort { $0.row < $1.row }
|
||||
|
||||
// 同行 left+right 配对,full 独占
|
||||
var rows: [AnyView] = []
|
||||
var i = 0
|
||||
while i < items.count {
|
||||
let item = items[i]
|
||||
switch item.position {
|
||||
case .full:
|
||||
rows.append(item.view)
|
||||
i += 1
|
||||
case .left:
|
||||
// 找同行下一个 right
|
||||
if let j = (i+1..<items.count).first(where: { items[$0].row == item.row && items[$0].position == .right }) {
|
||||
rows.append(AnyView(HStack(alignment: .top, spacing: 12) {
|
||||
item.view
|
||||
items[j].view
|
||||
}))
|
||||
items.remove(at: j)
|
||||
} else {
|
||||
rows.append(item.view)
|
||||
}
|
||||
i += 1
|
||||
case .right:
|
||||
rows.append(item.view)
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// MARK: - 工具
|
||||
|
||||
private var timeString: String {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "HH:mm:ss"
|
||||
return f.string(from: Date())
|
||||
}
|
||||
|
||||
private func formatBytes(_ bytes: UInt64) -> String {
|
||||
if bytes >= 1_073_741_824 {
|
||||
return String(format: "%.1f GB", Double(bytes) / 1_073_741_824)
|
||||
} else if bytes >= 1_048_576 {
|
||||
return String(format: "%.0f MB", Double(bytes) / 1_048_576)
|
||||
} else if bytes >= 1_024 {
|
||||
return String(format: "%.0f KB", Double(bytes) / 1_024)
|
||||
} else {
|
||||
return "\(bytes) B"
|
||||
}
|
||||
}
|
||||
|
||||
private func applyOpacity() {
|
||||
DispatchQueue.main.async {
|
||||
windowController?.applyOpacity(config.opacity)
|
||||
}
|
||||
}
|
||||
}
|
||||
335
Sources/FloatingMonitor/SystemMonitor.swift
Normal file
335
Sources/FloatingMonitor/SystemMonitor.swift
Normal file
@ -0,0 +1,335 @@
|
||||
import Foundation
|
||||
import IOKit
|
||||
import Darwin
|
||||
|
||||
// MARK: - 系统信息数据结构
|
||||
|
||||
struct SystemInfo {
|
||||
var cpuUsage: Double // 0-100
|
||||
var cpuCores: Int
|
||||
var cpuModel: String
|
||||
|
||||
var gpuUsage: Double? // 0-100 (部分 Mac 可能为 nil)
|
||||
var gpuModel: String
|
||||
|
||||
var memoryTotal: UInt64 // bytes
|
||||
var memoryUsed: UInt64
|
||||
var memoryUsagePercent: Double
|
||||
|
||||
var swapTotal: UInt64
|
||||
var swapUsed: UInt64
|
||||
var swapUsagePercent: Double
|
||||
var swapIns: UInt64
|
||||
var swapOuts: UInt64
|
||||
|
||||
var topCPUProcesses: [ProcessInfo]
|
||||
var topMemoryProcesses: [ProcessInfo]
|
||||
|
||||
struct ProcessInfo: Identifiable {
|
||||
let id = UUID()
|
||||
let pid: Int32
|
||||
let name: String
|
||||
let cpuPercent: Double
|
||||
let memoryBytes: UInt64
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 系统数据采集引擎
|
||||
|
||||
@MainActor
|
||||
final class SystemMonitor: ObservableObject {
|
||||
@Published var info = SystemInfo(
|
||||
cpuUsage: 0, cpuCores: 0, cpuModel: "",
|
||||
gpuUsage: nil, gpuModel: "",
|
||||
memoryTotal: 0, memoryUsed: 0, memoryUsagePercent: 0,
|
||||
swapTotal: 0, swapUsed: 0, swapUsagePercent: 0,
|
||||
swapIns: 0, swapOuts: 0,
|
||||
topCPUProcesses: [], topMemoryProcesses: []
|
||||
)
|
||||
|
||||
private var timer: Timer?
|
||||
private var previousCPU: host_cpu_load_info?
|
||||
|
||||
// 采集频率
|
||||
var interval: TimeInterval = 1.5 {
|
||||
didSet { restartTimer() }
|
||||
}
|
||||
var topN: Int = 5
|
||||
|
||||
nonisolated init() {}
|
||||
|
||||
// MARK: - 启动 / 停止
|
||||
|
||||
func start() {
|
||||
fetch() // 立即拉一次
|
||||
restartTimer()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func restartTimer() {
|
||||
timer?.invalidate()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.fetch()
|
||||
}
|
||||
}
|
||||
// 允许 timer 在 tracking 模式下也触发(防止拖拽窗口时卡顿不更新)
|
||||
if let t = timer {
|
||||
RunLoop.current.add(t, forMode: .common)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 数据采集
|
||||
|
||||
func fetch() {
|
||||
let cpu = fetchCPU()
|
||||
let gpu = fetchGPU()
|
||||
let mem = fetchMemory()
|
||||
let swap = fetchSwap()
|
||||
let procs = fetchTopProcesses(count: topN)
|
||||
|
||||
info = SystemInfo(
|
||||
cpuUsage: cpu.usage,
|
||||
cpuCores: cpu.cores,
|
||||
cpuModel: cpu.model,
|
||||
gpuUsage: gpu.usage,
|
||||
gpuModel: gpu.model,
|
||||
memoryTotal: mem.total,
|
||||
memoryUsed: mem.used,
|
||||
memoryUsagePercent: mem.percent,
|
||||
swapTotal: swap.total,
|
||||
swapUsed: swap.used,
|
||||
swapUsagePercent: swap.percent,
|
||||
swapIns: swap.ins,
|
||||
swapOuts: swap.outs,
|
||||
topCPUProcesses: procs.cpu,
|
||||
topMemoryProcesses: procs.mem
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - CPU
|
||||
|
||||
private func fetchCPU() -> (usage: Double, cores: Int, model: String) {
|
||||
var size = mach_msg_type_number_t(MemoryLayout<host_cpu_load_info>.stride / MemoryLayout<integer_t>.stride)
|
||||
var info = host_cpu_load_info()
|
||||
let result = withUnsafeMutablePointer(to: &info) {
|
||||
$0.withMemoryRebound(to: integer_t.self, capacity: Int(size)) {
|
||||
host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, $0, &size)
|
||||
}
|
||||
}
|
||||
|
||||
guard result == KERN_SUCCESS else {
|
||||
return (0, ProcessInfo.processInfo.activeProcessorCount, "Unknown")
|
||||
}
|
||||
|
||||
let cores = ProcessInfo.processInfo.activeProcessorCount
|
||||
|
||||
// 获取 CPU 型号
|
||||
var model = "Apple Silicon"
|
||||
var sysctlSize = 0
|
||||
sysctlbyname("machdep.cpu.brand_string", nil, &sysctlSize, nil, 0)
|
||||
if sysctlSize > 0 {
|
||||
var brand = [CChar](repeating: 0, count: sysctlSize)
|
||||
sysctlbyname("machdep.cpu.brand_string", &brand, &sysctlSize, nil, 0)
|
||||
model = String(cString: brand)
|
||||
}
|
||||
|
||||
let user = Double(info.cpu_ticks.0)
|
||||
let system = Double(info.cpu_ticks.1)
|
||||
let idle = Double(info.cpu_ticks.2)
|
||||
let nice = Double(info.cpu_ticks.3)
|
||||
|
||||
var usage: Double = 0
|
||||
if let prev = previousCPU {
|
||||
let prevUser = Double(prev.cpu_ticks.0)
|
||||
let prevSystem = Double(prev.cpu_ticks.1)
|
||||
let prevIdle = Double(prev.cpu_ticks.2)
|
||||
let prevNice = Double(prev.cpu_ticks.3)
|
||||
|
||||
let totalTicks = (user - prevUser) + (system - prevSystem) + (idle - prevIdle) + (nice - prevNice)
|
||||
let usedTicks = (user - prevUser) + (system - prevSystem) + (nice - prevNice)
|
||||
|
||||
if totalTicks > 0 {
|
||||
usage = (usedTicks / totalTicks) * 100
|
||||
}
|
||||
}
|
||||
|
||||
previousCPU = info
|
||||
return (min(usage, 100), cores, model)
|
||||
}
|
||||
|
||||
// MARK: - GPU (通过 IOKit)
|
||||
|
||||
private func fetchGPU() -> (usage: Double?, model: String) {
|
||||
var model = "Unknown GPU"
|
||||
var usage: Double? = nil
|
||||
|
||||
// 查找 GPU 节点
|
||||
var iterator: io_iterator_t = 0
|
||||
let matchDict = IOServiceMatching("IOAccelerator")
|
||||
let result = IOServiceGetMatchingServices(kIOMainPortDefault, matchDict, &iterator)
|
||||
|
||||
guard result == KERN_SUCCESS else { return (nil, "Unknown GPU") }
|
||||
|
||||
var gpuEntry = IOIteratorNext(iterator)
|
||||
defer { IOObjectRelease(iterator) }
|
||||
|
||||
while gpuEntry != 0 {
|
||||
// GPU 型号
|
||||
if let modelCF = IORegistryEntryCreateCFProperty(gpuEntry, "model" as CFString, kCFAllocatorDefault, 0) {
|
||||
if let modelStr = (modelCF.takeUnretainedValue() as? String) ?? (modelCF.takeUnretainedValue() as? Data).flatMap({ String(data: $0, encoding: .utf8) }) {
|
||||
model = modelStr
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试读取性能统计
|
||||
if let perfCF = IORegistryEntryCreateCFProperty(gpuEntry, "PerformanceStatistics" as CFString, kCFAllocatorDefault, 0) {
|
||||
let perfDict = perfCF.takeUnretainedValue() as? [String: Any]
|
||||
// Apple Silicon 常见 key
|
||||
if let util = perfDict?["GPU Core Utilization"] as? Double {
|
||||
usage = util * 100
|
||||
} else if let util = perfDict?["gpuCoreUtilization"] as? Double {
|
||||
usage = util * 100
|
||||
} else if let util = perfDict?["Device Utilization %"] as? Int {
|
||||
usage = Double(util)
|
||||
} else if let util = perfDict?["GPU Activity(%)"] as? Int {
|
||||
usage = Double(util)
|
||||
}
|
||||
}
|
||||
|
||||
IOObjectRelease(gpuEntry)
|
||||
gpuEntry = IOIteratorNext(iterator)
|
||||
}
|
||||
|
||||
// 如果没有从 IOKit 拿到型号,用 sysctl
|
||||
if model == "Unknown GPU" {
|
||||
var size = 0
|
||||
sysctlbyname("hw.model", nil, &size, nil, 0)
|
||||
if size > 0 {
|
||||
var hwModel = [CChar](repeating: 0, count: size)
|
||||
sysctlbyname("hw.model", &hwModel, &size, nil, 0)
|
||||
let hw = String(cString: hwModel)
|
||||
if hw.hasPrefix("Mac") {
|
||||
// Apple Silicon 通常显示为 "Apple M1/M2/M3..."
|
||||
// 通过 IO 获取到的 model 更准确
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (usage, model)
|
||||
}
|
||||
|
||||
// MARK: - 内存
|
||||
|
||||
private func fetchMemory() -> (total: UInt64, used: UInt64, percent: Double) {
|
||||
let total = ProcessInfo.processInfo.physicalMemory
|
||||
|
||||
var pageSize: vm_size_t = 0
|
||||
host_page_size(mach_host_self(), &pageSize)
|
||||
|
||||
var vmStat = vm_statistics64()
|
||||
var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64>.stride / MemoryLayout<integer_t>.stride)
|
||||
let result = withUnsafeMutablePointer(to: &vmStat) {
|
||||
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
|
||||
host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count)
|
||||
}
|
||||
}
|
||||
|
||||
guard result == KERN_SUCCESS else { return (total, 0, 0) }
|
||||
|
||||
let activePages = UInt64(vmStat.active_count)
|
||||
let wiredPages = UInt64(vmStat.wire_count)
|
||||
let compressedPages = UInt64(vmStat.compressor_page_count)
|
||||
|
||||
let pageSizeU64 = UInt64(pageSize)
|
||||
let usedRaw = (activePages + wiredPages + compressedPages) * pageSizeU64
|
||||
let percent = Double(usedRaw) / Double(total) * 100
|
||||
|
||||
return (total, usedRaw, min(percent, 100))
|
||||
}
|
||||
|
||||
// MARK: - Swap
|
||||
|
||||
private func fetchSwap() -> (total: UInt64, used: UInt64, percent: Double, ins: UInt64, outs: UInt64) {
|
||||
var xsw = xsw_usage()
|
||||
var size = MemoryLayout<xsw_usage>.size
|
||||
let result = sysctlbyname("vm.swapusage", &xsw, &size, nil, 0)
|
||||
|
||||
if result == 0 {
|
||||
return (xsw.xsu_total, xsw.xsu_used, xsw.xsu_total > 0 ? Double(xsw.xsu_used) / Double(xsw.xsu_total) * 100 : 0, 0, 0)
|
||||
}
|
||||
|
||||
return (0, 0, 0, 0, 0)
|
||||
}
|
||||
|
||||
// MARK: - 进程列表
|
||||
|
||||
private func fetchTopProcesses(count: Int) -> (cpu: [SystemInfo.ProcessInfo], mem: [SystemInfo.ProcessInfo]) {
|
||||
// 使用 ps 命令获取进程 CPU / 内存排行
|
||||
// ps -eo pid,%cpu,rss,comm -r | head -n 30
|
||||
let cpuProcs = executePS(sortByCPU: true, count: count)
|
||||
let memProcs = executePS(sortByCPU: false, count: count)
|
||||
return (cpuProcs, memProcs)
|
||||
}
|
||||
|
||||
private func executePS(sortByCPU: Bool, count: Int) -> [SystemInfo.ProcessInfo] {
|
||||
let sortFlag = sortByCPU ? "-r" : "-m"
|
||||
let command = "/bin/ps -eo pid,%cpu,rss,comm \(sortFlag) 2>/dev/null | head -n \(count + 1)"
|
||||
|
||||
let process = Process()
|
||||
process.launchPath = "/bin/bash"
|
||||
process.arguments = ["-c", command]
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = Pipe()
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
guard let output = String(data: data, encoding: .utf8) else { return [] }
|
||||
|
||||
var results: [SystemInfo.ProcessInfo] = []
|
||||
let lines = output.components(separatedBy: "\n").dropFirst() // 去掉表头
|
||||
|
||||
for line in lines {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { continue }
|
||||
|
||||
// 格式: PID %CPU RSS COMMAND
|
||||
var components = trimmed.components(separatedBy: .whitespaces)
|
||||
// 过滤空字符串
|
||||
components = components.filter { !$0.isEmpty }
|
||||
guard components.count >= 3 else { continue }
|
||||
|
||||
let pidStr = components[0]
|
||||
let cpuStr = components[1]
|
||||
let rssStr = components[2]
|
||||
let comm = components.dropFirst(3).joined(separator: " ")
|
||||
|
||||
guard let pid = Int32(pidStr),
|
||||
let cpu = Double(cpuStr),
|
||||
let rss = UInt64(rssStr) else { continue }
|
||||
|
||||
let rssBytes = rss * 1024 // RSS 单位是 KB
|
||||
|
||||
results.append(SystemInfo.ProcessInfo(
|
||||
pid: pid,
|
||||
name: comm,
|
||||
cpuPercent: cpu,
|
||||
memoryBytes: rssBytes
|
||||
))
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
217
Sources/FloatingMonitor/Views/GaugeView.swift
Normal file
217
Sources/FloatingMonitor/Views/GaugeView.swift
Normal file
@ -0,0 +1,217 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - 通用计量器:根据 GaugeDisplayStyle 动态渲染
|
||||
|
||||
struct StyledGauge: View {
|
||||
let title: String
|
||||
let icon: String
|
||||
let value: Double // 0-100
|
||||
let unit: String // "%" / "GB"
|
||||
let detail: String // 副标题(如 "3.2 / 16 GB")
|
||||
let style: GaugeDisplayStyle
|
||||
let fontSize: Double
|
||||
|
||||
// 主题色(根据负载渐变)
|
||||
private var gaugeColor: Color {
|
||||
if value < 40 { return .green }
|
||||
if value < 70 { return .yellow }
|
||||
if value < 85 { return .orange }
|
||||
return .red
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// 标题行
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: fontSize))
|
||||
.foregroundColor(.secondary)
|
||||
Text(title)
|
||||
.font(.system(size: fontSize, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// 根据样式渲染
|
||||
switch style {
|
||||
case .bar:
|
||||
barView
|
||||
case .ring:
|
||||
ringView
|
||||
case .text:
|
||||
textView
|
||||
case .pie:
|
||||
pieView
|
||||
case .compact:
|
||||
compactView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 条状百分比
|
||||
|
||||
private var barView: some View {
|
||||
VStack(spacing: 2) {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(0.12))
|
||||
.frame(height: 8)
|
||||
Capsule()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [gaugeColor.opacity(0.8), gaugeColor],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.frame(width: max(geo.size.width * CGFloat(value / 100), 4), height: 8)
|
||||
}
|
||||
}
|
||||
.frame(height: 8)
|
||||
|
||||
HStack {
|
||||
Text(String(format: "%.1f%@", value, unit))
|
||||
.font(.system(size: fontSize + 2, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
Text(detail)
|
||||
.font(.system(size: fontSize - 2))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 圆环进度
|
||||
|
||||
private var ringView: some View {
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.12), lineWidth: 5)
|
||||
.frame(width: 48, height: 48)
|
||||
Circle()
|
||||
.trim(from: 0, to: CGFloat(value / 100))
|
||||
.stroke(
|
||||
AngularGradient(
|
||||
colors: [gaugeColor.opacity(0.7), gaugeColor, gaugeColor.opacity(0.9)],
|
||||
center: .center
|
||||
),
|
||||
style: StrokeStyle(lineWidth: 5, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: 48, height: 48)
|
||||
|
||||
Text(String(format: "%.0f", value))
|
||||
.font(.system(size: fontSize - 1, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(String(format: "%.1f%@", value, unit))
|
||||
.font(.system(size: fontSize + 2, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
Text(detail)
|
||||
.font(.system(size: fontSize - 2))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 纯文本
|
||||
|
||||
private var textView: some View {
|
||||
HStack {
|
||||
Text(String(format: "%.1f%@", value, unit))
|
||||
.font(.system(size: fontSize + 6, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(gaugeColor)
|
||||
Spacer()
|
||||
Text(detail)
|
||||
.font(.system(size: fontSize - 2))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 扇形圆盘
|
||||
|
||||
private var pieView: some View {
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.05))
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
PieSlice(endAngle: .degrees(360 * value / 100))
|
||||
.fill(gaugeColor.opacity(0.85))
|
||||
.frame(width: 42, height: 42)
|
||||
.clipShape(Circle())
|
||||
|
||||
Text(String(format: "%.0f", value))
|
||||
.font(.system(size: fontSize - 2, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(String(format: "%.1f%@", value, unit))
|
||||
.font(.system(size: fontSize + 2, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
Text(detail)
|
||||
.font(.system(size: fontSize - 2))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 紧凑横条
|
||||
|
||||
private var compactView: some View {
|
||||
VStack(spacing: 4) {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.white.opacity(0.1))
|
||||
.frame(height: 14)
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(gaugeColor)
|
||||
.frame(width: max(geo.size.width * CGFloat(value / 100), 3), height: 14)
|
||||
}
|
||||
}
|
||||
.frame(height: 14)
|
||||
|
||||
HStack {
|
||||
Text(String(format: "%.1f%@", value, unit))
|
||||
.font(.system(size: fontSize, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
Text(detail)
|
||||
.font(.system(size: fontSize - 2))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 扇形 Shape
|
||||
|
||||
struct PieSlice: Shape {
|
||||
var endAngle: Angle
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
let center = CGPoint(x: rect.midX, y: rect.midY)
|
||||
let radius = min(rect.width, rect.height) / 2
|
||||
|
||||
path.move(to: center)
|
||||
path.addArc(center: center, radius: radius,
|
||||
startAngle: .degrees(-90),
|
||||
endAngle: endAngle - .degrees(90),
|
||||
clockwise: false)
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
101
Sources/FloatingMonitor/Views/ProcessListView.swift
Normal file
101
Sources/FloatingMonitor/Views/ProcessListView.swift
Normal file
@ -0,0 +1,101 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ProcessListView: View {
|
||||
let processes: [SystemInfo.ProcessInfo]
|
||||
let title: String
|
||||
let icon: String
|
||||
let isMemory: Bool // true = 显示内存,false = 显示 CPU%
|
||||
let fontSize: Double
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// 标题
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: fontSize))
|
||||
.foregroundColor(.secondary)
|
||||
Text(title)
|
||||
.font(.system(size: fontSize, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// 表头
|
||||
HStack(spacing: 0) {
|
||||
Text("进程")
|
||||
.font(.system(size: fontSize - 2))
|
||||
.foregroundColor(.secondary.opacity(0.6))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(isMemory ? "内存" : "CPU")
|
||||
.font(.system(size: fontSize - 2))
|
||||
.foregroundColor(.secondary.opacity(0.6))
|
||||
.frame(width: 52, alignment: .trailing)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
Divider()
|
||||
.background(Color.white.opacity(0.1))
|
||||
|
||||
// 进程列表
|
||||
ForEach(processes.prefix(5)) { proc in
|
||||
processRow(proc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func processRow(_ proc: SystemInfo.ProcessInfo) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
// 进程名
|
||||
Text(proc.name.components(separatedBy: "/").last ?? proc.name)
|
||||
.font(.system(size: fontSize - 1, design: .monospaced))
|
||||
.foregroundColor(.primary.opacity(0.85))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
|
||||
Spacer()
|
||||
|
||||
// 数值
|
||||
if isMemory {
|
||||
Text(formatBytes(proc.memoryBytes))
|
||||
.font(.system(size: fontSize - 1, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(memoryColor(proc.memoryBytes))
|
||||
.frame(width: 52, alignment: .trailing)
|
||||
} else {
|
||||
Text(String(format: "%.1f%%", proc.cpuPercent))
|
||||
.font(.system(size: fontSize - 1, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(cpuColor(proc.cpuPercent))
|
||||
.frame(width: 52, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 2)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private func formatBytes(_ bytes: UInt64) -> String {
|
||||
if bytes >= 1_073_741_824 {
|
||||
String(format: "%.1f GB", Double(bytes) / 1_073_741_824)
|
||||
} else if bytes >= 1_048_576 {
|
||||
String(format: "%.0f MB", Double(bytes) / 1_048_576)
|
||||
} else if bytes >= 1_024 {
|
||||
String(format: "%.0f KB", Double(bytes) / 1_024)
|
||||
} else {
|
||||
"\(bytes) B"
|
||||
}
|
||||
}
|
||||
|
||||
private func cpuColor(_ value: Double) -> Color {
|
||||
if value < 20 { return .green.opacity(0.8) }
|
||||
if value < 50 { return .yellow.opacity(0.8) }
|
||||
if value < 80 { return .orange.opacity(0.8) }
|
||||
return .red.opacity(0.8)
|
||||
}
|
||||
|
||||
private func memoryColor(_ bytes: UInt64) -> Color {
|
||||
let mb = Double(bytes) / 1_048_576
|
||||
if mb < 200 { return .green.opacity(0.8) }
|
||||
if mb < 500 { return .yellow.opacity(0.8) }
|
||||
if mb < 1000 { return .orange.opacity(0.8) }
|
||||
return .red.opacity(0.8)
|
||||
}
|
||||
}
|
||||
296
Sources/FloatingMonitor/Views/SettingsView.swift
Normal file
296
Sources/FloatingMonitor/Views/SettingsView.swift
Normal file
@ -0,0 +1,296 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@ObservedObject var config = Configuration.shared
|
||||
@ObservedObject var monitor: SystemMonitor
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// 标题栏
|
||||
HStack {
|
||||
Text("显示设置")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
Spacer()
|
||||
Button(action: { closeWindow() }) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
Divider().background(Color.white.opacity(0.1))
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
// 显示/隐藏开关组
|
||||
toggleGroup
|
||||
|
||||
Divider().background(Color.white.opacity(0.08))
|
||||
|
||||
// 各指标样式
|
||||
styleGroup
|
||||
|
||||
Divider().background(Color.white.opacity(0.08))
|
||||
|
||||
// 进程设置
|
||||
processGroup
|
||||
|
||||
Divider().background(Color.white.opacity(0.08))
|
||||
|
||||
// 其他设置
|
||||
otherGroup
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
|
||||
// 底部关闭
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("完成") { closeWindow() }
|
||||
.keyboardShortcut(.return)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.frame(width: 340, height: 640)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
|
||||
// MARK: - 显示/隐藏
|
||||
|
||||
private var toggleGroup: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("显示内容")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.leading, 4)
|
||||
|
||||
toggleRow("CPU", binding: $config.showCPU, icon: "cpu")
|
||||
toggleRow("GPU", binding: $config.showGPU, icon: "display")
|
||||
toggleRow("内存", binding: $config.showMemory, icon: "memorychip")
|
||||
toggleRow("Swap", binding: $config.showSwap, icon: "arrow.left.arrow.right")
|
||||
toggleRow("进程列表", binding: $config.showTopProcesses, icon: "list.bullet")
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleRow(_ label: String, binding: Binding<Bool>, icon: String) -> some View {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 18)
|
||||
.foregroundColor(binding.wrappedValue ? .accentColor : .secondary.opacity(0.5))
|
||||
Text(label)
|
||||
.font(.system(size: 12))
|
||||
Spacer()
|
||||
Toggle("", isOn: binding)
|
||||
.toggleStyle(.switch)
|
||||
.scaleEffect(0.7)
|
||||
.frame(width: 38)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 样式选择
|
||||
|
||||
private var styleGroup: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("样式 · 行号 · 列位")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.leading, 4)
|
||||
|
||||
styleRow("CPU", style: $config.cpuStyle, row: $config.cpuRow, pos: $config.cpuPos, icon: "cpu")
|
||||
styleRow("GPU", style: $config.gpuStyle, row: $config.gpuRow, pos: $config.gpuPos, icon: "display")
|
||||
styleRow("内存", style: $config.memoryStyle, row: $config.memoryRow, pos: $config.memoryPos, icon: "memorychip")
|
||||
styleRow("Swap", style: $config.swapStyle, row: $config.swapRow, pos: $config.swapPos, icon: "arrow.left.arrow.right")
|
||||
styleRow("进程", style: .constant(.compact), row: $config.processesRow, pos: $config.processesPos,
|
||||
icon: "list.bullet", styleDisabled: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func styleRow(_ label: String, style: Binding<GaugeDisplayStyle>,
|
||||
row: Binding<Int>, pos: Binding<ModulePosition>, icon: String,
|
||||
styleDisabled: Bool = false) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 14)
|
||||
.foregroundColor(.secondary)
|
||||
Text(label)
|
||||
.font(.system(size: 11))
|
||||
.frame(width: 26, alignment: .leading)
|
||||
|
||||
// 行号 stepper
|
||||
Stepper("", value: row, in: 1...10)
|
||||
.labelsHidden()
|
||||
.scaleEffect(0.7)
|
||||
.frame(width: 24)
|
||||
Text("\(row.wrappedValue)")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 14, alignment: .center)
|
||||
|
||||
Spacer()
|
||||
|
||||
if styleDisabled {
|
||||
Text("—")
|
||||
.foregroundColor(.secondary.opacity(0.4))
|
||||
.frame(width: 64)
|
||||
} else {
|
||||
Picker("", selection: style) {
|
||||
ForEach(GaugeDisplayStyle.allCases) { s in
|
||||
Text(s.displayName).tag(s)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 76)
|
||||
}
|
||||
|
||||
Picker("", selection: pos) {
|
||||
ForEach(ModulePosition.allCases) { p in
|
||||
Text(p.displayName).tag(p)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 90)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 进程
|
||||
|
||||
private var processGroup: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("进程列表")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.leading, 4)
|
||||
|
||||
HStack {
|
||||
Text("显示数量")
|
||||
.font(.system(size: 12))
|
||||
Spacer()
|
||||
Picker("", selection: $config.processCount) {
|
||||
ForEach([3, 5, 8, 10], id: \.self) { n in
|
||||
Text("\(n) 个").tag(n)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 160)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 其他
|
||||
|
||||
private var otherGroup: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("其他")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.leading, 4)
|
||||
|
||||
HStack {
|
||||
Text("刷新间隔")
|
||||
.font(.system(size: 12))
|
||||
Spacer()
|
||||
Picker("", selection: Binding<Double>(
|
||||
get: { config.refreshInterval },
|
||||
set: { newValue in
|
||||
config.refreshInterval = newValue
|
||||
NotificationCenter.default.post(
|
||||
name: .refreshIntervalChanged, object: newValue
|
||||
)
|
||||
}
|
||||
)) {
|
||||
Text("0.5s").tag(0.5)
|
||||
Text("1.0s").tag(1.0)
|
||||
Text("1.5s").tag(1.5)
|
||||
Text("2.0s").tag(2.0)
|
||||
Text("3.0s").tag(3.0)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 180)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("透明度")
|
||||
.font(.system(size: 12))
|
||||
.frame(width: 52, alignment: .leading)
|
||||
Slider(value: $config.opacity, in: 0.5...1.0, step: 0.05) {
|
||||
Text("")
|
||||
} onEditingChanged: { editing in
|
||||
if !editing { updateWindowOpacity() }
|
||||
}
|
||||
.frame(width: 130)
|
||||
Text(String(format: "%.0f%%", config.opacity * 100))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 38, alignment: .trailing)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("字体大小")
|
||||
.font(.system(size: 12))
|
||||
.frame(width: 52, alignment: .leading)
|
||||
Slider(value: $config.fontSize, in: 9...16, step: 1) {
|
||||
Text("")
|
||||
}
|
||||
.frame(width: 130)
|
||||
Text(String(format: "%.0f", config.fontSize))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 38, alignment: .trailing)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("窗口宽度")
|
||||
.font(.system(size: 12))
|
||||
.frame(width: 52, alignment: .leading)
|
||||
Slider(value: $config.windowWidth, in: 240...500) {
|
||||
Text("")
|
||||
} onEditingChanged: { editing in
|
||||
if !editing { applyWindowSize() }
|
||||
}
|
||||
.frame(width: 130)
|
||||
Text(String(format: "%.0f", config.windowWidth))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 38, alignment: .trailing)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("窗口高度")
|
||||
.font(.system(size: 12))
|
||||
.frame(width: 52, alignment: .leading)
|
||||
Slider(value: $config.windowHeight, in: 200...700) {
|
||||
Text("")
|
||||
} onEditingChanged: { editing in
|
||||
if !editing { applyWindowSize() }
|
||||
}
|
||||
.frame(width: 130)
|
||||
Text(String(format: "%.0f", config.windowHeight))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 38, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyWindowSize() {
|
||||
NotificationCenter.default.post(
|
||||
name: .resizeMonitorWindow,
|
||||
object: NSSize(width: config.windowWidth, height: config.windowHeight)
|
||||
)
|
||||
}
|
||||
|
||||
private func updateWindowOpacity() {
|
||||
if let window = NSApplication.shared.windows.first(where: { $0 is FloatingWindow }) {
|
||||
window.alphaValue = config.opacity
|
||||
}
|
||||
}
|
||||
|
||||
private func closeWindow() {
|
||||
NSApp.keyWindow?.close()
|
||||
}
|
||||
}
|
||||
9
Sources/FloatingMonitor/main.swift
Normal file
9
Sources/FloatingMonitor/main.swift
Normal file
@ -0,0 +1,9 @@
|
||||
import AppKit
|
||||
|
||||
// ── FloatingMonitor 入口 ──
|
||||
|
||||
let app = NSApplication.shared
|
||||
let delegate = AppDelegate()
|
||||
app.delegate = delegate
|
||||
app.setActivationPolicy(.accessory)
|
||||
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)
|
||||
Loading…
Reference in New Issue
Block a user