Initial: FloatingMonitor — macOS 悬浮系统监控

- 实时 CPU/GPU/内存/Swap 监控 + Top 进程
- 5 种计量器样式(圆环/条状/纯文本/扇形/紧凑)
- 半透明悬浮窗 + 菜单栏图标控制
- 独立设置窗口,每模块可指定行号+列位布局
- 窗口宽高/透明度/字体全局可调
This commit is contained in:
JOJO 2026-05-05 21:26:44 +08:00
commit 4ab9a7b28c
13 changed files with 1683 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.build/
dmg_build/
*.dmg
.DS_Store

16
Package.swift Normal file
View 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: []
)
]
)

View 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
}
}

View 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
}
}

View 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
))
}
}

View 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)
}
}

View 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)
}
}

View 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)
}
}
}

View 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
}
}

View 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
}
}

View 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)
}
}

View 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()
}
}

View 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)