{
  "format" : 1,
  "manifest" : {
    "accent" : "green",
    "capabilities" : [
      {
        "run" : {
          "commands" : [
            "top",
            "vm_stat",
            "df",
            "kill"
          ]
        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Monitoring/SystemMonitor",
    "id" : "io.navbytes.stackbar.plugins.system-monitor",
    "minimumStackbar" : "0.17.0",
    "name" : "System Monitor",
    "summary" : "CPU, memory and disk in the menu bar, with the processes using the most.",
    "surfaces" : [
      "menuBar",
      "panel",
      "widget",
      "settings"
    ],
    "symbol" : "gauge.with.dots.needle.67percent",
    "triggers" : [
      {
        "launch" : {

        }
      },
      {
        "every" : {
          "seconds" : 10
        }
      },
      {
        "wake" : {

        }
      }
    ],
    "version" : "1.3.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "3eRqYcOzzo/qshHVvJDctZ2nwEHmFl2BI7HFwA/Ibp0l39GTEk6UJMBe7mBWdxeNl4DOfKb2+4k4bsgAvJ0mBw==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// CPU, memory and disk in the menu bar, with the processes using the most.\n///\n/// Every 10 seconds it runs `top` once for two one-second samples (the\n/// second is the accurate one), `vm_stat` for memory the way Activity\n/// Monitor counts it (app memory, wired and compressed, not the file cache)\n/// and `df` for the disk, through Stackbar. Nothing runs in a shell.\n@main\nfinal class SystemMonitor: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.plugins.system-monitor\",\n        name: \"System Monitor\",\n        version: \"1.3.0\",\n        summary: \"CPU, memory and disk in the menu bar, with the processes using the most.\",\n        surfaces: [.menuBar, .panel, .widget, .settings],\n        triggers: [.launch, .every(.seconds(10)), .wake],\n        capabilities: [.run(commands: [\"top\", \"vm_stat\", \"df\", \"kill\"])],\n        minimumStackbar: \"0.17.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Monitoring/SystemMonitor\",\n        accent: .green,\n        symbol: \"gauge.with.dots.needle.67percent\"\n    )\n\n    // MARK: Previews\n\n    static let previews: [PreviewScene<SystemMonitor>] = [\n        PreviewScene(\"hero\", \"CPU, memory and disk at a glance\") { plugin in\n            plugin.sample = samples\n            plugin.disk = Disk(used: 612e9, total: 994e9)\n            plugin.cpuHistory = [18, 22, 31, 27, 24, 41, 38, 33, 29, 35, 52, 47, 39, 34, 30, 28, 36, 44, 40, 34]\n        },\n        PreviewScene(\"memory\", \"Find what is eating your memory\") { plugin in\n            plugin.sortBy = \"Memory\"\n            plugin.sample = samples\n            let order = [\"Xcode\", \"Docker\", \"Safari\", \"Figma\", \"Slack\", \"WindowServer\", \"Music\", \"Stackbar\"]\n            plugin.sample?.processes.sort { order.firstIndex(of: $0.command) ?? 0 < order.firstIndex(of: $1.command) ?? 0 }\n            plugin.disk = Disk(used: 612e9, total: 994e9)\n        },\n        PreviewScene(\"widget\", \"Your Mac's load on the desktop\", surface: .widget) { plugin in\n            plugin.sample = samples\n            plugin.disk = Disk(used: 612e9, total: 994e9)\n            plugin.cpuHistory = [18, 22, 31, 27, 24, 41, 38, 33, 29, 35, 52, 47, 39, 34, 30, 28, 36, 44, 40, 34]\n        },\n    ]\n\n    /// A busy afternoon on a 32 GB Mac.\n    static let samples = Sample(cpu: 34, memoryUsed: 21.4 * 1_073_741_824, memoryTotal: 32 * 1_073_741_824, load: [3.12, 2.87, 2.40], processes: [\n        ProcessRow(pid: 812, command: \"Xcode\", cpu: 48.2, memory: \"4.1G\"),\n        ProcessRow(pid: 1204, command: \"Safari\", cpu: 21.7, memory: \"2.6G\"),\n        ProcessRow(pid: 977, command: \"Slack\", cpu: 9.4, memory: \"1.2G\"),\n        ProcessRow(pid: 401, command: \"WindowServer\", cpu: 7.9, memory: \"820M\"),\n        ProcessRow(pid: 1530, command: \"Docker\", cpu: 6.1, memory: \"3.3G\"),\n        ProcessRow(pid: 2210, command: \"Music\", cpu: 2.3, memory: \"410M\"),\n        ProcessRow(pid: 1388, command: \"Figma\", cpu: 1.8, memory: \"1.9G\"),\n        ProcessRow(pid: 166, command: \"Stackbar\", cpu: 0.6, memory: \"96M\"),\n    ])\n\n    struct Sample: Equatable {\n        var cpu: Double = 0\n        var memoryUsed: Double = 0\n        var memoryTotal: Double = 0\n        var load: [Double] = []\n        var processes: [ProcessRow] = []\n        var memoryFraction: Double { memoryTotal > 0 ? memoryUsed / memoryTotal : 0 }\n    }\n\n    struct ProcessRow: Equatable, Identifiable {\n        var pid: Int\n        var command: String\n        var cpu: Double\n        var memory: String\n        var id: Int { pid }\n    }\n\n    struct Disk: Equatable {\n        var used: Double\n        var total: Double\n        var fraction: Double { total > 0 ? used / total : 0 }\n    }\n\n    var sample: Sample?\n    var disk: Disk?\n    var cpuHistory: [Double] = []\n    var problem: String?\n\n    @Stored(\"menuBarShows\") var menuBarShows = \"CPU and memory\"\n    @Stored(\"sortBy\") var sortBy = \"CPU\"\n\n    func refresh() async throws {\n        await measure()\n    }\n\n    func measure() async {\n        do {\n            let order = sortBy == \"Memory\" ? \"mem\" : \"cpu\"\n            let result = try await Stackbar.run(\"top\", [\"-l\", \"2\", \"-s\", \"1\", \"-n\", \"8\", \"-o\", order, \"-stats\", \"pid,command,cpu,mem\"], timeout: .seconds(15))\n            guard result.succeeded, var parsed = Self.parseTop(result.text) else {\n                problem = result.succeeded ? \"Couldn't read top's output\" : \"top failed (\\(result.status)): \\(result.errorText.prefix(160))\"\n                return\n            }\n            if let vm = try? await Stackbar.run(\"vm_stat\", timeout: .seconds(10)), vm.succeeded, let used = Self.parseVMStat(vm.text) {\n                parsed.memoryUsed = min(used, parsed.memoryTotal)\n            }\n            sample = parsed\n            cpuHistory = Array((cpuHistory + [parsed.cpu]).suffix(30))\n            problem = nil\n        } catch {\n            problem = \"\\(error)\"\n        }\n        disk = await Self.readDisk()\n    }\n\n    static func readDisk() async -> Disk? {\n        for path in [\"/System/Volumes/Data\", \"/\"] {\n            guard let result = try? await Stackbar.run(\"df\", [\"-k\", path], timeout: .seconds(10)), result.succeeded else { continue }\n            if let disk = parseDF(result.text) { return disk }\n        }\n        return nil\n    }\n\n    /// Memory Used as Activity Monitor shows it: app memory (anonymous\n    /// pages that can't be purged), wired and compressed.\n    static func parseVMStat(_ output: String) -> Double? {\n        var pageSize = 4096.0\n        var pages: [String: Double] = [:]\n        for line in output.split(separator: \"\\n\") {\n            if let range = line.range(of: \"page size of \") {\n                pageSize = Double(line[range.upperBound...].prefix { $0.isNumber }) ?? pageSize\n                continue\n            }\n            let parts = line.split(separator: \":\", maxSplits: 1)\n            guard parts.count == 2 else { continue }\n            pages[parts[0].trimmingCharacters(in: .whitespaces)] = Double(parts[1].trimmingCharacters(in: CharacterSet(charactersIn: \" .\")))\n        }\n        guard let anonymous = pages[\"Anonymous pages\"], let wired = pages[\"Pages wired down\"] else { return nil }\n        let app = max(0, anonymous - (pages[\"Pages purgeable\"] ?? 0))\n        return (app + wired + (pages[\"Pages occupied by compressor\"] ?? 0)) * pageSize\n    }\n\n    /// `df -k`: the second line's blocks, used and available, in KiB.\n    static func parseDF(_ output: String) -> Disk? {\n        let lines = output.split(separator: \"\\n\")\n        guard lines.count >= 2 else { return nil }\n        let fields = lines[1].split(separator: \" \", omittingEmptySubsequences: true)\n        guard fields.count >= 4, let used = Double(fields[2]), let available = Double(fields[3]) else { return nil }\n        // used + available, not the block count, which includes space\n        // reserved for other APFS volumes.\n        return Disk(used: used * 1024, total: (used + available) * 1024)\n    }\n\n    /// Reads the last of top's samples.\n    static func parseTop(_ output: String) -> Sample? {\n        let samples = output.components(separatedBy: \"Processes:\")\n        guard samples.count >= 2, let last = samples.last else { return nil }\n        var sample = Sample()\n        var foundCPU = false\n        var inTable = false\n        for raw in last.split(separator: \"\\n\") {\n            let line = raw.trimmingCharacters(in: .whitespaces)\n            if line.hasPrefix(\"CPU usage:\") {\n                // CPU usage: 7.69% user, 10.25% sys, 82.5% idle\n                let numbers = percentages(in: line)\n                if numbers.count >= 3 {\n                    sample.cpu = min(100, max(0, 100 - numbers[2]))\n                    foundCPU = true\n                }\n            } else if line.hasPrefix(\"Load Avg:\") {\n                sample.load = line.dropFirst(\"Load Avg:\".count).split(separator: \",\").compactMap { Double($0.trimmingCharacters(in: .whitespaces)) }\n            } else if line.hasPrefix(\"PhysMem:\") {\n                // PhysMem: 15G used (2101M wired, 3240M compressor), 140M unused.\n                let words = line.split(separator: \" \")\n                for (index, word) in words.enumerated() where index > 0 {\n                    let next = words.indices.contains(index + 1) ? words[index + 1] : \"\"\n                    if next.hasPrefix(\"used\") { sample.memoryUsed = bytes(String(word)) ?? 0 }\n                    if next.hasPrefix(\"unused\") { sample.memoryTotal = sample.memoryUsed + (bytes(String(word)) ?? 0) }\n                }\n            } else if line.hasPrefix(\"PID\") {\n                inTable = true\n            } else if inTable {\n                let fields = line.split(separator: \" \", omittingEmptySubsequences: true)\n                guard fields.count >= 4, let pid = Int(fields[0]), let cpu = Double(fields[fields.count - 2]) else { continue }\n                let command = fields[1..<(fields.count - 2)].joined(separator: \" \")\n                sample.processes.append(ProcessRow(pid: pid, command: command, cpu: cpu, memory: String(fields[fields.count - 1])))\n            }\n        }\n        return foundCPU ? sample : nil\n    }\n\n    static func percentages(in line: String) -> [Double] {\n        line.split(whereSeparator: { $0 == \" \" || $0 == \",\" }).compactMap { word in\n            word.hasSuffix(\"%\") ? Double(word.dropLast()) : nil\n        }\n    }\n\n    /// `15G`, `2101M`, `512K`, `100B` (top's units are binary).\n    static func bytes(_ text: String) -> Double? {\n        let trimmed = text.trimmingCharacters(in: CharacterSet(charactersIn: \"+-.,()\"))\n        guard let unit = trimmed.last, let value = Double(trimmed.dropLast()) else { return Double(trimmed) }\n        switch unit {\n        case \"T\": return value * pow(1024, 4)\n        case \"G\": return value * pow(1024, 3)\n        case \"M\": return value * pow(1024, 2)\n        case \"K\": return value * 1024\n        case \"B\": return value\n        default: return Double(trimmed)\n        }\n    }\n\n    /// top's `1129M-` or `6337K+` as `1.1 GB` or `6 MB`.\n    static func memory(_ text: String) -> String {\n        guard let bytes = bytes(text) else { return text }\n        let megabytes = bytes / pow(1024, 2)\n        return megabytes >= 1000 ? String(format: \"%.1f GB\", megabytes / 1024) : String(format: \"%.0f MB\", max(1, megabytes))\n    }\n\n    static func size(_ bytes: Double) -> String {\n        let gigabytes = bytes / pow(1024, 3)\n        return gigabytes >= 10 || gigabytes == gigabytes.rounded() ? String(format: \"%.0f GB\", gigabytes) : String(format: \"%.1f GB\", gigabytes)\n    }\n\n    /// The menu bar stays quiet until something needs a look.\n    /// `9.8`: gigabytes without the unit, for \"9.8 of 16 GB\".\n    static func number(_ bytes: Double) -> String {\n        let gigabytes = bytes / pow(1024, 3)\n        return gigabytes >= 100 ? String(format: \"%.0f\", gigabytes) : String(format: \"%.1f\", gigabytes)\n    }\n\n    static func role(_ fraction: Double) -> Role {\n        fraction >= 0.9 ? .critical : fraction >= 0.75 ? .warning : .primary\n    }\n\n    // MARK: Look\n\n    let theme = Theme.green\n\n    /// Green while there is room, amber when it's getting full, red when it's not.\n    func tone(_ fraction: Double, calm: Theme? = nil) -> Theme {\n        fraction >= 0.9 ? .critical : fraction >= 0.75 ? .warning : calm ?? theme\n    }\n\n    /// Each resource keeps its own ring color until it needs a look.\n    func rings(_ sample: Sample) -> [RingCluster.Ring] {\n        var rings = [\n            RingCluster.Ring(\"CPU\", value: sample.cpu / 100, theme: tone(sample.cpu / 100)),\n            RingCluster.Ring(\"Memory\", value: sample.memoryFraction, theme: tone(sample.memoryFraction, calm: .cyan)),\n        ]\n        if let disk { rings.append(RingCluster.Ring(\"Disk\", value: disk.fraction, theme: tone(disk.fraction, calm: .violet))) }\n        return rings\n    }\n\n    static func percent(_ fraction: Double) -> String { \"\\(Int((fraction * 100).rounded()))%\" }\n\n    /// What a glance should take away: the two readings, or the one in trouble.\n    func headline(_ sample: Sample) -> String {\n        if sample.cpu >= 75 { return \"CPU busy: \\(Self.percent(sample.cpu / 100))\" }\n        if sample.memoryFraction >= 0.9 { return \"Memory nearly full: \\(Self.percent(sample.memoryFraction))\" }\n        if let disk, disk.fraction >= 0.9 { return \"Disk nearly full: \\(Self.size(disk.total - disk.used)) free\" }\n        return \"CPU \\(Self.percent(sample.cpu / 100)) · Memory \\(Self.percent(sample.memoryFraction))\"\n    }\n\n    // MARK: Surfaces\n\n    var menuBar: ViewContent {\n        if let sample {\n            switch menuBarShows {\n            case \"CPU\": Label(Self.percent(sample.cpu / 100), symbol: \"cpu\").role(Self.role(sample.cpu / 100))\n            case \"Memory\": Label(Self.percent(sample.memoryFraction), symbol: \"memorychip\").role(Self.role(sample.memoryFraction))\n            default: Label(\"\\(Self.percent(sample.cpu / 100)) · \\(Self.percent(sample.memoryFraction))\", symbol: \"gauge.with.dots.needle.33percent\").role(Self.role(max(sample.cpu / 100, sample.memoryFraction)))\n            }\n        } else {\n            Label(\"\", symbol: \"gauge.with.dots.needle.33percent\").role(.secondary)\n        }\n    }\n\n    var panel: ViewContent {\n        if let problem, sample == nil {\n            EmptyState(\"Can't read the system\", symbol: \"exclamationmark.triangle\", message: problem, theme: .critical) {\n                ActionButton(\"Try Again\", symbol: \"arrow.clockwise\", theme: theme, prominent: true) { await self.measure() }\n            }\n        } else if let sample {\n            VStack(spacing: Space.m) {\n                PanelHeader(headline(sample), subtitle: sample.load.count == 3 ? \"Load \" + sample.load.map { String(format: \"%.2f\", $0) }.joined(separator: \"  \") : nil, symbol: \"gauge.with.dots.needle.33percent\", theme: theme)\n                HeroCard(theme: theme, look: .glow) {\n                    HStack(spacing: Space.l) {\n                        RingCluster(rings(sample), size: 116, lineWidth: 11, gap: 3)\n                        VStack(spacing: Space.s) {\n                            legend(\"CPU\", value: Self.percent(sample.cpu / 100), detail: \"busy\", theme: tone(sample.cpu / 100))\n                            legend(\"Memory\", value: Self.percent(sample.memoryFraction), detail: \"\\(Self.number(sample.memoryUsed)) of \\(Self.size(sample.memoryTotal))\", theme: tone(sample.memoryFraction, calm: .cyan))\n                            if let disk { legend(\"Disk\", value: Self.percent(disk.fraction), detail: \"\\(Self.size(disk.total - disk.used)) free\", theme: tone(disk.fraction, calm: .violet)) }\n                        }\n                        .frame(fillWidth: true)\n                    }\n                }\n                if cpuHistory.count >= 3 {\n                    Card(\"CPU\", detail: cpuHistory.count >= 30 ? \"last 5 min\" : \"since \\(cpuHistory.count * 10 / 60 < 1 ? \"\\(cpuHistory.count * 10) s\" : \"\\(cpuHistory.count * 10 / 60) min\") ago\") {\n                        Sparkline(cpuHistory, theme: tone(sample.cpu / 100), height: 48, label: \"CPU over time\")\n                        HStack {\n                            Text(\"Peak \\(Self.percent((cpuHistory.max() ?? 0) / 100))\").role(.secondary).typography(.captionNumber)\n                            Spacer()\n                            Text(\"Average \\(Self.percent(cpuHistory.reduce(0, +) / Double(cpuHistory.count) / 100))\").role(.secondary).typography(.captionNumber)\n                        }\n                    }\n                }\n                VStack(spacing: Space.xs) {\n                    HStack(spacing: Space.s) {\n                        CardTitle(\"Top processes\")\n                        Picker(\"Sort by\", options: [\"CPU\", \"Memory\"], selection: sortBy, style: .segmented) { choice in\n                            self.sortBy = choice\n                            await self.measure()\n                        }\n                        .frame(width: 150)\n                    }\n                    ForEach(sample.processes.prefix(8)) { process in\n                        Row(process.command, subtitle: \"pid \\(process.pid) · \\(Self.memory(process.memory))\", symbol: \"gearshape\", key: \"\\(process.pid)\")\n                            .badge(sortBy == \"Memory\" ? Self.memory(process.memory) : String(format: \"%.1f%%\", process.cpu), role: process.cpu > 80 ? .warning : .secondary)\n                            .action(\"Copy PID\") { Stackbar.copy(\"\\(process.pid)\") }\n                            .action(\"Quit Process\", role: .critical) { try await self.quit(process) }\n                    }\n                }\n            }\n        } else {\n            VStack(spacing: Space.m) {\n                PanelHeader(\"Measuring…\", subtitle: \"The first reading takes a couple of seconds\", symbol: \"gauge.with.dots.needle.33percent\", theme: theme)\n                HeroCard(theme: theme, look: .glow, alignment: .center) {\n                    RingCluster([.init(\"CPU\", value: 0, theme: theme), .init(\"Memory\", value: 0, theme: .cyan), .init(\"Disk\", value: 0, theme: .violet)], size: 116, lineWidth: 11) {\n                        Image(systemName: \"gauge.with.dots.needle.33percent\").font(size: 20, weight: .semibold).foreground(theme.textPaint).symbolEffect(.pulse)\n                    }\n                }\n            }\n        }\n    }\n\n    /// One ring's key: its color, the reading large, what it means small.\n    @ViewBuilder func legend(_ name: String, value: String, detail: String, theme: Theme) -> ViewContent {\n        HStack(spacing: Space.s) {\n            Capsule().fill(theme.sweep).frame(width: 4, height: 30)\n            VStack(spacing: 0) {\n                Text(name).role(.secondary).typography(.caption)\n                HStack(alignment: .end, spacing: Space.xs) {\n                    Text(value).typography(.stat).numericTransition()\n                    if !detail.isEmpty { Text(detail).role(.secondary).typography(.caption) }\n                }\n            }\n            Spacer(minLength: 0)\n        }\n        .accessibilityLabel(\"\\(name) \\(value), \\(detail)\")\n    }\n\n    var widget: ViewContent {\n        if let sample {\n            HStack(spacing: Space.m) {\n                RingCluster(rings(sample), size: 96, lineWidth: 9, gap: 2)\n                VStack(spacing: Space.xs) {\n                    legend(\"CPU\", value: Self.percent(sample.cpu / 100), detail: \"\", theme: tone(sample.cpu / 100))\n                    if cpuHistory.count >= 3 { Sparkline(cpuHistory, theme: tone(sample.cpu / 100), height: 22) }\n                    HStack(spacing: Space.m) {\n                        Text(\"Mem \\(Self.percent(sample.memoryFraction))\").typography(.captionNumber)\n                        if let disk { Text(\"Disk \\(Self.percent(disk.fraction))\").typography(.captionNumber) }\n                    }\n                }\n                .frame(fillWidth: true)\n            }\n            .frame(fillWidth: true, fillHeight: true)\n        } else {\n            Text(\"Measuring…\").style(.caption)\n        }\n    }\n\n    func quit(_ process: ProcessRow) async throws {\n        let result = try await Stackbar.run(\"kill\", [\"-TERM\", \"\\(process.pid)\"], timeout: .seconds(10))\n        guard result.succeeded else {\n            throw HostError(\"Couldn't quit \\(process.command): \" + (result.errorText.contains(\"not permitted\") ? \"it belongs to another user\" : result.errorText))\n        }\n        await measure()\n    }\n\n    var settings: ViewContent {\n        Section(\"Menu bar\") {\n            Picker(\"Show\", options: [\"CPU and memory\", \"CPU\", \"Memory\"], selection: menuBarShows, style: .segmented) { self.menuBarShows = $0 }\n        }\n    }\n\n    var tools: [AgentTool] {\n        AgentTool(\"usage\", description: \"CPU, memory and disk use now, and the busiest processes\") { _ in\n            guard let sample = self.sample else { return self.problem ?? \"not measured yet\" }\n            var lines = [\n                \"cpu: \\(Int(sample.cpu.rounded()))%\",\n                \"memory: \\(Self.size(sample.memoryUsed)) of \\(Self.size(sample.memoryTotal))\",\n            ]\n            if let disk = self.disk { lines.append(\"disk: \\(Self.size(disk.used)) of \\(Self.size(disk.total))\") }\n            lines += sample.processes.map { \"\\($0.pid) \\($0.command) \\($0.cpu)% cpu \\($0.memory)\" }\n            return lines.joined(separator: \"\\n\")\n        }\n    }\n}\n",
  "sourceDigest" : "11c7021020467a6842dc724923408d9c341dc65bcd41982ca090876165f80485"
}