summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-14 12:32:54 +0200
committerPaul Buetow <paul@buetow.org>2026-02-14 12:33:40 +0200
commit50733fe4ebac28136144d5b85721ee5fd0b7850a (patch)
tree6546058f9ea41b81502e833c50ab32ef96718ec8
parent52e70e2a065da95cdfcf7d370173003d3ce395cd (diff)
Add macOS support with automatic window activation
This commit adds full macOS support for loadbars, allowing it to run natively on macOS for both localhost monitoring and remote Linux hosts. Key changes: - Embed both Linux and Darwin monitoring scripts in the binary - Auto-detect localhost OS and use appropriate script - Darwin script uses native macOS tools (sysctl, vm_stat, netstat, iostat) - Remote hosts always use Linux script (assumes /proc filesystem) - Automatic window activation on macOS using build tags - No external helper scripts needed The binary now works seamlessly on macOS: - localhost monitoring uses macOS-specific commands - Remote Linux hosts work via SSH with Linux script - SDL window automatically comes to foreground on macOS - Cross-platform build with single binary for all scenarios Technical implementation: - internal/collector/script.go: Embeds both scripts - internal/collector/loadbars-remote-darwin.sh: macOS monitoring - internal/collector/loadbars-remote.sh: Linux monitoring (copied from scripts/) - internal/display/activate_darwin.go: macOS window activation - internal/display/activate.go: No-op for other platforms - Updated README.md with macOS installation instructions - Added MACOS.md with detailed macOS documentation
-rw-r--r--.gitignore1
-rw-r--r--MACOS.md71
-rw-r--r--README.md15
-rw-r--r--internal/collector/collector.go19
-rw-r--r--internal/collector/loadbars-remote-darwin.sh77
-rw-r--r--internal/collector/loadbars-remote.sh35
-rw-r--r--internal/collector/script.go13
-rw-r--r--internal/display/activate.go8
-rw-r--r--internal/display/activate_darwin.go24
-rw-r--r--internal/display/display.go3
10 files changed, 263 insertions, 3 deletions
diff --git a/.gitignore b/.gitignore
index e9293f1..aa902ff 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
.serena/
/loadbars
+.claude/
diff --git a/MACOS.md b/MACOS.md
new file mode 100644
index 0000000..ba6efda
--- /dev/null
+++ b/MACOS.md
@@ -0,0 +1,71 @@
+# macOS Support for Loadbars
+
+## What was implemented
+
+Loadbars now fully supports macOS with automatic window activation built into the binary.
+
+### Changes made:
+
+1. **Script embedding** - Both Linux and Darwin monitoring scripts are embedded in the binary
+2. **OS detection** - Automatically uses the correct script based on the host:
+ - `localhost` on macOS → Darwin script (sysctl, vm_stat, netstat, iostat)
+ - `localhost` on Linux → Linux script (/proc filesystem)
+ - All remote hosts → Linux script (assumes remote servers are Linux)
+3. **Window activation** - macOS-specific code automatically brings SDL window to foreground
+ - Uses build tags (`activate_darwin.go` and `activate.go`)
+ - No external helper script needed
+
+## Usage on macOS
+
+Simply run the binary directly:
+
+```bash
+# Monitor localhost
+./loadbars --showcores --showmem --shownet
+
+# Monitor remote Linux servers
+./loadbars server1.example.com server2.example.com --showcores
+
+# Monitor both localhost and remotes
+./loadbars localhost server1.example.com server2.example.com --showcores
+```
+
+The window will automatically appear in the foreground.
+
+## Building on macOS
+
+Requirements:
+```bash
+brew install sdl2
+```
+
+Build:
+```bash
+mage build
+# or
+go build -o loadbars ./cmd/loadbars
+```
+
+## Technical details
+
+### Script selection logic
+- `internal/collector/script.go` - Embeds both scripts
+- `internal/collector/collector.go` - Selects script based on host:
+ - For localhost: checks if `/proc` exists to determine Linux vs macOS
+ - For remote hosts: always uses Linux script
+
+### Window activation
+- `internal/display/activate_darwin.go` - macOS-specific activation using `open -a`
+- `internal/display/activate.go` - No-op for other platforms
+- Called automatically after SDL window creation
+
+### Darwin monitoring script
+- Uses macOS native tools: `sysctl`, `vm_stat`, `netstat -ibn`, `iostat`
+- Outputs same protocol format as Linux script (M LOADAVG, M MEMSTATS, etc.)
+- Limitations: No per-core CPU stats on macOS (iostat limitation)
+
+## Known limitations
+
+- Remote macOS hosts are not supported (assumes all remote hosts are Linux)
+- macOS script doesn't provide per-core CPU statistics (iostat limitation)
+- Swap usage on macOS always shows 0 (macOS uses compressed memory differently)
diff --git a/README.md b/README.md
index 5f3f938..d06873a 100644
--- a/README.md
+++ b/README.md
@@ -10,8 +10,11 @@ loadbars [LIST OF HOSTNAMES] [OPTIONS]
### Tested platforms
-This version of loadbars has been tested on Fedora Linux 43 and should work on
-most modern Linux distributions (RHEL, CentOS, Ubuntu, Debian, etc.).
+This version of loadbars has been tested on:
+- Fedora Linux 43 and most modern Linux distributions (RHEL, CentOS, Ubuntu, Debian, etc.)
+- macOS (Darwin) - localhost monitoring uses native macOS tools (sysctl, vm_stat, netstat, iostat)
+
+**Note:** Remote hosts are assumed to be Linux (using /proc filesystem). When running on macOS, localhost monitoring uses macOS-specific commands, while remote hosts use the Linux script.
### I like flying elephants
@@ -102,6 +105,14 @@ On Ubuntu/Debian:
sudo apt install libsdl2-dev
```
+On macOS:
+
+```bash
+brew install sdl2
+```
+
+**macOS Note:** The window will automatically come to the foreground when launched. No additional scripts needed.
+
### Running from Source
To run loadbars directly from the source directory:
diff --git a/internal/collector/collector.go b/internal/collector/collector.go
index dea88c7..cab9df0 100644
--- a/internal/collector/collector.go
+++ b/internal/collector/collector.go
@@ -25,7 +25,14 @@ type StatsStore interface {
// The script is embedded in the binary; no external script file is required.
func Run(ctx context.Context, host string, cfg *config.Config, store StatsStore) error {
hostKey, user := splitHostUser(host)
- script := bytes.NewReader(RemoteScript)
+
+ // Select script: Darwin for localhost on macOS, Linux for everything else (all remotes are Linux)
+ scriptBytes := LinuxScript
+ if isLocal(hostKey) {
+ scriptBytes = getLocalScript()
+ }
+
+ script := bytes.NewReader(scriptBytes)
var scanner *bufio.Scanner
if isLocal(hostKey) {
cmd := exec.CommandContext(ctx, "bash", "-s")
@@ -121,3 +128,13 @@ func splitHostUser(host string) (h, u string) {
func isLocal(h string) bool {
return h == "localhost" || h == "127.0.0.1"
}
+
+// getLocalScript returns the appropriate script for the local OS
+func getLocalScript() []byte {
+ // Check if /proc exists (Linux/Unix)
+ if _, err := exec.Command("test", "-d", "/proc").CombinedOutput(); err == nil {
+ return LinuxScript
+ }
+ // Otherwise assume macOS
+ return DarwinScript
+}
diff --git a/internal/collector/loadbars-remote-darwin.sh b/internal/collector/loadbars-remote-darwin.sh
new file mode 100644
index 0000000..f82c802
--- /dev/null
+++ b/internal/collector/loadbars-remote-darwin.sh
@@ -0,0 +1,77 @@
+#!/bin/bash
+# loadbars-remote-darwin.sh - macOS version using sysctl, vm_stat, netstat, and iostat
+# Emits loadbars protocol (M LOADAVG, M MEMSTATS, M NETSTATS, M CPUSTATS)
+# Interval for CPU sampling (seconds)
+INTERVAL=0.14
+
+# Get number of CPUs
+NCPU=$(sysctl -n hw.ncpu)
+
+while true; do
+ # Load average: from sysctl
+ echo "M LOADAVG"
+ sysctl -n vm.loadavg 2>/dev/null | awk '{print $2";"$3";"$4}' || echo "0;0;0"
+
+ # Memory: convert vm_stat output to /proc/meminfo-like format
+ echo "M MEMSTATS"
+ vm_stat 2>/dev/null | awk '
+ BEGIN { pagesize = 4096 }
+ /page size of ([0-9]+)/ { pagesize = $8 }
+ /Pages free:/ { free = $3 * pagesize / 1024 }
+ /Pages active:/ { active = $3 * pagesize / 1024 }
+ /Pages inactive:/ { inactive = $3 * pagesize / 1024 }
+ /Pages speculative:/ { speculative = $3 * pagesize / 1024 }
+ /Pages wired down:/ { wired = $4 * pagesize / 1024 }
+ /Pages occupied by compressor:/ { compressed = $5 * pagesize / 1024 }
+ END {
+ total = free + active + inactive + speculative + wired + compressed
+ printf "MemTotal: %d kB\n", total
+ printf "MemFree: %d kB\n", free
+ printf "MemAvailable: %d kB\n", free + inactive + speculative
+ printf "SwapTotal: 0 kB\n"
+ printf "SwapFree: 0 kB\n"
+ }
+ '
+
+ # Network: use netstat -ibn for interface stats
+ echo "M NETSTATS"
+ netstat -ibn 2>/dev/null | awk '
+ NR > 1 && $1 !~ /^Name/ && $3 ~ /^<Link/ {
+ # netstat -ibn output on macOS:
+ # Name Mtu Network Address Ipkts Ierrs Ibytes Opkts Oerrs Obytes Coll
+ iface = $1
+ ipkts = $5
+ ierrs = $6
+ ibytes = $7
+ opkts = $8
+ oerrs = $9
+ obytes = $10
+
+ if (ibytes ~ /^[0-9]+$/ && obytes ~ /^[0-9]+$/) {
+ printf "%s:b=%s;tb=%s;p=%s;tp=%s e=%s;te=%s;d=0;td=0\n",
+ iface, ibytes, obytes, ipkts, opkts, ierrs, oerrs
+ }
+ }
+ '
+
+ # CPU: macOS doesn't have /proc/stat, use iostat for CPU percentages
+ echo "M CPUSTATS"
+ for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
+ # iostat output on macOS: user sys idle
+ # Convert to /proc/stat format: cpu user nice system idle iowait irq softirq steal guest guest_nice
+ # We'll simulate values since macOS doesn't provide all fields
+ iostat -c 1 2>/dev/null | tail -1 | awk -v ncpu="$NCPU" '
+ {
+ # iostat columns: %user %nice %sys %idle (approximately)
+ # Multiply by ncpu to get total ticks (simulated)
+ user = int($3 * ncpu * 10)
+ sys = int($4 * ncpu * 10)
+ idle = int($5 * ncpu * 10)
+
+ # Output in /proc/stat format
+ printf "cpu %d 0 %d %d 0 0 0 0 0 0\n", user, sys, idle
+ }
+ '
+ sleep "$INTERVAL" 2>/dev/null || true
+ done
+done
diff --git a/internal/collector/loadbars-remote.sh b/internal/collector/loadbars-remote.sh
new file mode 100644
index 0000000..9037ad8
--- /dev/null
+++ b/internal/collector/loadbars-remote.sh
@@ -0,0 +1,35 @@
+#!/bin/bash
+# loadbars-remote.sh - Emits loadbars protocol (M LOADAVG, M MEMSTATS, M NETSTATS, M CPUSTATS)
+# for local or remote execution. No Perl required.
+# Usage: bash loadbars-remote.sh
+# Interval for CPU sampling (seconds)
+INTERVAL=0.14
+
+while true; do
+ # Load average: first 3 fields of /proc/loadavg joined by ;
+ echo "M LOADAVG"
+ read -r l1 l5 l15 _ < /proc/loadavg 2>/dev/null || true
+ echo "${l1:-0};${l5:-0};${l15:-0}"
+
+ # Memory: full /proc/meminfo
+ echo "M MEMSTATS"
+ cat /proc/meminfo 2>/dev/null || true
+
+ # Network: /proc/net/dev, skip 2 header lines, then "iface: rx... tx..."
+ echo "M NETSTATS"
+ while IFS= read -r line; do
+ line="${line/:/ }"
+ set -- $line
+ # $1=iface, $2=rx_bytes $3=rx_packets $4=rx_errs $5=rx_drop ... $10=tx_bytes $11=tx_packets $12=tx_errs $13=tx_drop
+ if [ -n "$2" ] || [ -n "${10:-}" ]; then
+ echo "$1:b=${2:-0};tb=${10:-0};p=${3:-0};tp=${11:-0} e=${4:-0};te=${12:-0};d=${5:-0};td=${13:-0}"
+ fi
+ done < <(tail -n +3 /proc/net/dev 2>/dev/null)
+
+ # CPU: /proc/stat, 20 times with INTERVAL sleep
+ echo "M CPUSTATS"
+ for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
+ cat /proc/stat 2>/dev/null || true
+ sleep "$INTERVAL" 2>/dev/null || true
+ done
+done
diff --git a/internal/collector/script.go b/internal/collector/script.go
new file mode 100644
index 0000000..3be2190
--- /dev/null
+++ b/internal/collector/script.go
@@ -0,0 +1,13 @@
+package collector
+
+import _ "embed"
+
+// LinuxScript contains the embedded loadbars-remote.sh script for Linux hosts
+//
+//go:embed loadbars-remote.sh
+var LinuxScript []byte
+
+// DarwinScript contains the embedded loadbars-remote-darwin.sh script for macOS hosts
+//
+//go:embed loadbars-remote-darwin.sh
+var DarwinScript []byte
diff --git a/internal/display/activate.go b/internal/display/activate.go
new file mode 100644
index 0000000..b9040d7
--- /dev/null
+++ b/internal/display/activate.go
@@ -0,0 +1,8 @@
+// +build !darwin
+
+package display
+
+// activateWindow is a no-op on non-macOS platforms
+func activateWindow() {
+ // Nothing needed on Linux/other platforms
+}
diff --git a/internal/display/activate_darwin.go b/internal/display/activate_darwin.go
new file mode 100644
index 0000000..54c6b94
--- /dev/null
+++ b/internal/display/activate_darwin.go
@@ -0,0 +1,24 @@
+// +build darwin
+
+package display
+
+import (
+ "os"
+ "os/exec"
+ "time"
+)
+
+// activateWindow brings the SDL window to the foreground on macOS
+func activateWindow() {
+ // Give SDL a moment to create the window
+ go func() {
+ time.Sleep(500 * time.Millisecond)
+ // Get the executable path
+ execPath, err := os.Executable()
+ if err != nil {
+ return
+ }
+ // Use open -a to bring the window to foreground
+ exec.Command("open", "-a", execPath).Run()
+ }()
+}
diff --git a/internal/display/display.go b/internal/display/display.go
index 231b8c6..10a0b04 100644
--- a/internal/display/display.go
+++ b/internal/display/display.go
@@ -87,6 +87,9 @@ func Run(ctx context.Context, cfg *config.Config, src stats.Source) error {
defer renderer.Destroy()
window.SetTitle(title)
+ // On macOS, bring the window to the foreground
+ activateWindow()
+
state := newRunState(cfg, int32(width), int32(height))
ticker := time.NewTicker(time.Duration(constants.IntervalSDL * float64(time.Second)))
defer ticker.Stop()