← back to Ram Sniper

RAMSniper.py

465 lines

#!/usr/bin/env python3
"""
RAM Sniper  —  a tiny standalone memory/CPU watchdog.

WHAT IT DOES
    • A top "navbar" showing live RAM % and CPU % (updates every 2s when ON).
    • A list of the biggest RAM-hogging processes, largest first.
    • "Kill Selected" snipes the process you pick (asks you first).
    • "Auto-Snipe" (optional, OFF by default) auto-kills anything whose RAM
      goes over the threshold you set — the real "sniper" mode.

HOW TO RUN
    Mac:      open Terminal, then:   python3 RAMSniper.py
              (or double-click if .py is set to open with Python Launcher)
    Windows:  double-click the file, OR:   py RAMSniper.py
              (rename to RAMSniper.pyw to hide the black console window)

    It needs nothing installed — Python's built-in Tkinter draws the window.
    For the most accurate numbers, optionally:   pip install psutil
    (works fine without it; falls back to the OS's own tools.)

    Sanity check without the window:   python3 RAMSniper.py --test

Single file. Nothing phones home. Nothing is written to disk.
"""

import os
import sys
import signal
import subprocess
import platform

APP_TITLE = "RAM Sniper"
REFRESH_MS = 2000          # how often to poll while ON
TOP_N = 20                 # how many processes to list
DEFAULT_THRESHOLD = 15.0   # auto-snipe RAM % per-process trigger

IS_WIN = platform.system() == "Windows"
IS_MAC = platform.system() == "Darwin"

# ---------------------------------------------------------------------------
# Stats layer:  prefer psutil (accurate), else per-OS fallbacks (best-effort).
# Every function returns simple numbers so the UI never has to know the source.
# ---------------------------------------------------------------------------
try:
    import psutil  # optional
    HAVE_PSUTIL = True
except Exception:
    HAVE_PSUTIL = False


def _run(cmd):
    """Run a shell command, return stdout text (empty string on any failure)."""
    try:
        return subprocess.run(cmd, capture_output=True, text=True, timeout=4).stdout
    except Exception:
        return ""


def total_ram_bytes():
    if HAVE_PSUTIL:
        return psutil.virtual_memory().total
    if IS_MAC:
        out = _run(["sysctl", "-n", "hw.memsize"])
        return int(out.strip() or 0)
    if IS_WIN:
        import ctypes
        class MS(ctypes.Structure):
            _fields_ = [("dwLength", ctypes.c_ulong),
                        ("dwMemoryLoad", ctypes.c_ulong),
                        ("ullTotalPhys", ctypes.c_ulonglong),
                        ("ullAvailPhys", ctypes.c_ulonglong),
                        ("ullTotalPageFile", ctypes.c_ulonglong),
                        ("ullAvailPageFile", ctypes.c_ulonglong),
                        ("ullTotalVirtual", ctypes.c_ulonglong),
                        ("ullAvailVirtual", ctypes.c_ulonglong),
                        ("ullAvailExtendedVirtual", ctypes.c_ulonglong)]
        m = MS(); m.dwLength = ctypes.sizeof(MS)
        ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(m))
        return m.ullTotalPhys
    # Linux
    try:
        with open("/proc/meminfo") as f:
            for line in f:
                if line.startswith("MemTotal:"):
                    return int(line.split()[1]) * 1024
    except Exception:
        pass
    return 0


def ram_used_percent():
    """Return system RAM used, as a percent 0..100."""
    if HAVE_PSUTIL:
        return psutil.virtual_memory().percent
    if IS_WIN:
        import ctypes
        class MS(ctypes.Structure):
            _fields_ = [("dwLength", ctypes.c_ulong),
                        ("dwMemoryLoad", ctypes.c_ulong),
                        ("ullTotalPhys", ctypes.c_ulonglong),
                        ("ullAvailPhys", ctypes.c_ulonglong),
                        ("ullTotalPageFile", ctypes.c_ulonglong),
                        ("ullAvailPageFile", ctypes.c_ulonglong),
                        ("ullTotalVirtual", ctypes.c_ulonglong),
                        ("ullAvailVirtual", ctypes.c_ulonglong),
                        ("ullAvailExtendedVirtual", ctypes.c_ulonglong)]
        m = MS(); m.dwLength = ctypes.sizeof(MS)
        ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(m))
        return float(m.dwMemoryLoad)
    if IS_MAC:
        # Derive used% from vm_stat page counts.
        page = 4096
        ps = _run(["sysctl", "-n", "vm.pagesize"]).strip()
        if ps.isdigit():
            page = int(ps)
        out = _run(["vm_stat"])
        pages = {}
        for line in out.splitlines():
            if ":" in line:
                k, _, v = line.partition(":")
                v = v.strip().rstrip(".")
                if v.isdigit():
                    pages[k.strip()] = int(v)
        total = total_ram_bytes()
        if total and pages:
            free = (pages.get("Pages free", 0) +
                    pages.get("Pages inactive", 0) +
                    pages.get("Pages speculative", 0) +
                    pages.get("Pages purgeable", 0)) * page
            used = max(0, total - free)
            return round(100.0 * used / total, 1)
        return 0.0
    # Linux
    try:
        info = {}
        with open("/proc/meminfo") as f:
            for line in f:
                k, _, v = line.partition(":")
                info[k.strip()] = int(v.split()[0]) * 1024
        total = info.get("MemTotal", 0)
        avail = info.get("MemAvailable", info.get("MemFree", 0))
        if total:
            return round(100.0 * (total - avail) / total, 1)
    except Exception:
        pass
    return 0.0


