#!/usr/bin/env python3
# Govibe Process Manager
# Lightweight Linux process manager for Debian and Ubuntu.

import os
import warnings
warnings.filterwarnings('ignore', category=DeprecationWarning)
warnings.filterwarnings('ignore', category=PendingDeprecationWarning)
import re
import pwd
import sys
import json
import ast
import signal
import subprocess
import time
import shutil
from collections import deque

import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, GLib, Pango, GdkPixbuf

APPINDICATOR_AVAILABLE = False
AppIndicator3 = None
try:
    gi.require_version("AyatanaAppIndicator3", "0.1")
    from gi.repository import AyatanaAppIndicator3 as AppIndicator3
    APPINDICATOR_AVAILABLE = True
except Exception:
    try:
        gi.require_version("AppIndicator3", "0.1")
        from gi.repository import AppIndicator3
        APPINDICATOR_AVAILABLE = True
    except Exception:
        AppIndicator3 = None
        APPINDICATOR_AVAILABLE = False

APP_NAME = "Govibe Process Manager"
APP_VERSION = "1.0.16"
REFRESH_MS = 2000
HISTORY_LEN = 60
CONFIG_DIR = os.path.join(GLib.get_user_config_dir(), "govibe-process-manager")
SETTINGS_PATH = os.path.join(CONFIG_DIR, "settings.json")
AUTOSTART_DIR = os.path.join(GLib.get_user_config_dir(), "autostart")
AUTOSTART_PATH = os.path.join(AUTOSTART_DIR, "govibe-process-manager.desktop")
TRAY_ICON_DIR = os.path.join(GLib.get_user_cache_dir(), "govibe-process-manager")
HOTKEY_PATH = "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/govibe-process-manager/"
MEDIA_KEYS_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys"
CUSTOM_KEY_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys.custom-keybinding"
ORIGINAL_ARGS = sys.argv[1:]

try:
    GLib.set_prgname("govibe-process-manager")
    GLib.set_application_name(APP_NAME)
except Exception:
    pass
try:
    Gdk.set_program_class("org.govibe.ProcessManager")
except Exception:
    pass
try:
    Gtk.Window.set_default_icon_name("govibe-process-manager")
except Exception:
    pass


THEMES = {
    "Midnight Graphite": {
        "window": "#0b1117",
        "titlebar": "#101820",
        "title_text": "#eaf7ff",
        "panel": "#151f2a",
        "panel_border": "#2f4152",
        "content": "#0f1720",
        "toolbar": "#131c25",
        "text": "#f3f7fb",
        "muted": "#c8d4df",
        "table_bg": "#0d141b",
        "table_alt": "#111b24",
        "table_text": "#f5f8fb",
        "table_header_bg": "#1e2c39",
        "table_header_text": "#ffffff",
        "tab_bg": "#131c25",
        "tab_checked_bg": "#263747",
        "tab_text": "#dce8f4",
        "tab_checked_text": "#ffffff",
        "entry_bg": "#091017",
        "entry_text": "#f5f8fb",
        "accent": "#38bdf8",
        "safe": "#247aa3",
        "danger": "#c04a3f",
        "warn": "#d6a235",
        "button": "#2b3440",
        "button_text": "#ffffff",
        "graph_bg": "#101820",
        "graph_grid": "#263747",
        "graph_line": "#38bdf8",
        "graph_text": "#f2f6fb",
    },
    "Graphite Blue": {
        "window": "#101722",
        "titlebar": "#122133",
        "title_text": "#e6f7ff",
        "panel": "#182635",
        "panel_border": "#36506a",
        "content": "#111c28",
        "toolbar": "#152232",
        "text": "#f2f7fb",
        "muted": "#bfd0df",
        "table_bg": "#0f1a25",
        "table_alt": "#142131",
        "table_text": "#f4f8fc",
        "table_header_bg": "#24384d",
        "table_header_text": "#ffffff",
        "tab_bg": "#172536",
        "tab_checked_bg": "#28435d",
        "tab_text": "#deebf7",
        "tab_checked_text": "#ffffff",
        "entry_bg": "#0b1420",
        "entry_text": "#ffffff",
        "accent": "#5fd3ff",
        "safe": "#2c88b5",
        "danger": "#d05247",
        "warn": "#e0ad3e",
        "button": "#2a3a4d",
        "button_text": "#ffffff",
        "graph_bg": "#101b27",
        "graph_grid": "#28435d",
        "graph_line": "#5fd3ff",
        "graph_text": "#f2f7fb",
    },
    "Govibe White Pro": {
        "window": "#eef3f8",
        "titlebar": "#edf6fb",
        "title_text": "#0b1720",
        "panel": "#ffffff",
        "panel_border": "#c9d6e2",
        "content": "#f7fafc",
        "toolbar": "#eef3f8",
        "text": "#111827",
        "muted": "#344255",
        "table_bg": "#ffffff",
        "table_alt": "#f4f7fa",
        "table_text": "#111827",
        "table_header_bg": "#e4ecf3",
        "table_header_text": "#111827",
        "tab_bg": "#e8eef4",
        "tab_checked_bg": "#ffffff",
        "tab_text": "#17202a",
        "tab_checked_text": "#000000",
        "entry_bg": "#ffffff",
        "entry_text": "#111827",
        "accent": "#1587b8",
        "safe": "#2d7fa3",
        "danger": "#c7473c",
        "warn": "#d7a131",
        "button": "#ffffff",
        "button_text": "#111827",
        "graph_bg": "#ffffff",
        "graph_grid": "#cfe5f1",
        "graph_line": "#1587b8",
        "graph_text": "#111827",
    },
}

def hex_rgb(value):
    value = value.strip().lstrip("#")
    if len(value) != 6:
        return (0.0, 0.0, 0.0)
    return tuple(int(value[i:i+2], 16) / 255.0 for i in (0, 2, 4))



def read_text(path, default=""):
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as f:
            return f.read()
    except Exception:
        return default


def read_bytes(path, default=b""):
    try:
        with open(path, "rb") as f:
            return f.read()
    except Exception:
        return default


def human_bytes(num):
    try:
        num = float(num)
    except Exception:
        return "0 B"
    units = ["B", "KB", "MB", "GB", "TB", "PB"]
    for unit in units:
        if abs(num) < 1024.0:
            return f"{num:.1f} {unit}" if unit != "B" else f"{int(num)} B"
        num /= 1024.0
    return f"{num:.1f} EB"


def safe_user(uid):
    try:
        return pwd.getpwuid(int(uid)).pw_name
    except Exception:
        return str(uid)


def run_cmd(args, timeout=4):
    try:
        out = subprocess.check_output(args, stderr=subprocess.DEVNULL, timeout=timeout)
        return out.decode("utf-8", "replace")
    except Exception:
        return ""


def privileged_command(args):
    if os.geteuid() == 0:
        return subprocess.Popen(args)
    pkexec = "/usr/bin/pkexec"
    if os.path.exists(pkexec):
        return subprocess.Popen([pkexec] + args)
    raise PermissionError("Administrator permission is required. Install pkexec or run the app as root.")


def parse_cpu_line(line):
    parts = line.split()
    if not parts:
        return None
    values = [int(x) for x in parts[1:] if x.isdigit()]
    if not values:
        return None
    idle = values[3] + (values[4] if len(values) > 4 else 0)
    total = sum(values)
    return total, idle


def cpu_snapshots():
    total = None
    cores = []
    for line in read_text("/proc/stat").splitlines():
        if line.startswith("cpu "):
            total = parse_cpu_line(line)
        elif re.match(r"^cpu\d+\s", line):
            snap = parse_cpu_line(line)
            if snap:
                cores.append(snap)
    return total, cores


def cpu_percent(prev, curr):
    if not prev or not curr:
        return 0.0
    total_delta = curr[0] - prev[0]
    idle_delta = curr[1] - prev[1]
    if total_delta <= 0:
        return 0.0
    return max(0.0, min(100.0, 100.0 * (total_delta - idle_delta) / total_delta))


def memory_info():
    data = {}
    for line in read_text("/proc/meminfo").splitlines():
        if ":" in line:
            key, rest = line.split(":", 1)
            val = rest.strip().split()[0]
            try:
                data[key] = int(val) * 1024
            except Exception:
                pass
    total = data.get("MemTotal", 0)
    available = data.get("MemAvailable", 0)
    used = max(0, total - available)
    swap_total = data.get("SwapTotal", 0)
    swap_free = data.get("SwapFree", 0)
    return total, used, swap_total, max(0, swap_total - swap_free)


def network_totals():
    rx = tx = 0
    for line in read_text("/proc/net/dev").splitlines()[2:]:
        if ":" not in line:
            continue
        iface, rest = line.split(":", 1)
        iface = iface.strip()
        if iface == "lo":
            continue
        nums = rest.split()
        if len(nums) >= 16:
            try:
                rx += int(nums[0])
                tx += int(nums[8])
            except Exception:
                pass
    return rx, tx


def disk_io_totals():
    read_sectors = write_sectors = 0
    for line in read_text("/proc/diskstats").splitlines():
        parts = line.split()
        if len(parts) < 14:
            continue
        name = parts[2]
        if name.startswith(("loop", "ram", "sr")):
            continue
        if re.search(r"\d+$", name) and not name.startswith(("nvme", "mmcblk")):
            continue
        try:
            read_sectors += int(parts[5])
            write_sectors += int(parts[9])
        except Exception:
            pass
    return read_sectors * 512, write_sectors * 512


def cpu_model_name():
    for line in read_text("/proc/cpuinfo").splitlines():
        if line.lower().startswith("model name") and ":" in line:
            return line.split(":", 1)[1].strip()
    return "Linux CPU"


def cpu_mhz():
    vals = []
    for line in read_text("/proc/cpuinfo").splitlines():
        if line.lower().startswith("cpu mhz") and ":" in line:
            try:
                vals.append(float(line.split(":", 1)[1].strip()))
            except Exception:
                pass
    return max(vals) if vals else 0.0


