package main
import (
"bufio"
"fmt"
"image"
"log"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/aarzilli/nucular"
"github.com/jezek/xgb"
"github.com/jezek/xgb/xproto"
)
const windowTitle = "Force Quit Monitor"
func minimizeSelf() {
out, err := exec.Command("kdotool", "search", "-t", windowTitle).Output()
if err != nil {
log.Printf("kdotool search failed, leaving window visible: %v", err)
return
}
id := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
if id == "" {
log.Printf("kdotool search found no window titled %q", windowTitle)
return
}
if err := exec.Command("kdotool", "windowminimize", id).Run(); err != nil {
log.Printf("kdotool windowminimize failed: %v", err)
}
}
func uiScale() float64 {
const defaultDPI = 96.0
conn, err := xgb.NewConn()
if err != nil {
return 1.0
}
defer conn.Close()
root := xproto.Setup(conn).DefaultScreen(conn).Root
reply, err := xproto.GetProperty(conn, false, root, xproto.AtomResourceManager, xproto.AtomString, 0, (1<<32)-1).Reply()
if err != nil || reply == nil {
return 1.0
}
for _, line := range strings.Split(string(reply.Value), "\n") {
if name, value, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(name) == "Xft.dpi" {
if dpi, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err == nil && dpi > 0 {
return dpi / defaultDPI
}
}
}
return 1.0
}
const refreshInterval = 2 * time.Second
type procInfo struct {
pid int
name string
rssKB int64
swapKB int64
totalKB int64
}
func readProcesses() []procInfo {
entries, err := os.ReadDir("/proc")
if err != nil {
return nil
}
var procs []procInfo
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
status, err := os.Open(filepath.Join("/proc", e.Name(), "status"))
if err != nil {
continue
}
var rss, swap int64
scanner := bufio.NewScanner(status)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 2 {
continue
}
switch fields[0] {
case "VmRSS:":
rss, _ = strconv.ParseInt(fields[1], 10, 64)
case "VmSwap:":
swap, _ = strconv.ParseInt(fields[1], 10, 64)
}
}
status.Close()
if rss == 0 && swap == 0 {
continue
}
name := e.Name()
if commBytes, err := os.ReadFile(filepath.Join("/proc", e.Name(), "comm")); err == nil {
name = strings.TrimSpace(string(commBytes))
}
procs = append(procs, procInfo{pid: pid, name: name, rssKB: rss, swapKB: swap, totalKB: rss + swap})
}
sort.Slice(procs, func(i, j int) bool { return procs[i].totalKB > procs[j].totalKB })
return procs
}
type appState struct {
mu sync.Mutex
procs []procInfo
selected int
statusText string
}
func (s *appState) refresh() {
procs := readProcesses()
s.mu.Lock()
s.procs = procs
s.mu.Unlock()
}
func main() {
state := &appState{selected: -1}
state.refresh()
scale := uiScale()
for {
runWindow(state, scale)
}
}
func runWindow(state *appState, scale float64) {
var mw nucular.MasterWindow
update := func(w *nucular.Window) {
state.mu.Lock()
procs := state.procs
selected := state.selected
statusText := state.statusText
state.mu.Unlock()
w.Row(20).Dynamic(1)
w.Label("Sorted by memory + swap used, highest first. Select a process and force quit it.", "LC")
w.Row(22).Static(70, 260, 90, 90, 90)
w.Label("PID", "LC")
w.Label("Name", "LC")
w.Label("RSS (MB)", "LC")
w.Label("Swap (MB)", "LC")
w.Label("Total (MB)", "LC")
for _, p := range procs {
w.Row(22).Static(70, 260, 90, 90, 90)
isSelected := p.pid == selected
wasSelected := isSelected
w.SelectableLabel(strconv.Itoa(p.pid), "LC", &isSelected)
if isSelected && !wasSelected {
state.mu.Lock()
state.selected = p.pid
state.mu.Unlock()
}
w.Label(p.name, "LC")
w.Label(fmt.Sprintf("%.1f", float64(p.rssKB)/1024), "LC")
w.Label(fmt.Sprintf("%.1f", float64(p.swapKB)/1024), "LC")
w.Label(fmt.Sprintf("%.1f", float64(p.totalKB)/1024), "LC")
}
w.Row(20).Dynamic(1)
w.Label(statusText, "LC")
w.Row(28).Dynamic(1)
if w.ButtonText("Force Quit Selected") {
state.mu.Lock()
pid := state.selected
state.mu.Unlock()
var msg string
if pid < 0 {
msg = "No process selected."
} else if err := syscall.Kill(pid, syscall.SIGKILL); err != nil {
msg = fmt.Sprintf("Failed to kill PID %d: %v", pid, err)
} else {
msg = fmt.Sprintf("Killed PID %d.", pid)
}
state.mu.Lock()
state.statusText = msg
state.mu.Unlock()
state.refresh()
}
}
mw = nucular.NewMasterWindowOptions(0, nucular.NewWindowOptions{
Title: windowTitle,
Size: image.Point{X: 700, Y: 450},
}, update)
mw.Style().Scale(scale)
stop := make(chan struct{})
go func() {
ticker := time.NewTicker(refreshInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
state.refresh()
mw.Changed()
case <-stop:
return
}
}
}()
go func() {
time.Sleep(200 * time.Millisecond)
minimizeSelf()
}()
mw.Main()
close(stop)
}