_last_cpu = {"idle": None, "total": None}


def cpu_used_percent():
    """Return system CPU used, as a percent 0..100 (non-blocking / instant)."""
    if HAVE_PSUTIL:
        # interval=None => since the previous call; primed at startup.
        return psutil.cpu_percent(interval=None)
    if IS_WIN:
        out = _run(["wmic", "cpu", "get", "loadpercentage"])
        for tok in out.split():
            if tok.strip().isdigit():
                return float(tok.strip())
        return 0.0
    if IS_MAC:
        # Sum recent %cpu across processes, normalise by core count. Approximate.
        out = _run(["ps", "-A", "-o", "%cpu"])
        vals = []
        for line in out.splitlines()[1:]:
            line = line.strip()
            try:
                vals.append(float(line))
            except ValueError:
                pass
        ncpu = os.cpu_count() or 1
        return round(min(100.0, sum(vals) / ncpu), 1)
    # Linux: delta of /proc/stat between calls.
    try:
        with open("/proc/stat") as f:
            parts = f.readline().split()[1:]
        nums = list(map(int, parts))
        idle = nums[3] + (nums[4] if len(nums) > 4 else 0)
        total = sum(nums)
        pi, pt = _last_cpu["idle"], _last_cpu["total"]
        _last_cpu["idle"], _last_cpu["total"] = idle, total
        if pi is None:
            return 0.0
        dt = total - pt
        di = idle - pi
        if dt <= 0:
            return 0.0
        return round(100.0 * (dt - di) / dt, 1)
    except Exception:
        return 0.0


def top_processes(n=TOP_N):
    """Return list of dicts: pid, name, rss_mb, ram_pct — biggest RAM first."""
    total = total_ram_bytes() or 1
    rows = []
    if HAVE_PSUTIL:
        for p in psutil.process_iter(["pid", "name", "memory_info"]):
            try:
                rss = p.info["memory_info"].rss
                rows.append({
                    "pid": p.info["pid"],
                    "name": p.info["name"] or "?",
                    "rss_mb": rss / (1024 * 1024),
                    "ram_pct": 100.0 * rss / total,
                })
            except Exception:
                continue
    elif IS_WIN:
        out = _run(["tasklist", "/fo", "csv", "/nh"])
        for line in out.splitlines():
            cols = [c.strip('"') for c in line.split('","')]
            if len(cols) >= 5:
                name = cols[0].strip('"')
                try:
                    pid = int(cols[1])
                    mem_kb = int(cols[4].replace(",", "").replace(".", "")
                                 .replace(" K", "").replace("K", "").strip() or 0)
                except ValueError:
                    continue
                rss = mem_kb * 1024
                rows.append({"pid": pid, "name": name,
                             "rss_mb": rss / (1024 * 1024),
                             "ram_pct": 100.0 * rss / total})
    else:  # Mac / Linux
        out = _run(["ps", "-axo", "pid=,rss=,comm="])
        for line in out.splitlines():
            line = line.strip()
            if not line:
                continue
            try:
                pid_s, rss_s, comm = line.split(None, 2)
                pid = int(pid_s)
                rss = int(rss_s) * 1024  # ps rss is in KB
            except ValueError:
                continue
            rows.append({"pid": pid, "name": os.path.basename(comm),
                         "rss_mb": rss / (1024 * 1024),
                         "ram_pct": 100.0 * rss / total})
    rows.sort(key=lambda r: r["rss_mb"], reverse=True)
    return rows[:n]