def parse_proc_stat(pid):
    text = read_text(f"/proc/{pid}/stat")
    if not text:
        return None
    try:
        end = text.rfind(")")
        after = text[end + 2:].split()
        state = after[0]
        utime = int(after[11])
        stime = int(after[12])
        num_threads = int(after[17])
        return state, utime + stime, num_threads
    except Exception:
        return None


def proc_uid_and_status(pid):
    uid = "?"
    state = "?"
    rss = 0
    for line in read_text(f"/proc/{pid}/status").splitlines():
        if line.startswith("Uid:"):
            uid = line.split()[1]
        elif line.startswith("State:"):
            state = line.split(":", 1)[1].strip()
        elif line.startswith("VmRSS:"):
            try:
                rss = int(line.split()[1]) * 1024
            except Exception:
                rss = 0
    return uid, state, rss


def proc_command(pid):
    raw = read_bytes(f"/proc/{pid}/cmdline")
    if raw:
        parts = [p.decode("utf-8", "replace") for p in raw.split(b"\0") if p]
        if parts:
            return " ".join(parts)
    name = read_text(f"/proc/{pid}/comm").strip()
    return name or f"pid {pid}"


def list_processes(prev_times, prev_total, curr_total):
    ncpu = max(1, os.cpu_count() or 1)
    rows = []
    mem_total, _, _, _ = memory_info()
    total_delta = 0
    if prev_total and curr_total:
        total_delta = curr_total[0] - prev_total[0]
    current_times = {}

    for name in os.listdir("/proc"):
        if not name.isdigit():
            continue
        pid = int(name)
        stat = parse_proc_stat(pid)
        if not stat:
            continue
        state_code, proc_time, threads = stat
        uid, state_text, rss = proc_uid_and_status(pid)
        current_times[pid] = proc_time
        cpu = 0.0
        if total_delta > 0 and pid in prev_times:
            cpu = max(0.0, (proc_time - prev_times.get(pid, proc_time)) / total_delta * 100.0 * ncpu)
        mem_pct = (rss / mem_total * 100.0) if mem_total else 0.0
        rows.append({
            "pid": pid,
            "user": safe_user(uid),
            "cpu": cpu,
            "rss": rss,
            "mem_pct": mem_pct,
            "state": state_text or state_code,
            "threads": threads,
            "cmd": proc_command(pid),
        })
    rows.sort(key=lambda r: r["cpu"], reverse=True)
    return rows, current_times


def discover_gpu_processes():
    by_pid = {}

    out = run_cmd(["nvidia-smi", "--query-compute-apps=pid,process_name,used_memory", "--format=csv,noheader,nounits"], timeout=3)
    for line in out.splitlines():
        parts = [p.strip() for p in line.split(",")]
        if len(parts) >= 3 and parts[0].isdigit():
            pid = int(parts[0])
            by_pid.setdefault(pid, {"pid": pid, "devices": set(), "mem": "", "cmd": ""})
            by_pid[pid]["devices"].add("NVIDIA")
            by_pid[pid]["mem"] = parts[2] + " MB"
            by_pid[pid]["cmd"] = parts[1]

    rocm = run_cmd(["rocm-smi", "--showpids"], timeout=3)
    for m in re.finditer(r"\bPID\s*[:=]?\s*(\d+)\b|\bpid\s*[:=]?\s*(\d+)\b", rocm):
        pid_s = m.group(1) or m.group(2)
        if pid_s and pid_s.isdigit():
            pid = int(pid_s)
            by_pid.setdefault(pid, {"pid": pid, "devices": set(), "mem": "", "cmd": ""})
            by_pid[pid]["devices"].add("AMD ROCm")

    device_patterns = ("/dev/dri", "/dev/nvidia", "/dev/kfd")
    for name in os.listdir("/proc"):
        if not name.isdigit():
            continue
        pid = int(name)
        fd_dir = f"/proc/{pid}/fd"
        try:
            fds = os.listdir(fd_dir)
        except Exception:
            continue
        devices = set()
        for fd in fds[:128]:
            try:
                target = os.readlink(os.path.join(fd_dir, fd))
            except Exception:
                continue
            if any(target.startswith(p) for p in device_patterns):
                devices.add(target)
        if devices:
            by_pid.setdefault(pid, {"pid": pid, "devices": set(), "mem": "", "cmd": ""})
            by_pid[pid]["devices"].update(devices)

    rows = []
    for pid, item in by_pid.items():
        uid, _, rss = proc_uid_and_status(pid)
        cmd = item.get("cmd") or proc_command(pid)
        rows.append({
            "pid": pid,
            "user": safe_user(uid),
            "devices": ", ".join(sorted(item["devices"]))[:140],
            "gpu_mem": item.get("mem", ""),
            "rss": rss,
            "cmd": cmd,
        })
    rows.sort(key=lambda r: r["pid"])
    return rows


class MiniGraph(Gtk.DrawingArea):
    def __init__(self, title="", suffix="%"):
        super().__init__()
        self.values = deque([0.0] * HISTORY_LEN, maxlen=HISTORY_LEN)
        self.title = title
        self.suffix = suffix
        self.theme = THEMES["Midnight Graphite"]
        self.set_size_request(170, 82)
        self.connect("draw", self.on_draw)

    def set_theme(self, theme):
        self.theme = theme
        self.queue_draw()

    def push(self, value):
        try:
            value = float(value)
        except Exception:
            value = 0.0
        self.values.append(max(0.0, min(100.0, value)))
        self.queue_draw()

    def on_draw(self, widget, cr):
        alloc = self.get_allocation()
        w, h = alloc.width, alloc.height
        cr.set_source_rgb(*hex_rgb(self.theme.get("graph_bg", "#ffffff")))
        cr.rectangle(0, 0, w, h)
        cr.fill()

        cr.set_source_rgb(*hex_rgb(self.theme.get("graph_grid", "#c9e7f4")))
        cr.set_line_width(1)
        for i in range(1, 4):
            y = h * i / 4
            cr.move_to(0, y)
            cr.line_to(w, y)
        for i in range(1, 6):
            x = w * i / 6
            cr.move_to(x, 0)
            cr.line_to(x, h)
        cr.stroke()

        cr.set_source_rgb(*hex_rgb(self.theme.get("graph_line", "#1987af")))
        cr.set_line_width(1.8)
        vals = list(self.values)
        if len(vals) > 1:
            for i, v in enumerate(vals):
                x = i * w / (len(vals) - 1)
                y = h - (v / 100.0) * (h - 4) - 2
                if i == 0:
                    cr.move_to(x, y)
                else:
                    cr.line_to(x, y)
            cr.stroke()

        cr.set_source_rgb(*hex_rgb(self.theme.get("graph_line", "#1987af")))
        cr.set_line_width(1)
        cr.rectangle(0.5, 0.5, w - 1, h - 1)
        cr.stroke()

        cr.set_source_rgb(*hex_rgb(self.theme.get("graph_text", "#111827")))
        cr.select_font_face("Sans", 0, 0)
        font_size = 10 if h < 80 else 12
        cr.set_font_size(font_size)
        label = f"{self.title} {self.values[-1]:.0f}{self.suffix}" if self.title else f"{self.values[-1]:.0f}{self.suffix}"
        cr.move_to(7, 16 if h < 80 else 20)
        cr.show_text(label[:30])
        return False


class GovibeProcessManager(Gtk.Application):
    def __init__(self):
        super().__init__(application_id="org.govibe.ProcessManager")
        self.window = None
        self.start_minimized = any(arg in ("--minimized", "--tray", "--background") for arg in ORIGINAL_ARGS) and not any(arg in ("--show", "--raise", "--activate") for arg in ORIGINAL_ARGS)

    def raise_main_window(self):
        if not self.window:
            return
        win = self.window
        try:
            win.set_skip_taskbar_hint(False)
            win.set_accept_focus(True)
            win.set_focus_on_map(True)
            win.set_urgency_hint(True)
        except Exception:
            pass
        try:
            win.show_all()
            win.deiconify()
            win.unstick()
            win.set_visible(True)
        except Exception:
            pass
        # Keep above briefly so launcher and tray actions open in front.
        try:
            win.set_keep_above(True)
        except Exception:
            pass
        try:
            win.present_with_time(Gdk.CURRENT_TIME)
        except Exception:
            try:
                win.present()
            except Exception:
                pass
        try:
            win.grab_focus()
            win.activate_focus()
        except Exception:
            pass
        try:
            gdk_window = win.get_window()
            if gdk_window:
                gdk_window.show()
                gdk_window.raise_()
                gdk_window.focus(Gdk.CURRENT_TIME)
        except Exception:
            pass
        GLib.timeout_add(80, self.raise_again_once)
        GLib.timeout_add(180, self.raise_again_once)
        GLib.timeout_add(400, self.raise_again_once)
        GLib.timeout_add(900, self.raise_again_once)
        GLib.timeout_add(1600, self.raise_again_once)
        GLib.timeout_add(3000, self.release_raise_flags)

    def raise_again_once(self):
        if self.window:
            try:
                self.window.present_with_time(Gdk.CURRENT_TIME)
            except Exception:
                try:
                    self.window.present()
                except Exception:
                    pass
            try:
                gdk_window = self.window.get_window()
                if gdk_window:
                    gdk_window.raise_()
                    gdk_window.focus(Gdk.CURRENT_TIME)
            except Exception:
                pass
        return False

    def release_raise_flags(self):
        if self.window:
            try:
                self.window.set_keep_above(False)
                self.window.set_urgency_hint(False)
            except Exception:
                pass
        return False

    def do_activate(self):
        if self.window:
            self.raise_main_window()
            return
        self.window = MainWindow(self)
        self.window.show_all()
        if self.start_minimized:
            self.window.hide()
        else:
            self.raise_main_window()