def kill_pid(pid):
    """Terminate a process. Returns (ok, message)."""
    if pid in (0, os.getpid()):
        return False, "Refusing to kill an invalid PID or myself."
    try:
        if IS_WIN:
            r = subprocess.run(["taskkill", "/PID", str(pid), "/F"],
                               capture_output=True, text=True)
            return (r.returncode == 0), (r.stdout or r.stderr).strip()
        os.kill(pid, signal.SIGTERM)
        return True, f"Sent SIGTERM to {pid}."
    except PermissionError:
        return False, f"Permission denied killing {pid} (needs admin/root)."
    except ProcessLookupError:
        return False, f"Process {pid} already gone."
    except Exception as e:
        return False, f"{type(e).__name__}: {e}"


# ---------------------------------------------------------------------------
# Headless self-test:   python3 RAMSniper.py --test
# ---------------------------------------------------------------------------
def selftest():
    if HAVE_PSUTIL:
        cpu_used_percent()  # prime
    print(f"{APP_TITLE} self-test on {platform.system()} "
          f"({'psutil' if HAVE_PSUTIL else 'stdlib fallback'})")
    print(f"  Total RAM : {total_ram_bytes()/(1024**3):.1f} GB")
    print(f"  RAM used  : {ram_used_percent():.1f} %")
    print(f"  CPU used  : {cpu_used_percent():.1f} %")
    print("  Top 5 RAM hogs:")
    for r in top_processes(5):
        print(f"    {r['pid']:>7}  {r['ram_pct']:5.1f}%  "
              f"{r['rss_mb']:8.1f} MB  {r['name']}")