class MainWindow(Gtk.ApplicationWindow):
    def __init__(self, app):
        super().__init__(application=app, title=APP_NAME)
        try:
            self.set_wmclass("org.govibe.ProcessManager", "org.govibe.ProcessManager")
        except Exception:
            pass
        try:
            self.set_icon_name("govibe-process-manager")
        except Exception:
            pass
        self.set_default_size(1180, 760)
        self.set_position(Gtk.WindowPosition.CENTER)

        self.prev_cpu_total = None
        self.prev_core_snaps = []
        self.prev_proc_times = {}
        self.last_net = None
        self.last_disk = None
        self.last_io_time = time.time()
        self.cpu_graphs = []
        self.overall_cpu_graph = MiniGraph("CPU")
        self.memory_graph = MiniGraph("RAM")
        self.network_graph = MiniGraph("NET")
        self.disk_graph = MiniGraph("DISK")
        self.gpu_scan_due = 0
        self.theme_name = "Midnight Graphite"
        self.current_theme = THEMES[self.theme_name]
        self.css_provider = Gtk.CssProvider()
        self.css_added = False
        self.cell_renderers = []
        self.tab_label_widgets = []
        self.theme_combo = None
        self.tray_icon = None
        self.tray_surface = None
        self.last_tray_percent = None
        self.last_selected_kind = None
        self.last_selected_key = None
        self.settings = self.load_settings()
        self.autostart_menu_item = None
        self.indicator = None
        self.indicator_menu = None

        self.build_css()
        self.build_ui()
        self.create_tray_icon()
        self.cleanup_retired_shortcut()
        self.apply_runtime_theme()
        GLib.timeout_add(350, self.first_refresh)
        GLib.timeout_add(REFRESH_MS, self.refresh_all)

    def build_css(self):
        t = self.current_theme
        white_mode = self.theme_name == "Govibe White Pro"
        notebook_border = "0" if white_mode else f"1px solid {t['panel_border']}"
        scroller_border = "0" if white_mode else f"1px solid {t['panel_border']}"
        tab_border = f"1px solid {t['panel_border']}"
        header_border = f"1px solid {t['panel_border']}" if white_mode else "0"
        toolbar_border = f"1px solid {t['panel_border']}" if white_mode else "0"
        white_clean_css = f"""
        notebook {{ background: {t['content']}; background-color: {t['content']}; border-width: 0; border-style: none; box-shadow: none; padding: 0; margin: 0; }}
        notebook > stack, notebook stack {{ background: {t['content']}; background-color: {t['content']}; border-width: 0; border-style: none; box-shadow: none; padding: 0; margin: 0; }}
        notebook > stack > box, notebook stack box {{ background: {t['content']}; background-color: {t['content']}; border-width: 0; border-style: none; box-shadow: none; padding: 0; margin: 0; }}
        notebook > stack scrolledwindow, notebook stack scrolledwindow {{ background: {t['content']}; background-color: {t['content']}; border-width: 0; border-style: none; box-shadow: none; padding: 0; margin: 0; }}
        viewport, viewport.frame, scrolledwindow viewport, scrolledwindow > viewport {{ background: {t['content']}; background-color: {t['content']}; border-width: 0; border-style: none; box-shadow: none; padding: 0; margin: 0; }}
        .content-page, .performance-page, .performance-bg {{ background: {t['content']}; background-color: {t['content']}; border-width: 0; border-style: none; box-shadow: none; padding: 0; margin: 0; }}
        .performance-scroll, .performance-scroll viewport, .performance-scroll > viewport {{ background: {t['content']}; background-color: {t['content']}; border-width: 0; border-style: none; box-shadow: none; padding: 0; margin: 0; }}
        .performance-scroll overshoot, .performance-scroll undershoot {{ background: {t['content']}; background-color: {t['content']}; border-width: 0; border-style: none; box-shadow: none; }}
        .performance-scroll scrollbar {{ background: {t['content']}; background-color: {t['content']}; border-width: 0; border-style: none; }}
        """ if white_mode else ""
        css = f"""
        window {{ background-color: {t['window']}; color: {t['text']}; }}
        label {{ color: {t['text']}; }}
        .govibe-titlebar {{ background: {t['titlebar']}; color: {t['title_text']}; padding: 8px; border-bottom: 1px solid {t['panel_border']}; }}
        .govibe-titlebar label {{ color: {t['title_text']}; }}
        .side-card {{ background: {t['panel']}; border: 1px solid {t['panel_border']}; padding: 12px; margin: 6px; border-radius: 6px; }}
        .side-card label {{ color: {t['text']}; }}
        .metric-title {{ font-size: 20px; color: {t['text']}; }}
        .metric-value {{ font-size: 14px; color: {t['muted']}; }}
        .content-page {{ background: {t['content']}; color: {t['text']}; }}
        .toolbar-dark {{ background: {t['toolbar']}; padding: 8px; border-radius: 8px; border: {toolbar_border}; }}
        .toolbar-dark label {{ color: {t['text']}; }}
        button {{ background: {t['button']}; color: {t['button_text']}; border-radius: 6px; padding: 6px 12px; border: 1px solid {t['panel_border']}; }}
        button label {{ color: {t['button_text']}; }}
        button:hover {{ opacity: 0.92; }}
        .danger {{ background: {t['danger']}; color: #ffffff; }}
        .danger label {{ color: #ffffff; }}
        .warn {{ background: {t['warn']}; color: #111111; }}
        .warn label {{ color: #111111; }}
        .safe {{ background: {t['safe']}; color: #ffffff; }}
        .safe label {{ color: #ffffff; }}
        entry, searchentry {{ background: {t['entry_bg']}; color: {t['entry_text']}; border-radius: 6px; border: 1px solid {t['panel_border']}; padding: 6px; }}
        entry text, searchentry text {{ color: {t['entry_text']}; }}
        combobox, combobox button {{ background: {t['panel']}; color: {t['text']}; border-radius: 6px; }}
        combobox label, combobox button label {{ color: {t['text']}; }}
        menu, menubar, menuitem {{ background: {t['panel']}; color: {t['text']}; }}
        menuitem label, menubar label {{ color: {t['text']}; }}
        notebook {{ background: {t['content']}; border: {notebook_border}; box-shadow: none; }}
        notebook header {{ background: {t['tab_bg']}; border-bottom: {header_border}; box-shadow: none; }}
        notebook tab {{ background: {t['tab_bg']}; padding: 7px 16px; border: {tab_border}; box-shadow: none; }}
        notebook tab label {{ color: {t['tab_text']}; }}
        notebook tab:checked {{ background: {t['tab_checked_bg']}; border-bottom: 3px solid {t['accent']}; }}
        notebook tab:checked label {{ color: {t['tab_checked_text']}; }}
        scrolledwindow {{ background: {t['table_bg']}; border: {scroller_border}; box-shadow: none; }}
        treeview, treeview.view {{ background-color: {t['table_bg']}; color: {t['table_text']}; }}
        treeview.view cell {{ color: {t['table_text']}; background-color: transparent; }}
        treeview.view:selected, treeview.view:selected:focus {{ background-color: {t['accent']}; color: #ffffff; }}
        treeview.view:selected cell, treeview.view:selected:focus cell {{ color: #ffffff; }}
        treeview header button {{ background: {t['table_header_bg']}; color: {t['table_header_text']}; border: 0; border-right: 1px solid {t['panel_border']}; }}
        treeview header button label {{ color: {t['table_header_text']}; font-weight: bold; }}
        .footer {{ background: {t['window']}; padding: 6px; }}
        linkbutton label {{ color: {t['accent']}; }}
        {white_clean_css}
        """
        self.css_provider.load_from_data(css.encode("utf-8"))
        if not self.css_added:
            Gtk.StyleContext.add_provider_for_screen(
                Gdk.Screen.get_default(), self.css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
            )
            self.css_added = True

    def make_cell_renderer(self):
        renderer = Gtk.CellRendererText()
        renderer.set_property("ellipsize", Pango.EllipsizeMode.END)
        renderer.set_property("foreground", self.current_theme["table_text"])
        self.cell_renderers.append(renderer)
        return renderer

    def make_tab_label(self, text):
        label = Gtk.Label()
        label.govibe_tab_text = text
        self.tab_label_widgets.append(label)
        self.update_tab_label(label)
        return label

    def update_tab_label(self, label):
        text = GLib.markup_escape_text(getattr(label, "govibe_tab_text", ""))
        color = self.current_theme.get("tab_text", "#111111")
        label.set_markup(f"<span foreground='{color}' weight='bold'>{text}</span>")

    def apply_runtime_theme(self):
        for renderer in self.cell_renderers:
            try:
                renderer.set_property("foreground", self.current_theme["table_text"])
            except Exception:
                pass
        for label in self.tab_label_widgets:
            self.update_tab_label(label)
        for graph in self.cpu_graphs + [self.overall_cpu_graph, self.memory_graph, self.network_graph, self.disk_graph]:
            try:
                graph.set_theme(self.current_theme)
            except Exception:
                pass
        self.queue_draw()
        if self.last_tray_percent is not None:
            self.update_tray_icon(self.last_tray_percent)

    def on_theme_changed(self, combo):
        name = combo.get_active_text()
        if not name or name not in THEMES:
            return
        self.theme_name = name
        self.current_theme = THEMES[name]
        self.build_css()
        self.apply_runtime_theme()
        self.set_status(f"Theme changed to {name}")

    def select_theme_from_menu(self, widget, name):
        names = list(THEMES.keys())
        if self.theme_combo and name in names:
            self.theme_combo.set_active(names.index(name))
        elif name in THEMES:
            self.theme_name = name
            self.current_theme = THEMES[name]
            self.build_css()
            self.apply_runtime_theme()

    def load_settings(self):
        try:
            with open(SETTINGS_PATH, "r", encoding="utf-8") as f:
                data = json.load(f)
            return data if isinstance(data, dict) else {}
        except Exception:
            return {}

    def save_settings(self):
        try:
            os.makedirs(CONFIG_DIR, exist_ok=True)
            with open(SETTINGS_PATH, "w", encoding="utf-8") as f:
                json.dump(self.settings, f, indent=2, sort_keys=True)
        except Exception:
            pass

    def app_autostart_enabled(self):
        if not os.path.exists(AUTOSTART_PATH):
            return False
        text = read_text(AUTOSTART_PATH).lower()
        if "hidden=true" in text or "x-gnome-autostart-enabled=false" in text:
            return False
        return True

    def set_app_autostart(self, enabled):
        os.makedirs(AUTOSTART_DIR, exist_ok=True)
        if enabled:
            desktop = """[Desktop Entry]
Type=Application
Name=Govibe Process Manager
Comment=Start Govibe Process Manager in the tray
Exec=env GDK_APPLICATION_ID=org.govibe.ProcessManager BAMF_DESKTOP_FILE_HINT=/usr/share/applications/org.govibe.ProcessManager.desktop govibe-process-manager --minimized
Icon=govibe-process-manager
Terminal=false
Categories=System;Monitor;Utility;
StartupWMClass=org.govibe.ProcessManager
X-GNOME-Autostart-enabled=true
StartupNotify=false
"""
            with open(AUTOSTART_PATH, "w", encoding="utf-8") as f:
                f.write(desktop)
            try:
                os.chmod(AUTOSTART_PATH, 0o755)
            except Exception:
                pass
            self.set_status("Autostart enabled. Govibe Process Manager will start minimized at login.")
        else:
            if os.path.exists(AUTOSTART_PATH):
                try:
                    os.remove(AUTOSTART_PATH)
                except Exception:
                    text = read_text(AUTOSTART_PATH)
                    if text:
                        text = self.desktop_entry_set(text, {"Hidden": "true", "X-GNOME-Autostart-enabled": "false"})
                        with open(AUTOSTART_PATH, "w", encoding="utf-8") as f:
                            f.write(text)
            self.set_status("Autostart disabled")
        self.sync_option_checks()

    def on_autostart_toggled(self, item):
        self.set_app_autostart(item.get_active())

    def gsettings_value(self, args, timeout=3):
        try:
            out = subprocess.check_output(["gsettings"] + args, stderr=subprocess.DEVNULL, timeout=timeout)
            return out.decode("utf-8", "replace").strip()
        except Exception:
            return ""

    def gsettings_set(self, schema, key, value):
        return subprocess.call(["gsettings", "set", schema, key, value], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0

    def gsettings_schema_exists(self, schema):
        # Some GNOME settings schemas are relocatable.
        for cmd in (["list-schemas"], ["list-relocatable-schemas"]):
            out = self.gsettings_value(cmd)
            if schema in out.splitlines():
                return True
        return False

    def parse_gsettings_list(self, value):
        value = (value or "").strip()
        if value.startswith("@as "):
            value = value[4:].strip()
        try:
            parsed = ast.literal_eval(value)
            if isinstance(parsed, list):
                return [str(x) for x in parsed]
        except Exception:
            pass
        return []

    def format_gsettings_list(self, items):
        safe = []
        for item in items:
            safe.append("'" + str(item).replace("'", "\\'") + "'")
        return "[" + ", ".join(safe) + "]"

    def cleanup_retired_shortcut(self):
        # Keep desktop shortcut handling outside the app.
        if self.settings.get("retired_shortcut_cleanup_v1016"):
            return
        try:
            if shutil.which("gsettings") and self.gsettings_schema_exists(MEDIA_KEYS_SCHEMA):
                paths = self.parse_gsettings_list(self.gsettings_value(["get", MEDIA_KEYS_SCHEMA, "custom-keybindings"]))
                if HOTKEY_PATH in paths:
                    paths = [p for p in paths if p != HOTKEY_PATH]
                    self.gsettings_set(MEDIA_KEYS_SCHEMA, "custom-keybindings", self.format_gsettings_list(paths))
                backup = self.settings.get("backup_logout_binding")
                current_logout = self.gsettings_value(["get", MEDIA_KEYS_SCHEMA, "logout"])
                if backup:
                    self.gsettings_set(MEDIA_KEYS_SCHEMA, "logout", backup)
                elif current_logout.strip() in ("@as []", "[]", "@as[]"):
                    self.gsettings_set(MEDIA_KEYS_SCHEMA, "logout", "['<Control><Alt>Delete>']")
            self.settings["retired_shortcut_cleanup_v1016"] = True
            self.save_settings()
        except Exception:
            pass

    def sync_option_checks(self):
        for item, state in [
            (getattr(self, "autostart_menu_item", None), self.app_autostart_enabled()),
        ]:
            if item:
                try:
                    old_handlers = getattr(item, "_govibe_handlers_block", False)
                    item._govibe_handlers_block = True
                    item.set_active(state)
                    item._govibe_handlers_block = old_handlers
                except Exception:
                    pass

    def guarded_autostart_toggled(self, item):
        if getattr(item, "_govibe_handlers_block", False):
            return
        self.on_autostart_toggled(item)

    def build_ui(self):
        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        self.add(outer)

        title = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        title.get_style_context().add_class("govibe-titlebar")
        icon = Gtk.Label(label="▣")
        icon.set_markup("<b>▣</b>")
        title_label = Gtk.Label(label=APP_NAME)
        title_label.set_xalign(0.5)
        title_label.set_hexpand(True)
        skin_label = Gtk.Label(label="Skin:")
        self.theme_combo = Gtk.ComboBoxText()
        for theme_name in THEMES.keys():
            self.theme_combo.append_text(theme_name)
        self.theme_combo.set_active(list(THEMES.keys()).index(self.theme_name))
        self.theme_combo.connect("changed", self.on_theme_changed)
        title.pack_start(icon, False, False, 8)
        title.pack_start(title_label, True, True, 0)
        title.pack_start(skin_label, False, False, 6)
        title.pack_start(self.theme_combo, False, False, 8)
        outer.pack_start(title, False, False, 0)

        menu_bar = Gtk.MenuBar()
        for name in ("File", "Options", "View", "Help"):
            item = Gtk.MenuItem(label=name)
            submenu = Gtk.Menu()
            if name == "File":
                refresh = Gtk.MenuItem(label="Refresh now")
                refresh.connect("activate", lambda *_: self.refresh_all())
                quit_item = Gtk.MenuItem(label="Quit")
                quit_item.connect("activate", lambda *_: self.close())
                submenu.append(refresh)
                submenu.append(quit_item)
            elif name == "View":
                item_0 = Gtk.MenuItem(label="Task Manager")
                item_0.connect("activate", lambda *_: self.notebook.set_current_page(0))
                item_a = Gtk.MenuItem(label="Performance")
                item_a.connect("activate", lambda *_: self.notebook.set_current_page(1))
                item_b = Gtk.MenuItem(label="GPU processes")
                item_b.connect("activate", lambda *_: self.notebook.set_current_page(3))
                item_c = Gtk.MenuItem(label="Startup applications")
                item_c.connect("activate", lambda *_: self.notebook.set_current_page(4))
                submenu.append(item_0)
                submenu.append(item_a)
                submenu.append(item_b)
                submenu.append(item_c)
            elif name == "Help":
                about = Gtk.MenuItem(label="About Govibe Process Manager")
                about.connect("activate", self.show_about)
                submenu.append(about)
            else:
                fast = Gtk.MenuItem(label="Auto refresh: 2 seconds")
                fast.set_sensitive(False)
                submenu.append(fast)
                self.autostart_menu_item = Gtk.CheckMenuItem(label="Start Govibe Process Manager at login")
                self.autostart_menu_item.set_active(self.app_autostart_enabled())
                self.autostart_menu_item.connect("toggled", self.guarded_autostart_toggled)
                submenu.append(self.autostart_menu_item)
                submenu.append(Gtk.SeparatorMenuItem())
                for theme_name in THEMES.keys():
                    theme_item = Gtk.MenuItem(label=f"Use skin: {theme_name}")
                    theme_item.connect("activate", self.select_theme_from_menu, theme_name)
                    submenu.append(theme_item)
            item.set_submenu(submenu)
            menu_bar.append(item)
        outer.pack_start(menu_bar, False, False, 0)

        body = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
        outer.pack_start(body, True, True, 0)

        sidebar = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
        sidebar.set_size_request(235, -1)
        body.pack_start(sidebar, False, False, 0)
        self.sidebar_cards = {}
        for key, title_text in [("cpu", "CPU"), ("mem", "Memory"), ("net", "Ethernet"), ("disk", "Disk"), ("gpu", "GPU")]:
            card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
            card.get_style_context().add_class("side-card")
            lab = Gtk.Label(label=title_text)
            lab.get_style_context().add_class("metric-title")
            lab.set_xalign(0)
            val = Gtk.Label(label="Waiting...")
            val.get_style_context().add_class("metric-value")
            val.set_xalign(0)
            card.pack_start(lab, False, False, 0)
            card.pack_start(val, False, False, 0)
            sidebar.pack_start(card, False, False, 0)
            self.sidebar_cards[key] = val

        self.notebook = Gtk.Notebook()
        body.pack_start(self.notebook, True, True, 0)
        self.build_process_tab()
        self.build_performance_tab()
        self.build_services_tab()
        self.build_gpu_tab()
        self.build_startup_tab()

        bottom = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        bottom.get_style_context().add_class("footer")
        bottom.set_border_width(8)
        self.status_label = Gtk.Label(label="Ready")
        self.status_label.set_xalign(0)
        bottom.pack_start(self.status_label, True, True, 0)
        link = Gtk.LinkButton(uri="https://govibe.org", label="govibe.org")
        bottom.pack_end(link, False, False, 0)
        outer.pack_end(bottom, False, False, 0)

    def build_process_tab(self):
        page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        page.get_style_context().add_class("content-page")
        page.set_border_width(8)
        toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        toolbar.get_style_context().add_class("toolbar-dark")
        self.process_search = Gtk.SearchEntry()
        self.process_search.set_placeholder_text("Search PID, user, or command")
        self.process_search.connect("search-changed", lambda *_: self.proc_filter.refilter())
        toolbar.pack_start(self.process_search, True, True, 0)
        for label, sig, klass in [
            ("End Process", signal.SIGTERM, "safe"),
            ("Force Kill", signal.SIGKILL, "danger"),
            ("Suspend", signal.SIGSTOP, "warn"),
            ("Resume", signal.SIGCONT, "safe"),
        ]:
            btn = Gtk.Button(label=label)
            btn.get_style_context().add_class(klass)
            btn.connect("clicked", self.kill_selected_process, sig)
            toolbar.pack_start(btn, False, False, 0)
        refresh_btn = Gtk.Button(label="Refresh")
        refresh_btn.connect("clicked", lambda *_: self.refresh_all())
        toolbar.pack_start(refresh_btn, False, False, 0)
        page.pack_start(toolbar, False, False, 0)

        self.proc_store = Gtk.ListStore(int, str, float, str, float, str, int, str)
        self.proc_filter = self.proc_store.filter_new()
        self.proc_filter.set_visible_func(self.process_visible)
        self.proc_sort = Gtk.TreeModelSort(model=self.proc_filter)
        tree = Gtk.TreeView(model=self.proc_sort)
        tree.set_headers_visible(True)
        tree.set_rules_hint(True)
        tree.get_style_context().add_class("govibe-table")
        self.proc_tree = tree
        tree.get_selection().set_mode(Gtk.SelectionMode.SINGLE)
        tree.get_selection().connect("changed", self.on_tree_selection_changed, "process")
        columns = [
            ("PID", 0, "text"),
            ("User", 1, "text"),
            ("CPU %", 2, "float"),
            ("Memory", 3, "text"),
            ("Mem %", 4, "float"),
            ("State", 5, "text"),
            ("Threads", 6, "text"),
            ("Command", 7, "text"),
        ]
        for title, idx, kind in columns:
            renderer = self.make_cell_renderer()
            renderer.set_property("ellipsize", Pango.EllipsizeMode.END)
            col = Gtk.TreeViewColumn(title, renderer)
            if kind == "float":
                col.set_cell_data_func(renderer, self.float_cell, idx)
            else:
                col.add_attribute(renderer, "text", idx)
            col.set_sort_column_id(idx)
            col.set_resizable(True)
            if title == "Command":
                col.set_expand(True)
            tree.append_column(col)
        scroller = Gtk.ScrolledWindow()
        scroller.add(tree)
        self.proc_scroller = scroller
        page.pack_start(scroller, True, True, 0)
        self.notebook.append_page(page, self.make_tab_label("Task Manager"))

    def float_cell(self, column, cell, model, itr, idx):
        try:
            cell.set_property("text", f"{float(model[itr][idx]):.1f}")
        except Exception:
            cell.set_property("text", "0.0")

    def process_visible(self, model, itr, data=None):
        q = self.process_search.get_text().strip().lower()
        if not q:
            return True
        row_text = " ".join(str(model[itr][i]) for i in range(len(model[itr]))).lower()
        return q in row_text

    def build_performance_tab(self):
        outer = Gtk.ScrolledWindow()
        outer.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
        outer.set_shadow_type(Gtk.ShadowType.NONE)
        outer.get_style_context().add_class("content-page")
        outer.get_style_context().add_class("performance-scroll")

        # The white skin must not show the dark Graphite frame around this tab.
        # A Gtk.Box with border_width can expose the parent notebook background in GTK3,
        # so the Performance page uses a painted EventBox background and zero page border.
        bg = Gtk.EventBox()
        bg.set_visible_window(True)
        bg.get_style_context().add_class("content-page")
        bg.get_style_context().add_class("performance-bg")
        outer.add(bg)

        page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
        page.get_style_context().add_class("content-page")
        page.get_style_context().add_class("performance-page")
        page.set_border_width(0)
        page.set_margin_top(6)
        page.set_margin_bottom(6)
        page.set_margin_start(6)
        page.set_margin_end(6)
        bg.add(page)

        header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        cpu_title = Gtk.Label()
        cpu_title.set_markup("<span size='30000'>CPU</span>")
        cpu_title.set_xalign(0)
        header.pack_start(cpu_title, False, False, 0)
        self.cpu_model_label = Gtk.Label(label=cpu_model_name())
        self.cpu_model_label.set_xalign(1)
        header.pack_start(self.cpu_model_label, True, True, 0)
        page.pack_start(header, False, False, 0)

        self.cpu_grid = Gtk.Grid()
        self.cpu_grid.set_row_spacing(8)
        self.cpu_grid.set_column_spacing(8)
        self.cpu_grid.set_hexpand(True)
        page.pack_start(self.cpu_grid, False, False, 0)

        stats = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=32)
        self.util_label = Gtk.Label(label="Utilization\n0%")
        self.util_label.set_xalign(0)
        self.speed_label = Gtk.Label(label="Speed\n0.00 GHz")
        self.speed_label.set_xalign(0)
        self.process_count_label = Gtk.Label(label="Processes\n0")
        self.thread_count_label = Gtk.Label(label="Threads\n0")
        self.handle_count_label = Gtk.Label(label="Handles\nN/A")
        for w in [self.util_label, self.speed_label, self.process_count_label, self.thread_count_label, self.handle_count_label]:
            w.set_margin_top(4)
            w.set_margin_bottom(4)
            stats.pack_start(w, False, False, 0)
        page.pack_start(stats, False, False, 0)

        chart_title = Gtk.Label()
        chart_title.set_markup("<b>Live system graphs</b>")
        chart_title.set_xalign(0)
        page.pack_start(chart_title, False, False, 0)

        for graph in [self.overall_cpu_graph, self.memory_graph, self.network_graph, self.disk_graph]:
            graph.set_size_request(360, 110)
            graph.set_hexpand(True)
            graph.set_vexpand(False)

        resource_grid = Gtk.Grid()
        resource_grid.set_row_spacing(10)
        resource_grid.set_column_spacing(10)
        resource_grid.set_hexpand(True)
        resource_grid.attach(self.overall_cpu_graph, 0, 0, 1, 1)
        resource_grid.attach(self.memory_graph, 1, 0, 1, 1)
        resource_grid.attach(self.network_graph, 0, 1, 1, 1)
        resource_grid.attach(self.disk_graph, 1, 1, 1, 1)
        page.pack_start(resource_grid, False, False, 0)

        self.extra_stats = Gtk.Label(label="")
        self.extra_stats.set_xalign(0)
        self.extra_stats.set_yalign(0)
        self.extra_stats.set_margin_top(6)
        page.pack_start(self.extra_stats, False, False, 0)
        self.notebook.append_page(outer, self.make_tab_label("Performance"))

    def ensure_cpu_graphs(self, count):
        if len(self.cpu_graphs) == count:
            return
        for child in self.cpu_grid.get_children():
            self.cpu_grid.remove(child)
        self.cpu_graphs = []
        # Adaptive CPU thread graph density.
        # Small and medium CPUs stay readable, while 24, 32, 64, and 128 thread CPUs do not create giant rows.
        if count >= 96:
            cols, graph_w, graph_h, gap = 8, 120, 52, 4
        elif count >= 48:
            cols, graph_w, graph_h, gap = 8, 130, 56, 4
        elif count >= 24:
            cols, graph_w, graph_h, gap = 6, 148, 62, 5
        elif count >= 16:
            cols, graph_w, graph_h, gap = 4, 168, 70, 5
        elif count >= 8:
            cols, graph_w, graph_h, gap = 4, 180, 74, 6
        else:
            cols, graph_w, graph_h, gap = 2, 205, 78, 6
        self.cpu_grid.set_row_spacing(gap)
        self.cpu_grid.set_column_spacing(gap)
        for i in range(count):
            g = MiniGraph(f"CPU {i}")
            g.set_size_request(graph_w, graph_h)
            g.set_hexpand(True)
            g.set_theme(self.current_theme)
            self.cpu_graphs.append(g)
            self.cpu_grid.attach(g, i % cols, i // cols, 1, 1)
        self.cpu_grid.show_all()

    def build_services_tab(self):
        page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        page.get_style_context().add_class("content-page")
        page.set_border_width(8)
        toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        toolbar.get_style_context().add_class("toolbar-dark")
        self.service_search = Gtk.SearchEntry()
        self.service_search.set_placeholder_text("Search service name or description")
        self.service_search.connect("search-changed", lambda *_: self.service_filter.refilter())
        toolbar.pack_start(self.service_search, True, True, 0)
        for label, action, klass in [("Start", "start", "safe"), ("Stop", "stop", "danger"), ("Restart", "restart", "warn")]:
            btn = Gtk.Button(label=label)
            btn.get_style_context().add_class(klass)
            btn.connect("clicked", self.service_action, action)
            toolbar.pack_start(btn, False, False, 0)
        refresh = Gtk.Button(label="Refresh")
        refresh.connect("clicked", lambda *_: self.refresh_services())
        toolbar.pack_start(refresh, False, False, 0)
        page.pack_start(toolbar, False, False, 0)

        self.service_store = Gtk.ListStore(str, str, str, str, str)
        self.service_filter = self.service_store.filter_new()
        self.service_filter.set_visible_func(self.service_visible)
        self.service_sort = Gtk.TreeModelSort(model=self.service_filter)
        self.service_tree = Gtk.TreeView(model=self.service_sort)
        self.service_tree.set_headers_visible(True)
        self.service_tree.set_rules_hint(True)
        self.service_tree.get_style_context().add_class("govibe-table")
        self.service_tree.get_selection().set_mode(Gtk.SelectionMode.SINGLE)
        self.service_tree.get_selection().connect("changed", self.on_tree_selection_changed, "service")
        for title, idx in [("Unit", 0), ("Load", 1), ("Active", 2), ("Sub", 3), ("Description", 4)]:
            renderer = self.make_cell_renderer()
            renderer.set_property("ellipsize", Pango.EllipsizeMode.END)
            col = Gtk.TreeViewColumn(title, renderer, text=idx)
            col.set_sort_column_id(idx)
            col.set_resizable(True)
            if title == "Description":
                col.set_expand(True)
            self.service_tree.append_column(col)
        scroller = Gtk.ScrolledWindow()
        scroller.add(self.service_tree)
        self.service_scroller = scroller
        page.pack_start(scroller, True, True, 0)
        self.notebook.append_page(page, self.make_tab_label("Services"))

    def service_visible(self, model, itr, data=None):
        q = self.service_search.get_text().strip().lower()
        if not q:
            return True
        return q in " ".join(str(model[itr][i]) for i in range(5)).lower()

    def build_gpu_tab(self):
        page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        page.get_style_context().add_class("content-page")
        page.set_border_width(8)
        info = Gtk.Label(label="GPU process detection uses nvidia-smi, rocm-smi, and /proc device handles for /dev/dri, /dev/nvidia, and /dev/kfd.")
        info.set_xalign(0)
        page.pack_start(info, False, False, 0)
        toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        toolbar.get_style_context().add_class("toolbar-dark")
        self.gpu_search = Gtk.SearchEntry()
        self.gpu_search.set_placeholder_text("Search GPU process")
        self.gpu_search.connect("search-changed", lambda *_: self.gpu_filter.refilter())
        toolbar.pack_start(self.gpu_search, True, True, 0)
        end = Gtk.Button(label="End GPU Process")
        end.get_style_context().add_class("safe")
        end.connect("clicked", self.kill_selected_gpu, signal.SIGTERM)
        kill = Gtk.Button(label="Force Kill GPU Process")
        kill.get_style_context().add_class("danger")
        kill.connect("clicked", self.kill_selected_gpu, signal.SIGKILL)
        refresh = Gtk.Button(label="Refresh GPU")
        refresh.connect("clicked", lambda *_: self.refresh_gpu(force=True))
        toolbar.pack_start(end, False, False, 0)
        toolbar.pack_start(kill, False, False, 0)
        toolbar.pack_start(refresh, False, False, 0)
        page.pack_start(toolbar, False, False, 0)

        self.gpu_store = Gtk.ListStore(int, str, str, str, str, str)
        self.gpu_filter = self.gpu_store.filter_new()
        self.gpu_filter.set_visible_func(self.gpu_visible)
        self.gpu_sort = Gtk.TreeModelSort(model=self.gpu_filter)
        self.gpu_tree = Gtk.TreeView(model=self.gpu_sort)
        self.gpu_tree.set_headers_visible(True)
        self.gpu_tree.set_rules_hint(True)
        self.gpu_tree.get_style_context().add_class("govibe-table")
        self.gpu_tree.get_selection().set_mode(Gtk.SelectionMode.SINGLE)
        self.gpu_tree.get_selection().connect("changed", self.on_tree_selection_changed, "gpu")
        for title, idx in [("PID", 0), ("User", 1), ("GPU Device", 2), ("GPU Memory", 3), ("System Memory", 4), ("Command", 5)]:
            renderer = self.make_cell_renderer()
            renderer.set_property("ellipsize", Pango.EllipsizeMode.END)
            col = Gtk.TreeViewColumn(title, renderer, text=idx)
            col.set_sort_column_id(idx)
            col.set_resizable(True)
            if title == "Command":
                col.set_expand(True)
            self.gpu_tree.append_column(col)
        scroller = Gtk.ScrolledWindow()
        scroller.add(self.gpu_tree)
        self.gpu_scroller = scroller
        page.pack_start(scroller, True, True, 0)
        self.notebook.append_page(page, self.make_tab_label("GPU Processes"))


    def build_startup_tab(self):
        page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        page.get_style_context().add_class("content-page")
        page.set_border_width(8)
        info = Gtk.Label(label="Control user startup applications from ~/.config/autostart and system startup entries from /etc/xdg/autostart.")
        info.set_xalign(0)
        page.pack_start(info, False, False, 0)

        toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        toolbar.get_style_context().add_class("toolbar-dark")
        self.startup_search = Gtk.SearchEntry()
        self.startup_search.set_placeholder_text("Search startup application")
        self.startup_search.connect("search-changed", lambda *_: self.startup_filter.refilter())
        toolbar.pack_start(self.startup_search, True, True, 0)
        enable = Gtk.Button(label="Enable")
        enable.get_style_context().add_class("safe")
        enable.connect("clicked", self.startup_action, True)
        disable = Gtk.Button(label="Disable")
        disable.get_style_context().add_class("danger")
        disable.connect("clicked", self.startup_action, False)
        refresh = Gtk.Button(label="Refresh")
        refresh.connect("clicked", lambda *_: self.refresh_startup())
        toolbar.pack_start(enable, False, False, 0)
        toolbar.pack_start(disable, False, False, 0)
        toolbar.pack_start(refresh, False, False, 0)
        page.pack_start(toolbar, False, False, 0)

        self.startup_store = Gtk.ListStore(bool, str, str, str, str, str)
        self.startup_filter = self.startup_store.filter_new()
        self.startup_filter.set_visible_func(self.startup_visible)
        self.startup_sort = Gtk.TreeModelSort(model=self.startup_filter)
        self.startup_tree = Gtk.TreeView(model=self.startup_sort)
        self.startup_tree.set_headers_visible(True)
        self.startup_tree.set_rules_hint(True)
        self.startup_tree.get_style_context().add_class("govibe-table")
        self.startup_tree.get_selection().set_mode(Gtk.SelectionMode.SINGLE)
        self.startup_tree.get_selection().connect("changed", self.on_tree_selection_changed, "startup")

        toggle = Gtk.CellRendererToggle()
        toggle.set_property("activatable", True)
        toggle.connect("toggled", self.on_startup_toggle)
        col = Gtk.TreeViewColumn("Enabled", toggle, active=0)
        col.set_sort_column_id(0)
        col.set_resizable(True)
        self.startup_tree.append_column(col)

        for title, idx in [("Name", 1), ("Source", 2), ("Command", 3), ("File", 4)]:
            renderer = self.make_cell_renderer()
            renderer.set_property("ellipsize", Pango.EllipsizeMode.END)
            col = Gtk.TreeViewColumn(title, renderer, text=idx)
            col.set_sort_column_id(idx)
            col.set_resizable(True)
            if title in ("Command", "File"):
                col.set_expand(True)
            self.startup_tree.append_column(col)

        scroller = Gtk.ScrolledWindow()
        scroller.add(self.startup_tree)
        self.startup_scroller = scroller
        page.pack_start(scroller, True, True, 0)
        self.notebook.append_page(page, self.make_tab_label("Startup Applications"))

    def startup_visible(self, model, itr, data=None):
        q = self.startup_search.get_text().strip().lower()
        if not q:
            return True
        return q in " ".join(str(model[itr][i]) for i in range(1, 5)).lower()

    def desktop_entry_data(self, path):
        data = {}
        in_entry = False
        for line in read_text(path).splitlines():
            raw = line.strip()
            if not raw or raw.startswith("#"):
                continue
            if raw.startswith("[") and raw.endswith("]"):
                in_entry = raw == "[Desktop Entry]"
                continue
            if in_entry and "=" in raw:
                key, val = raw.split("=", 1)
                data[key.strip()] = val.strip()
        return data

    def desktop_entry_set(self, text, updates):
        lines = text.splitlines()
        seen = set()
        out = []
        in_entry = False
        inserted = False
        for line in lines:
            raw = line.strip()
            if raw.startswith("[") and raw.endswith("]"):
                if in_entry and not inserted:
                    for key, val in updates.items():
                        if val is not None and key not in seen:
                            out.append(f"{key}={val}")
                    inserted = True
                in_entry = raw == "[Desktop Entry]"
                out.append(line)
                continue
            if in_entry and "=" in raw and not raw.startswith("#"):
                key = raw.split("=", 1)[0].strip()
                if key in updates:
                    seen.add(key)
                    val = updates[key]
                    if val is None:
                        continue
                    out.append(f"{key}={val}")
                    continue
            out.append(line)
        if in_entry and not inserted:
            for key, val in updates.items():
                if val is not None and key not in seen:
                    out.append(f"{key}={val}")
        return "\n".join(out).rstrip() + "\n"

    def startup_entries(self):
        home = os.path.expanduser("~")
        user_dir = os.path.join(home, ".config", "autostart")
        system_dirs = ["/etc/xdg/autostart"]
        entries = {}
        for d in system_dirs:
            if os.path.isdir(d):
                for fname in sorted(os.listdir(d)):
                    if fname.endswith(".desktop"):
                        path = os.path.join(d, fname)
                        entries[fname] = {"path": path, "source": "System"}
        if os.path.isdir(user_dir):
            for fname in sorted(os.listdir(user_dir)):
                if fname.endswith(".desktop"):
                    path = os.path.join(user_dir, fname)
                    source = "User"
                    if fname in entries:
                        source = "User override"
                    entries[fname] = {"path": path, "source": source}
        rows = []
        for fname, meta in sorted(entries.items()):
            path = meta["path"]
            data = self.desktop_entry_data(path)
            if data.get("Type", "Application") != "Application":
                continue
            name = data.get("Name") or data.get("Name[en]") or fname.replace(".desktop", "")
            cmd = data.get("Exec", "")
            hidden = data.get("Hidden", "").lower() == "true"
            autostart_enabled = data.get("X-GNOME-Autostart-enabled", "true").lower() != "false"
            enabled = (not hidden) and autostart_enabled
            rows.append((enabled, name, meta["source"], cmd, path, fname))
        return rows

    def refresh_startup(self):
        state = self.save_tree_state(getattr(self, "startup_tree", None), getattr(self, "startup_scroller", None), 5)
        self.startup_store.clear()
        rows = self.startup_entries()
        for row in rows:
            self.startup_store.append(list(row))
        self.restore_tree_state(getattr(self, "startup_tree", None), getattr(self, "startup_scroller", None), state, 5)
        self.set_status(f"Loaded {len(rows)} startup applications")
        return False

    def set_startup_enabled(self, path, basename, enabled):
        user_dir = os.path.join(os.path.expanduser("~"), ".config", "autostart")
        os.makedirs(user_dir, exist_ok=True)
        target = path
        if not path.startswith(user_dir):
            target = os.path.join(user_dir, basename)
            if not os.path.exists(target):
                try:
                    shutil.copy2(path, target)
                except Exception:
                    text = read_text(path)
                    if text:
                        with open(target, "w", encoding="utf-8") as f:
                            f.write(text)
        text = read_text(target)
        if not text:
            raise RuntimeError("Could not read startup desktop file")
        if enabled:
            updates = {"Hidden": None, "X-GNOME-Autostart-enabled": "true"}
        else:
            updates = {"Hidden": "true", "X-GNOME-Autostart-enabled": "false"}
        new_text = self.desktop_entry_set(text, updates)
        with open(target, "w", encoding="utf-8") as f:
            f.write(new_text)

    def startup_action(self, button, enabled):
        sel = self.startup_tree.get_selection()
        model, itr = sel.get_selected()
        if not itr:
            self.set_status("Select a startup application first")
            return
        path = model[itr][4]
        basename = model[itr][5]
        try:
            self.set_startup_enabled(path, basename, enabled)
            self.set_status(("Enabled " if enabled else "Disabled ") + model[itr][1])
            GLib.timeout_add(250, self.refresh_startup)
        except Exception as e:
            self.error_dialog(str(e))

    def on_startup_toggle(self, renderer, path_string):
        itr = self.startup_sort.get_iter(path_string)
        if not itr:
            return
        enabled = bool(self.startup_sort[itr][0])
        path = self.startup_sort[itr][4]
        basename = self.startup_sort[itr][5]
        try:
            self.set_startup_enabled(path, basename, not enabled)
            GLib.timeout_add(250, self.refresh_startup)
        except Exception as e:
            self.error_dialog(str(e))

    def gpu_visible(self, model, itr, data=None):
        q = self.gpu_search.get_text().strip().lower()
        if not q:
            return True
        return q in " ".join(str(model[itr][i]) for i in range(6)).lower()


    def save_tree_state(self, tree, scroller, id_col=0):
        selected = None
        scroll = 0.0
        try:
            if scroller:
                scroll = scroller.get_vadjustment().get_value()
        except Exception:
            scroll = 0.0
        try:
            if tree:
                model, itr = tree.get_selection().get_selected()
                if itr:
                    selected = str(model[itr][id_col])
        except Exception:
            selected = None
        return {"selected": selected, "scroll": scroll}

    def restore_tree_state(self, tree, scroller, state, id_col=0):
        if not state:
            return False
        selected = state.get("selected")

        # Keep the user's click stable. Selecting a row must not force the
        # view back to the selected PID on every refresh, because that makes
        # the list feel like it jumps under the mouse. We restore the selection
        # silently, then restore the exact scrollbar value after GTK redraws.
        try:
            model = tree.get_model() if tree else None
            if model is not None and selected is not None:
                itr = model.get_iter_first()
                while itr:
                    if str(model[itr][id_col]) == selected:
                        tree.get_selection().select_iter(itr)
                        break
                    itr = model.iter_next(itr)
        except Exception:
            pass

        def restore_scroll():
            try:
                if scroller:
                    adj = scroller.get_vadjustment()
                    upper = max(0.0, adj.get_upper() - adj.get_page_size())
                    adj.set_value(min(float(state.get("scroll", 0.0)), upper))
            except Exception:
                pass
            return False
        GLib.idle_add(restore_scroll)
        return False

    def process_row_values(self, r):
        return [r["pid"], r["user"], r["cpu"], human_bytes(r["rss"]), r["mem_pct"], r["state"], r["threads"], r["cmd"]]

    def update_process_store_stable(self, rows):
        # Do not clear and rebuild the process list on every tick. A rebuild
        # changes row paths and makes the clicked row feel detached from the
        # mouse. Updating rows in place keeps the current visual order, current
        # click selection, and current scroll position stable.
        new_by_pid = {int(r["pid"]): self.process_row_values(r) for r in rows}

        if len(self.proc_store) == 0:
            for r in rows:
                self.proc_store.append(self.process_row_values(r))
            return

        refs = {}
        itr = self.proc_store.get_iter_first()
        while itr:
            try:
                pid = int(self.proc_store[itr][0])
                refs[pid] = Gtk.TreeRowReference.new(self.proc_store, self.proc_store.get_path(itr))
            except Exception:
                pass
            itr = self.proc_store.iter_next(itr)

        for pid, values in new_by_pid.items():
            ref = refs.get(pid)
            path = ref.get_path() if ref else None
            if path:
                try:
                    itr = self.proc_store.get_iter(path)
                    for col, value in enumerate(values):
                        self.proc_store[itr][col] = value
                except Exception:
                    self.proc_store.append(values)
            else:
                self.proc_store.append(values)

        itr = self.proc_store.get_iter_first()
        while itr:
            try:
                pid = int(self.proc_store[itr][0])
            except Exception:
                pid = None
            if pid not in new_by_pid:
                if not self.proc_store.remove(itr):
                    break
            else:
                itr = self.proc_store.iter_next(itr)

    def on_tree_selection_changed(self, selection, kind):
        try:
            model, itr = selection.get_selected()
            if not itr:
                return
            key = str(model[itr][0])
            self.last_selected_kind = kind
            self.last_selected_key = key
            if kind in ("process", "gpu"):
                cmd_col = 7 if kind == "process" else 5
                cmd = str(model[itr][cmd_col])
                self.set_status(f"Selected {kind} PID {key}: {cmd[:120]}")
            elif kind == "service":
                self.set_status(f"Selected service {key}")
            elif kind == "startup":
                self.set_status(f"Selected startup application {model[itr][1]}")
        except Exception:
            pass

    def create_tray_menu(self):
        menu = Gtk.Menu()
        show_item = Gtk.MenuItem(label="Show or hide")
        show_item.connect("activate", lambda *_: self.on_tray_activate(None))
        refresh_item = Gtk.MenuItem(label="Refresh now")
        refresh_item.connect("activate", lambda *_: self.refresh_all())
        autostart_item = Gtk.CheckMenuItem(label="Start at login")
        autostart_item.set_active(self.app_autostart_enabled())
        autostart_item.connect("toggled", self.guarded_autostart_toggled)
        quit_item = Gtk.MenuItem(label="Quit")
        quit_item.connect("activate", lambda *_: self.get_application().quit())
        menu.append(show_item)
        menu.append(refresh_item)
        menu.append(Gtk.SeparatorMenuItem())
        menu.append(autostart_item)
        menu.append(Gtk.SeparatorMenuItem())
        menu.append(quit_item)
        menu.show_all()
        return menu

    def create_tray_icon(self):
        self.indicator_menu = self.create_tray_menu()
        if APPINDICATOR_AVAILABLE and AppIndicator3 is not None:
            try:
                os.makedirs(TRAY_ICON_DIR, exist_ok=True)
                self.indicator = AppIndicator3.Indicator.new(
                    "govibe-process-manager",
                    "govibe-process-manager",
                    AppIndicator3.IndicatorCategory.SYSTEM_SERVICES,
                )
                try:
                    self.indicator.set_icon_theme_path(TRAY_ICON_DIR)
                except Exception:
                    pass
                self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
                self.indicator.set_menu(self.indicator_menu)
                try:
                    self.indicator.set_label("CPU", "CPU")
                except Exception:
                    pass
                return
            except Exception:
                self.indicator = None
        # Fedora/GNOME: avoid deprecated Gtk.StatusIcon fallback because it causes repeated scale-factor warnings.
        self.tray_icon = None

    def on_tray_activate(self, icon):
        if self.get_visible() and self.is_active():
            self.hide()
        else:
            app = self.get_application()
            if app and hasattr(app, "raise_main_window"):
                app.raise_main_window()
            else:
                self.show_all()
                self.present()

    def on_tray_popup(self, icon, button, activate_time):
        if not self.indicator_menu:
            self.indicator_menu = self.create_tray_menu()
        self.indicator_menu.popup(None, None, None, None, button, activate_time)

    def write_tray_svg(self, percent):
        try:
            os.makedirs(TRAY_ICON_DIR, exist_ok=True)
            icon_path = os.path.join(TRAY_ICON_DIR, "govibe-tray-cpu.svg")
            pct = max(0.0, min(100.0, float(percent)))
            t = self.current_theme
            bg = t.get("panel", "#16212b")
            fg = t.get("accent", "#38bdf8")
            text = t.get("text", "#ffffff")
            dash = max(1.0, pct * 1.88)
            svg = f"""<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="{bg}"/>
<circle cx="32" cy="32" r="24" fill="none" stroke="#000000" stroke-opacity="0.18" stroke-width="6"/>
<circle cx="32" cy="32" r="24" fill="none" stroke="{fg}" stroke-width="6" stroke-linecap="round" stroke-dasharray="{dash:.2f} 188" transform="rotate(-90 32 32)"/>
<text x="32" y="34" text-anchor="middle" font-family="Sans,Arial" font-size="18" font-weight="700" fill="{text}">{pct:.0f}</text>
<text x="32" y="49" text-anchor="middle" font-family="Sans,Arial" font-size="9" font-weight="700" fill="{text}">CPU</text>
</svg>"""
            with open(icon_path, "w", encoding="utf-8") as f:
                f.write(svg)
            return icon_path
        except Exception:
            return None

    def update_tray_icon(self, percent):
        self.last_tray_percent = percent
        text = f"CPU {percent:.0f}%"
        icon_path = self.write_tray_svg(percent)
        if self.indicator:
            try:
                if icon_path:
                    try:
                        self.indicator.set_icon_theme_path(TRAY_ICON_DIR)
                    except Exception:
                        pass
                    self.indicator.set_icon_full("govibe-tray-cpu", text)
                try:
                    self.indicator.set_label(f"{percent:.0f}%", text)
                except Exception:
                    pass
                return
            except Exception:
                pass
        if not self.tray_icon:
            return
        try:
            self.tray_icon.set_title(text)
            self.tray_icon.set_tooltip_text(f"Govibe Process Manager  {text}")
        except Exception:
            pass
        try:
            if icon_path:
                pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(icon_path, 64, 64, True)
                self.tray_icon.set_from_pixbuf(pixbuf)
                return
        except Exception:
            pass
        try:
            import cairo
            size = 64
            surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, size, size)
            cr = cairo.Context(surface)
            cr.set_source_rgb(*hex_rgb(self.current_theme.get("panel", "#16212b")))
            cr.paint()
            cr.set_source_rgb(*hex_rgb(self.current_theme.get("accent", "#38bdf8")))
            cr.set_line_width(6)
            cr.arc(size / 2, size / 2, 25, -1.5708, -1.5708 + (6.28318 * max(0.0, min(100.0, percent)) / 100.0))
            cr.stroke()
            cr.set_source_rgb(*hex_rgb(self.current_theme.get("text", "#ffffff")))
            cr.select_font_face("Sans", 0, 1)
            cr.set_font_size(18)
            label = f"{percent:.0f}"
            ext = cr.text_extents(label)
            cr.move_to((size - ext.width) / 2 - ext.x_bearing, 34)
            cr.show_text(label)
            cr.set_font_size(10)
            cr.move_to(22, 48)
            cr.show_text("CPU")
            pixbuf = Gdk.pixbuf_get_from_surface(surface, 0, 0, size, size)
            if pixbuf:
                self.tray_icon.set_from_pixbuf(pixbuf)
        except Exception:
            pass

    def selected_pid_from_tree(self, tree):
        sel = tree.get_selection()
        model, itr = sel.get_selected()
        if not itr:
            return None
        pid = int(model[itr][0])
        if pid <= 0:
            return None
        return pid

    def kill_pid(self, pid, sig):
        try:
            os.kill(pid, sig)
            self.set_status(f"Signal {sig} sent to PID {pid}")
        except PermissionError:
            try:
                privileged_command(["/bin/kill", f"-{int(sig)}", str(pid)])
                self.set_status(f"Requested administrator signal {sig} for PID {pid}")
            except Exception as e:
                self.error_dialog(str(e))
        except ProcessLookupError:
            self.set_status(f"PID {pid} no longer exists")
        except Exception as e:
            self.error_dialog(str(e))

    def kill_selected_process(self, button, sig):
        pid = self.selected_pid_from_tree(self.proc_tree)
        if pid is None:
            self.set_status("Select a process first")
            return
        self.kill_pid(pid, sig)
        GLib.timeout_add(350, self.refresh_all)

    def kill_selected_gpu(self, button, sig):
        pid = self.selected_pid_from_tree(self.gpu_tree)
        if pid is None:
            self.set_status("Select a GPU process first")
            return
        self.kill_pid(pid, sig)
        GLib.timeout_add(350, lambda: self.refresh_gpu(force=True))

    def service_action(self, button, action):
        sel = self.service_tree.get_selection()
        model, itr = sel.get_selected()
        if not itr:
            self.set_status("Select a service first")
            return
        unit = model[itr][0]
        try:
            privileged_command(["/bin/systemctl", action, unit])
            self.set_status(f"Requested {action} for {unit}")
            GLib.timeout_add(1200, self.refresh_services)
        except Exception as e:
            self.error_dialog(str(e))

    def refresh_services(self):
        state = self.save_tree_state(getattr(self, "service_tree", None), getattr(self, "service_scroller", None), 0)
        self.service_store.clear()
        out = run_cmd(["systemctl", "list-units", "--type=service", "--all", "--no-legend", "--no-pager", "--plain"], timeout=6)
        if not out:
            self.service_store.append(["systemd unavailable", "", "", "", "Could not read services on this system"])
            self.restore_tree_state(getattr(self, "service_tree", None), getattr(self, "service_scroller", None), state, 0)
            return False
        for line in out.splitlines():
            parts = line.split(None, 4)
            if len(parts) >= 5:
                self.service_store.append([parts[0], parts[1], parts[2], parts[3], parts[4]])
        self.restore_tree_state(getattr(self, "service_tree", None), getattr(self, "service_scroller", None), state, 0)
        self.set_status(f"Loaded {len(self.service_store)} services")
        return False

    def refresh_gpu(self, force=False):
        now = time.time()
        if not force and now < self.gpu_scan_due:
            return False
        self.gpu_scan_due = now + 6
        state = self.save_tree_state(getattr(self, "gpu_tree", None), getattr(self, "gpu_scroller", None), 0)
        rows = discover_gpu_processes()
        self.gpu_store.clear()
        if rows:
            for r in rows:
                self.gpu_store.append([r["pid"], r["user"], r["devices"], r["gpu_mem"], human_bytes(r["rss"]), r["cmd"]])
        else:
            self.gpu_store.append([-1, "", "No GPU process found", "", "", "OpenGL/Vulkan users will appear here when detected"])
        self.restore_tree_state(getattr(self, "gpu_tree", None), getattr(self, "gpu_scroller", None), state, 0)
        self.sidebar_cards["gpu"].set_text(f"{len(rows)} GPU process" + ("es" if len(rows) != 1 else ""))
        return False

    def first_refresh(self):
        self.refresh_services()
        self.refresh_gpu(force=True)
        self.refresh_startup()
        self.refresh_all()
        return False

    def refresh_all(self):
        curr_total, curr_cores = cpu_snapshots()
        overall = cpu_percent(self.prev_cpu_total, curr_total)
        core_count = len(curr_cores)
        self.ensure_cpu_graphs(core_count)
        for i, snap in enumerate(curr_cores):
            prev = self.prev_core_snaps[i] if i < len(self.prev_core_snaps) else None
            self.cpu_graphs[i].push(cpu_percent(prev, snap))
        self.prev_core_snaps = curr_cores

        total, used, swap_total, swap_used = memory_info()
        mem_pct = (used / total * 100.0) if total else 0.0
        rx, tx = network_totals()
        dr, dw = disk_io_totals()
        now = time.time()
        elapsed = max(0.1, now - self.last_io_time)
        net_rate = 0.0
        disk_rate = 0.0
        if self.last_net:
            net_rate = ((rx - self.last_net[0]) + (tx - self.last_net[1])) / elapsed
        if self.last_disk:
            disk_rate = ((dr - self.last_disk[0]) + (dw - self.last_disk[1])) / elapsed
        self.last_net = (rx, tx)
        self.last_disk = (dr, dw)
        self.last_io_time = now

        self.overall_cpu_graph.push(overall)
        self.memory_graph.push(mem_pct)
        self.network_graph.push(min(100.0, net_rate / (1024 * 1024) * 10.0))
        self.disk_graph.push(min(100.0, disk_rate / (1024 * 1024) * 10.0))

        rows, new_proc_times = list_processes(self.prev_proc_times, self.prev_cpu_total, curr_total)
        self.prev_proc_times = new_proc_times
        self.prev_cpu_total = curr_total

        proc_state = self.save_tree_state(getattr(self, "proc_tree", None), getattr(self, "proc_scroller", None), 0)
        threads = 0
        for r in rows:
            threads += r["threads"]
        self.update_process_store_stable(rows)
        self.proc_filter.refilter()
        self.restore_tree_state(getattr(self, "proc_tree", None), getattr(self, "proc_scroller", None), proc_state, 0)

        mhz = cpu_mhz()
        ghz = mhz / 1000.0 if mhz else 0.0
        self.sidebar_cards["cpu"].set_text(f"{overall:.0f}%  {ghz:.2f} GHz")
        self.sidebar_cards["mem"].set_text(f"{human_bytes(used)} / {human_bytes(total)} ({mem_pct:.0f}%)")
        self.sidebar_cards["net"].set_text(f"RX+TX {human_bytes(net_rate)}/s")
        self.sidebar_cards["disk"].set_text(f"Read+Write {human_bytes(disk_rate)}/s")

        self.util_label.set_text(f"Utilization\n{overall:.0f}%")
        self.speed_label.set_text(f"Speed\n{ghz:.2f} GHz")
        self.process_count_label.set_text(f"Processes\n{len(rows)}")
        self.thread_count_label.set_text(f"Threads\n{threads}")
        self.handle_count_label.set_text("Handles\nN/A")
        virt = "Enabled" if os.path.exists("/sys/module/kvm") else "Unknown"
        self.extra_stats.set_text(
            f"Maximum speed: {ghz:.2f} GHz\n"
            f"Logical processors: {os.cpu_count() or core_count}\n"
            f"Virtualization support: {virt}\n"
            f"Memory: {human_bytes(used)} used of {human_bytes(total)}\n"
            f"Swap: {human_bytes(swap_used)} used of {human_bytes(swap_total)}\n"
            f"Network throughput: {human_bytes(net_rate)}/s\n"
            f"Disk throughput: {human_bytes(disk_rate)}/s\n"
            f"Uptime: {self.uptime_text()}"
        )
        self.set_status(f"Updated {len(rows)} processes")
        self.update_tray_icon(overall)
        self.refresh_gpu(force=False)
        return True

    def uptime_text(self):
        try:
            seconds = float(read_text("/proc/uptime").split()[0])
        except Exception:
            return "Unknown"
        days = int(seconds // 86400)
        seconds %= 86400
        hours = int(seconds // 3600)
        seconds %= 3600
        mins = int(seconds // 60)
        return f"{days}d {hours}h {mins}m"

    def set_status(self, text):
        self.status_label.set_text(text)

    def error_dialog(self, text):
        dialog = Gtk.MessageDialog(
            transient_for=self,
            flags=0,
            message_type=Gtk.MessageType.ERROR,
            buttons=Gtk.ButtonsType.OK,
            text="Govibe Process Manager",
        )
        dialog.format_secondary_text(text)
        dialog.run()
        dialog.destroy()

    def show_about(self, *args):
        about = Gtk.AboutDialog(transient_for=self, modal=True)
        about.set_program_name(APP_NAME)
        about.set_version(APP_VERSION)
        about.set_comments("Process manager for Debian and Ubuntu.")
        about.set_website("https://govibe.org")
        about.run()
        about.destroy()


def main():
    # Keep our launcher options out of GTK argument parsing.
    app = GovibeProcessManager()
    return app.run([sys.argv[0]])


if __name__ == "__main__":
    raise SystemExit(main())