# ---------------------------------------------------------------------------
# GUI
# ---------------------------------------------------------------------------
def launch_gui():
    import tkinter as tk
    from tkinter import ttk, messagebox

    if HAVE_PSUTIL:
        cpu_used_percent()  # prime the psutil delta

    root = tk.Tk()
    root.title(APP_TITLE)
    root.geometry("720x520")
    root.minsize(620, 420)

    BG = "#0f1216"; FG = "#e7edf3"; ACCENT = "#00b4d8"
    HOT = "#ff5c5c"; OKC = "#3ddc84"; MUTE = "#8a97a3"
    root.configure(bg=BG)

    state = {"on": False, "auto": tk.BooleanVar(value=False),
             "threshold": tk.DoubleVar(value=DEFAULT_THRESHOLD),
             "job": None}

    # ---- NAVBAR ----------------------------------------------------------
    nav = tk.Frame(root, bg="#151b22", height=64)
    nav.pack(fill="x", side="top")
    nav.pack_propagate(False)

    tk.Label(nav, text="🎯 RAM SNIPER", bg="#151b22", fg=ACCENT,
             font=("Helvetica", 15, "bold")).pack(side="left", padx=16)

    ram_lbl = tk.Label(nav, text="RAM  --%", bg="#151b22", fg=FG,
                       font=("Helvetica", 14, "bold"))
    ram_lbl.pack(side="left", padx=14)
    cpu_lbl = tk.Label(nav, text="CPU  --%", bg="#151b22", fg=FG,
                       font=("Helvetica", 14, "bold"))
    cpu_lbl.pack(side="left", padx=14)

    def toggle():
        state["on"] = not state["on"]
        if state["on"]:
            power_btn.config(text="● ON", bg=OKC, fg="#08210f")
            tick()
        else:
            power_btn.config(text="○ OFF", bg="#2a333d", fg=FG)
            if state["job"]:
                root.after_cancel(state["job"]); state["job"] = None
            status.config(text="Paused.")

    power_btn = tk.Button(nav, text="○ OFF", command=toggle, bg="#2a333d",
                          fg=FG, font=("Helvetica", 12, "bold"),
                          relief="flat", padx=16, pady=4, cursor="hand2")
    power_btn.pack(side="right", padx=16)

    src = "psutil" if HAVE_PSUTIL else "built-in"
    tk.Label(nav, text=src, bg="#151b22", fg=MUTE,
             font=("Helvetica", 9)).pack(side="right", padx=6)

    # ---- CONTROLS --------------------------------------------------------
    ctl = tk.Frame(root, bg=BG); ctl.pack(fill="x", padx=14, pady=(10, 4))
    tk.Checkbutton(ctl, text="Auto-snipe processes over",
                   variable=state["auto"], bg=BG, fg=FG, selectcolor="#151b22",
                   activebackground=BG, activeforeground=FG,
                   font=("Helvetica", 11),
                   command=lambda: _confirm_auto()).pack(side="left")
    tk.Entry(ctl, textvariable=state["threshold"], width=5,
             bg="#151b22", fg=FG, insertbackground=FG,
             relief="flat").pack(side="left", padx=(6, 2))
    tk.Label(ctl, text="% RAM", bg=BG, fg=FG,
             font=("Helvetica", 11)).pack(side="left")

    def _confirm_auto():
        if state["auto"].get():
            if not messagebox.askyesno(
                    "Enable Auto-Snipe?",
                    "Auto-Snipe will automatically KILL any process whose RAM "
                    "goes over the threshold, with no further prompts.\n\n"
                    "Turn it on?"):
                state["auto"].set(False)

    kill_btn = tk.Button(ctl, text="🔫 Kill Selected",
                         command=lambda: do_kill(), bg=HOT, fg="white",
                         font=("Helvetica", 11, "bold"), relief="flat",
                         padx=14, pady=3, cursor="hand2")
    kill_btn.pack(side="right")

    # ---- PROCESS LIST ----------------------------------------------------
    style = ttk.Style()
    try:
        style.theme_use("clam")
    except Exception:
        pass
    style.configure("Treeview", background="#12171d", fieldbackground="#12171d",
                    foreground=FG, rowheight=24, borderwidth=0)
    style.configure("Treeview.Heading", background="#151b22", foreground=ACCENT,
                    font=("Helvetica", 10, "bold"))
    style.map("Treeview", background=[("selected", ACCENT)],
              foreground=[("selected", "#04121a")])

    cols = ("pid", "name", "ram_mb", "ram_pct")
    tree = ttk.Treeview(root, columns=cols, show="headings")
    tree.heading("pid", text="PID")
    tree.heading("name", text="Process")
    tree.heading("ram_mb", text="RAM (MB)")
    tree.heading("ram_pct", text="RAM %")
    tree.column("pid", width=80, anchor="e")
    tree.column("name", width=320, anchor="w")
    tree.column("ram_mb", width=120, anchor="e")
    tree.column("ram_pct", width=90, anchor="e")
    tree.tag_configure("hot", foreground=HOT)
    tree.pack(fill="both", expand=True, padx=14, pady=8)

    status = tk.Label(root, text="Press ON to start.", bg=BG, fg=MUTE,
                      anchor="w", font=("Helvetica", 10))
    status.pack(fill="x", padx=14, pady=(0, 8))

    def do_kill():
        sel = tree.selection()
        if not sel:
            status.config(text="Select a process first."); return
        item = tree.item(sel[0])["values"]
        pid, name = int(item[0]), item[1]
        if messagebox.askyesno("Snipe process?",
                               f"Kill {name}  (PID {pid}) ?"):
            ok, msg = kill_pid(pid)
            status.config(text=msg, fg=OKC if ok else HOT)
            refresh()

    def refresh():
        procs = top_processes()
        thr = 0.0
        try:
            thr = float(state["threshold"].get())
        except Exception:
            thr = DEFAULT_THRESHOLD
        auto_hits = []
        tree.delete(*tree.get_children())
        for r in procs:
            hot = r["ram_pct"] >= thr
            tree.insert("", "end",
                        values=(r["pid"], r["name"],
                                f"{r['rss_mb']:.0f}", f"{r['ram_pct']:.1f}"),
                        tags=("hot",) if hot else ())
            if hot:
                auto_hits.append(r)
        return auto_hits

    def tick():
        ram = ram_used_percent()
        cpu = cpu_used_percent()
        ram_lbl.config(text=f"RAM  {ram:.0f}%",
                       fg=HOT if ram >= 85 else FG)
        cpu_lbl.config(text=f"CPU  {cpu:.0f}%",
                       fg=HOT if cpu >= 85 else FG)
        hits = refresh()
        if state["auto"].get() and hits:
            for r in hits:
                if r["pid"] == os.getpid():
                    continue
                ok, msg = kill_pid(r["pid"])
                status.config(text=f"AUTO-SNIPED {r['name']} ({r['pid']}): {msg}",
                              fg=OKC if ok else HOT)
        else:
            status.config(text=f"Watching {TOP_N} processes · "
                               f"threshold {state['threshold'].get():.0f}% · "
                               f"{'AUTO-SNIPE ON' if state['auto'].get() else 'manual'}",
                          fg=MUTE)
        if state["on"]:
            state["job"] = root.after(REFRESH_MS, tick)

    root.mainloop()


if __name__ == "__main__":
    if "--test" in sys.argv:
        selftest()
    else:
        try:
            launch_gui()
        except Exception as e:
            print(f"Could not open the window: {e}")
            print("Try the headless check:  python3 RAMSniper.py --test")