#!/usr/bin/env bash
# BriteMouse Linux - Self-Contained Installer
# Downloads nothing. The entire app is embedded in this script.
# Usage: sudo bash BriteMouse-linux.sh
set -euo pipefail

INSTALL_DIR="/opt/britemouse"
BIN_LINK="/usr/local/bin/britemouse"
DESKTOP_FILE="/usr/share/applications/britemouse.desktop"

BOLD='\033[1m'
CYAN='\033[0;36m'
GREEN='\033[0;32m'
RED='\033[0;31m'
RESET='\033[0m'

info()    { echo -e "${CYAN}${BOLD}==> $*${RESET}"; }
success() { echo -e "${GREEN}${BOLD}✓  $*${RESET}"; }
error()   { echo -e "${RED}${BOLD}✗  $*${RESET}" >&2; exit 1; }

echo ""
echo -e "${BOLD}  BriteMouse — Linux Installer${RESET}"
echo "  Cursor particle effects for your desktop"
echo ""

[ $EUID -ne 0 ] && error "Run as root: sudo bash BriteMouse-linux.sh"

# ── Install dependencies ─────────────────────────────────────────────────────
install_deps() {
    if command -v apt-get &>/dev/null; then
        info "Detected apt (Debian / Ubuntu / Mint)"
        apt-get update -qq
        DEBIAN_FRONTEND=noninteractive apt-get install -y \
            python3 python3-xlib python3-gi python3-gi-cairo python3-cairo \
            gir1.2-gtk-3.0 gir1.2-gdk-3.0 libgtk-3-0 libcairo2 \
            libxext6 libx11-6
    elif command -v dnf &>/dev/null; then
        info "Detected dnf (Fedora / RHEL)"
        dnf install -y python3 python3-xlib python3-gobject python3-cairo gtk3 libXext libX11
    elif command -v pacman &>/dev/null; then
        info "Detected pacman (Arch Linux)"
        pacman -Sy --noconfirm python python-xlib python-gobject python-cairo gtk3
    elif command -v zypper &>/dev/null; then
        info "Detected zypper (openSUSE)"
        zypper install -y python3 python3-xlib python3-gobject python3-cairo typelib-1_0-Gtk-3_0
    else
        error "Unsupported package manager. Install manually: python3, python3-xlib, python3-gi, python3-gi-cairo, python3-cairo"
    fi
}

info "Installing system dependencies..."
install_deps

info "Verifying Python bindings..."
python3 -c "
from Xlib import X, display as xdisplay
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, Gdk, GLib
import cairo
import ctypes
import ctypes.util
" 2>/dev/null || error "Python bindings failed to import. Check the output above."
success "Xlib + GTK3 + Cairo OK"

# ── Extract app ───────────────────────────────────────────────────────────────
info "Installing BriteMouse to $INSTALL_DIR..."
mkdir -p "$INSTALL_DIR"

# Clean up old corrupted config if it exists
REAL_HOME=$(getent passwd "${SUDO_USER:-$USER}" | cut -d: -f6)
OLD_CONFIG="$REAL_HOME/.config/britemouse/settings.json"
if [ -f "$OLD_CONFIG" ]; then
    info "Removing old configuration to prevent crashes..."
    rm -f "$OLD_CONFIG"
fi

cat > "$INSTALL_DIR/britemouse.py" << 'PYEOF'
#!/usr/bin/env python3
"""
BriteMouse — Linux cursor particle effects

Architecture (back to proven GTK DOCK approach):
  Overlay:  GTK3 DOCK window, ARGB visual, no fullscreen()
  Mouse:    Gdk seat pointer — focus-independent, works on X11/Wayland
  Tray:     AppIndicator3 / AyatanaAppIndicator3
  Settings: GTK3 native window
  Repaint:  win.invalidate_rect() + GLib.PRIORITY_HIGH timer

Why NOT pure Xlib put_image:
  4 monitors × ~60 MB surface × 60 fps = ~14 GB/s socket writes — impossible.
  GTK lets the compositor handle blitting; we only send cairo draw commands.
"""

import sys, os, math, random, time, json, signal, traceback, logging, threading

LOG_DIR = os.path.expanduser("~/.config/britemouse")
os.makedirs(LOG_DIR, exist_ok=True)
LOG_PATH = os.path.join(LOG_DIR, "britemouse.log")
logging.basicConfig(filename=LOG_PATH, level=logging.DEBUG,
                    format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("britemouse")

# ── Display environment detection ────────────────────────────────────────────
# Three cases, two modes:
#
#   PURE X11:       only DISPLAY set                 → GDK_BACKEND=x11
#                   XShape input region for click-through
#
#   XWAYLAND:       both WAYLAND_DISPLAY and DISPLAY  → GDK_BACKEND=wayland  (!)
#                   We use the NATIVE Wayland backend even though X11 is present.
#                   Reason: Mutter/GNOME does NOT honour XShape input regions for
#                   XWayland surfaces. The only click-through mechanism that works
#                   is wl_surface.set_input_region — which GTK calls via
#                   gdk_win.set_pass_through(True) only when backend=wayland.
#                   Gdk seat pointer returns Wayland logical coords that match
#                   the GTK window coords, so particles land correctly too.
#
#   NATIVE WAYLAND: only WAYLAND_DISPLAY              → GDK_BACKEND=wayland
#                   Same as above — wayland backend, set_pass_through.

_HAS_WAYLAND = bool(os.environ.get("WAYLAND_DISPLAY"))
_HAS_X11     = bool(os.environ.get("DISPLAY"))

if _HAS_WAYLAND:
    _DISPLAY_MODE = "xwayland" if _HAS_X11 else "wayland"
    os.environ["GDK_BACKEND"] = "wayland"
    log.info("Mode: %s — using GDK_BACKEND=wayland for click-through", _DISPLAY_MODE)
elif _HAS_X11:
    _DISPLAY_MODE = "x11"
    os.environ.setdefault("GDK_BACKEND", "x11")
    log.info("Mode: pure X11")
else:
    print("ERROR: No DISPLAY or WAYLAND_DISPLAY set.", file=sys.stderr)
    sys.exit(1)

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

# AppIndicator3 (GNOME 40+ tray) — try ayatana first
_IND = None
for _ns in ("AyatanaAppIndicator3", "AppIndicator3"):
    try:
        gi.require_version(_ns, "0.1")
        from gi.repository import AyatanaAppIndicator3 as _IND
        log.info("Tray: %s", _ns); break
    except Exception:
        try:
            from gi.repository import AppIndicator3 as _IND
            log.info("Tray: AppIndicator3"); break
        except Exception:
            pass
if _IND is None:
    log.warning("AppIndicator3 unavailable — no tray icon")

# python-xlib is no longer used for mouse polling.
# Under GDK_BACKEND=x11, Gdk seat pointer queries the X server directly —
# it is focus-independent and already in the correct GTK logical coordinate space.
_XDPY = None
_XROOT = None

# ── Config ────────────────────────────────────────────────────────────────────
CONFIG_PATH = os.path.expanduser("~/.config/britemouse/settings.json")
DEFAULT_CONFIG = {
    "effect": "glow", "color": [0, 255, 255],
    "size": 50, "trail_length": 50, "particle_density": 50,
    "opacity": 80, "fade_speed": 50, "motion_smoothing": 50,
    "glow_intensity": 50, "rainbow_mode": False, "enabled": True,
}

def _norm_color(c):
    if isinstance(c, str):
        c = c.strip().lstrip("#")
        if len(c) == 6: return [int(c[i:i+2], 16) for i in (0,2,4)]
        return list(DEFAULT_CONFIG["color"])
    if isinstance(c, (list, tuple)) and len(c) >= 3: return [int(x) for x in c[:3]]
    return list(DEFAULT_CONFIG["color"])

def load_config():
    try:
        if os.path.exists(CONFIG_PATH):
            with open(CONFIG_PATH) as f:
                cfg = {**DEFAULT_CONFIG, **json.load(f)}
                cfg["color"] = _norm_color(cfg.get("color"))
                return cfg
    except Exception: pass
    return dict(DEFAULT_CONFIG)

def save_config(cfg):
    try:
        os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
        with open(CONFIG_PATH, "w") as f: json.dump(cfg, f, indent=2)
    except Exception: pass

# ── Particle engine ───────────────────────────────────────────────────────────
def lerp(a, b, t): return a + (b - a) * t
def rand(lo, hi):  return random.uniform(lo, hi)

class Particle:
    def __init__(self, x, y, cfg):
        s = cfg["size"] / 50.0
        self.x, self.y = x, y
        a = rand(0, math.pi * 2); v = rand(20, 80) * s
        self.vx, self.vy = math.cos(a)*v, math.sin(a)*v
        self.life  = 1.0
        self.decay = rand(0.5, 2.0) * (cfg["fade_speed"] / 50.0)
        self.size  = rand(2, 6) * s

    def update(self, dt):
        self.x += self.vx*dt; self.y += self.vy*dt
        self.vx *= 0.96;      self.vy *= 0.96
        self.life -= self.decay * dt
        return self.life > 0

class TrailPoint:
    def __init__(self, x, y): self.x, self.y, self.life = x, y, 1.0

class EffectsEngine:
    def __init__(self, cfg):
        self.cfg = cfg
        self.particles = []; self.trail = []
        self.mouse_x = self.mouse_y = 0.0
        self.smooth_x = self.smooth_y = 0.0
        self.prev_x = self.prev_y = 0.0
        self.speed = 0.0; self.rainbow_hue = 0.0
        self.last_time = time.monotonic()
        self._seeded = False  # prevent burst at (0,0) before first mouse reading

    def update_mouse(self, x, y):
        if not self._seeded:
            self.smooth_x = self.prev_x = x
            self.smooth_y = self.prev_y = y
            self._seeded = True
        self.mouse_x, self.mouse_y = x, y

    def _get_color(self):
        try:
            if self.cfg.get("rainbow_mode"):
                h = self.rainbow_hue % 360; h6 = h/60; i = int(h6); f = h6-i; v = 1.0
                p,q,t = v*(1-f), v*(1-f), v*(1-(1-f))
                return [(v,t,p),(q,v,p),(p,v,t),(p,q,v),(t,p,v),(v,p,q)][i%6]
            c = self.cfg.get("color", [0,255,255])
            if isinstance(c, str): c = _norm_color(c)
            return float(c[0])/255, float(c[1])/255, float(c[2])/255
        except Exception: return 0.0, 1.0, 1.0

    def tick(self):
        if not self._seeded: return   # don't spawn until we have a real position
        now = time.monotonic(); dt = min(now - self.last_time, 0.05); self.last_time = now
        s = self.cfg["motion_smoothing"] / 100.0; t = 1.0 - lerp(0.5, 0.05, s)
        self.smooth_x = lerp(self.smooth_x, self.mouse_x, t)
        self.smooth_y = lerp(self.smooth_y, self.mouse_y, t)
        dx = self.smooth_x - self.prev_x; dy = self.smooth_y - self.prev_y
        self.speed = math.sqrt(dx*dx + dy*dy) / max(dt, 0.001)
        self.prev_x, self.prev_y = self.smooth_x, self.smooth_y
        if self.cfg.get("rainbow_mode"): self.rainbow_hue = (self.rainbow_hue + 120*dt) % 360
        sx, sy = self.smooth_x, self.smooth_y
        if self.cfg.get("effect","glow") == "glow":
            mp = int(self.cfg["particle_density"] * 6)
            for _ in range(max(1, int(self.speed*0.05*(self.cfg["particle_density"]/50)))):
                if len(self.particles) < mp: self.particles.append(Particle(sx, sy, self.cfg))
            self.particles = [p for p in self.particles if p.update(dt)]
        else:
            ml = int(self.cfg["trail_length"] * 3)
            self.trail.append(TrailPoint(sx, sy))
            if len(self.trail) > ml: self.trail = self.trail[-ml:]
            for t2 in self.trail: t2.life -= (self.cfg["fade_speed"]/80.0)*dt
            self.trail = [t2 for t2 in self.trail if t2.life > 0]

    def render(self, ctx):
        r, g, b = self._get_color()
        op = self.cfg["opacity"]/100.0; glow = self.cfg["glow_intensity"]*0.6
        effect = self.cfg.get("effect","glow")
        ctx.save(); ctx.set_operator(cairo.OPERATOR_OVER)
        if effect == "glow":
            for p in self.particles:
                a = p.life*p.life*op; sz = p.size*p.life
                if sz <= 0: continue
                ctx.set_source_rgba(r,g,b,a); ctx.arc(p.x,p.y,sz,0,math.pi*2); ctx.fill()
                if glow > 0:
                    gr = cairo.RadialGradient(p.x,p.y,0,p.x,p.y,sz*3)
                    gr.add_color_stop_rgba(0,r,g,b,a*0.4); gr.add_color_stop_rgba(1,r,g,b,0)
                    ctx.set_source(gr); ctx.arc(p.x,p.y,sz*3,0,math.pi*2); ctx.fill()
        elif effect == "neon" and len(self.trail) >= 2:
            wb = (self.cfg["size"]/50.0)*4
            for lw,alpha in [(wb*4,0.08),(wb*2.5,0.15),(wb,0.9)]:
                ctx.set_line_cap(cairo.LINE_CAP_ROUND); ctx.set_line_join(cairo.LINE_JOIN_ROUND)
                ctx.set_line_width(lw); ctx.move_to(self.trail[0].x, self.trail[0].y)
                for pt in self.trail[1:]: ctx.line_to(pt.x, pt.y)
                ctx.set_source_rgba(r,g,b,alpha*op); ctx.stroke()
        ctx.restore()

# ── Overlay window ────────────────────────────────────────────────────────────
class OverlayWindow(Gtk.Window):
    def __init__(self, engine, cfg):
        super().__init__()
        self.engine = engine; self.cfg = cfg

        self.set_decorated(False)
        self.set_app_paintable(True)
        self.set_skip_taskbar_hint(True)
        self.set_skip_pager_hint(True)
        self.set_keep_above(True)
        self.set_accept_focus(False)
        self.set_focus_on_map(False)
        # DOCK: compositor keeps alpha active, WM never intercepts it
        self.set_type_hint(Gdk.WindowTypeHint.DOCK)

        # ARGB visual for true per-pixel transparency
        screen = self.get_screen()
        visual = screen.get_rgba_visual()
        if visual:
            self.set_visual(visual)
        else:
            log.error("No RGBA visual — is a compositor running?")

        # Cover all monitors without calling fullscreen() which strips alpha
        display = Gdk.Display.get_default()
        total_w, total_h, min_x, min_y = 0, 0, 0, 0
        try:
            for i in range(display.get_n_monitors()):
                geo = display.get_monitor(i).get_geometry()
                total_w = max(total_w, geo.x + geo.width)
                total_h = max(total_h, geo.y + geo.height)
                min_x = min(min_x, geo.x); min_y = min(min_y, geo.y)
        except Exception:
            total_w, total_h = screen.get_width(), screen.get_height()
        self.resize(total_w - min_x, total_h - min_y)
        self.move(min_x, min_y)

        self.connect("draw",    self._on_draw)
        self.connect("realize", self._apply_passthrough)
        self.connect("map",     self._apply_passthrough)

        # timeout_add_full isn't exposed in python-gi; plain timeout_add is fine
        # because XQueryPointer + invalidate_rect handle focus-independence directly.
        GLib.timeout_add(16, self._tick)

    def _apply_passthrough(self, _w):
        """Make the overlay fully transparent to mouse/keyboard input.

        Wayland / XWayland (GDK_BACKEND=wayland):
            gdk_win.set_pass_through(True) calls wl_surface.set_input_region
            with an empty region — the correct Wayland mechanism. Mutter/GNOME
            honours this unconditionally. XShape does NOT work here.

        Pure X11 (GDK_BACKEND=x11):
            set_pass_through(True) + python-xlib XShape ShapeInput empty rect.
        """
        gdk_win = self.get_window()
        if not gdk_win:
            return

        # Primary mechanism — works on both wayland and x11 GTK backends
        try:
            gdk_win.set_pass_through(True)
            log.info("set_pass_through(True) OK (mode=%s)", _DISPLAY_MODE)
        except AttributeError:
            log.warning("set_pass_through not available (GTK < 3.18)")

        if _DISPLAY_MODE == "x11":
            # Belt-and-suspenders for pure X11: also set XShape input region
            try:
                import Xlib.display as _XD
                _xdpy = _XD.Display()
                xid   = gdk_win.get_xid()
                _xwin = _xdpy.create_resource_object("window", xid)
                _xwin.shape_rectangles(0, 2, 0, 0, [], 0)  # SO_SET=0, SK_INPUT=2
                _xdpy.flush()
                _xdpy.close()
                log.info("XShape input region cleared (xid=0x%x)", xid)
            except Exception as e:
                log.warning("XShape fallback failed: %s", e)

        # Re-apply after 200ms — Mutter can reset surface properties on first map
        if not getattr(self, "_reapply_scheduled", False):
            self._reapply_scheduled = True
            GLib.timeout_add(200, self._reapply_passthrough)

    def _reapply_passthrough(self):
        self._reapply_scheduled = False
        self._apply_passthrough(None)
        return False

    def _poll_mouse(self):
        """
        Poll mouse via Gdk seat pointer.

        X11/XWayland: Gdk queries the X server directly — fully focus-independent,
                      already in GTK logical coordinate space.
        Native Wayland: Gdk queries the Wayland compositor — also focus-independent
                        under the Wayland protocol.

        Both paths return (x, y) in GTK logical coordinates relative to our
        overlay window origin. No XQueryPointer needed — that would introduce a
        second coordinate space and break on native Wayland entirely.
        """
        if not getattr(self, "_mouse_mode_logged", False):
            log.info("Mouse polling via Gdk seat pointer (mode: %s)", _DISPLAY_MODE)
            self._mouse_mode_logged = True
        try:
            display = Gdk.Display.get_default()
            seat    = display.get_default_seat()
            ptr     = seat.get_pointer()
            result  = ptr.get_position()
            # result is (screen, x, y) or (display, screen, x, y) depending on version
            if len(result) >= 4:
                x, y = float(result[2]), float(result[3])
            elif len(result) >= 3:
                x, y = float(result[1]), float(result[2])
            else:
                return 0.0, 0.0
            wx, wy = self.get_position()
            return x - wx, y - wy
        except Exception as e:
            if not getattr(self, "_ptr_err_logged", False):
                log.warning("Gdk pointer error: %s", e)
                self._ptr_err_logged = True
            return 0.0, 0.0

    def _tick(self):
        if not self.get_realized(): return True
        mx, my = self._poll_mouse()
        self.engine.update_mouse(mx, my)
        if self.cfg.get("enabled", True):
            self.engine.tick()
        gdk_win = self.get_window()
        if gdk_win:
            gdk_win.invalidate_rect(None, False)   # force compositor repaint
        return True  # keep timer running

    def _on_draw(self, _w, ctx):
        # Clear to fully transparent first
        ctx.set_operator(cairo.OPERATOR_SOURCE)
        ctx.set_source_rgba(0, 0, 0, 0)
        ctx.paint()
        ctx.set_operator(cairo.OPERATOR_OVER)
        if self.cfg.get("enabled", True):
            self.engine.render(ctx)
        return False

# ── Settings window ───────────────────────────────────────────────────────────
class SettingsWindow(Gtk.Window):
    def __init__(self, cfg, on_change):
        super().__init__(title="BriteMouse Settings")
        self.cfg = cfg; self.on_change = on_change
        self.set_default_size(420, 540)
        self.set_resizable(False)
        self.set_position(Gtk.WindowPosition.CENTER)
        self.set_keep_above(True)
        self._build()
        self.show_all()

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

        hdr = Gtk.Box(spacing=8)
        hdr.set_margin_top(18); hdr.set_margin_bottom(14)
        hdr.set_margin_start(20); hdr.set_margin_end(20)
        lbl = Gtk.Label(); lbl.set_markup('<span size="large" weight="bold">BriteMouse</span>')
        lbl.set_halign(Gtk.Align.START); hdr.pack_start(lbl, True, True, 0)
        outer.pack_start(hdr, False, False, 0)
        outer.pack_start(Gtk.Separator(), False, False, 0)

        body = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
        body.set_margin_top(14); body.set_margin_bottom(14)
        body.set_margin_start(20); body.set_margin_end(20)
        outer.pack_start(body, True, True, 0)

        self._sw("Enable Effects", "enabled", body)

        er = Gtk.Box(spacing=8)
        el = Gtk.Label(label="Effect"); el.set_hexpand(True); el.set_halign(Gtk.Align.START)
        combo = Gtk.ComboBoxText()
        combo.append("glow","Glow Particles"); combo.append("neon","Neon Line")
        combo.set_active_id(self.cfg.get("effect","glow"))
        combo.connect("changed", lambda cb: self._set("effect", cb.get_active_id()))
        er.pack_start(el, True, True, 0); er.pack_end(combo, False, False, 0)
        body.pack_start(er, False, False, 0)

        for key,lbl_txt,lo,hi in [
            ("size","Size",10,100),("trail_length","Trail Length",10,100),
            ("particle_density","Particle Density",10,100),("opacity","Opacity",10,100),
            ("fade_speed","Fade Speed",10,100),("motion_smoothing","Motion Smoothing",0,100),
            ("glow_intensity","Glow Intensity",0,100),
        ]:
            self._slider(lbl_txt, key, self.cfg.get(key, 50), lo, hi, body)

        self._sw("Rainbow Mode", "rainbow_mode", body)
        outer.pack_start(Gtk.Separator(), False, False, 0)

        br = Gtk.Box(spacing=8); br.set_margin_top(10); br.set_margin_bottom(10)
        br.set_margin_start(20); br.set_margin_end(20)
        btn = Gtk.Button(label="Save & Close")
        btn.connect("clicked", lambda _: (save_config(self.cfg), self.destroy()))
        btn.get_style_context().add_class("suggested-action")
        br.pack_end(btn, False, False, 0)
        outer.pack_start(br, False, False, 0)

    def _sw(self, txt, key, parent):
        v = Gtk.BooleanVar() if False else None
        sw = Gtk.Switch(); sw.set_active(bool(self.cfg.get(key, False)))
        row = Gtk.Box(spacing=8)
        lb  = Gtk.Label(label=txt); lb.set_hexpand(True); lb.set_halign(Gtk.Align.START)
        sw.connect("notify::active", lambda s,_,k=key: self._set(k, s.get_active()))
        row.pack_start(lb, True, True, 0); row.pack_end(sw, False, False, 0)
        parent.pack_start(row, False, False, 0)

    def _slider(self, txt, key, val, lo, hi, parent):
        row = Gtk.Box(spacing=8)
        lb  = Gtk.Label(label=txt); lb.set_size_request(150,-1); lb.set_halign(Gtk.Align.START)
        adj = Gtk.Adjustment(value=val, lower=lo, upper=hi, step_increment=1)
        sc  = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj)
        sc.set_hexpand(True); sc.set_draw_value(True); sc.set_digits(0)
        sc.set_value_pos(Gtk.PositionType.RIGHT)
        sc.connect("value-changed", lambda s,k=key: self._set(k, int(s.get_value())))
        row.pack_start(lb, False, False, 0); row.pack_start(sc, True, True, 0)
        parent.pack_start(row, False, False, 0)

    def _set(self, key, val):
        self.cfg[key] = val; self.on_change()

# ── App ───────────────────────────────────────────────────────────────────────
class App:
    def __init__(self):
        self.cfg     = load_config()
        self.engine  = EffectsEngine(self.cfg)
        self.overlay = OverlayWindow(self.engine, self.cfg)
        self.overlay.show_all()
        self._settings_win = None
        self._setup_tray()
        signal.signal(signal.SIGINT,  lambda *_: self._quit())
        signal.signal(signal.SIGTERM, lambda *_: self._quit())

    def _setup_tray(self):
        if _IND is None:
            log.warning("No tray — AppIndicator3 unavailable")
            return
        self._ind = _IND.Indicator.new(
            "britemouse", "input-mouse",
            _IND.IndicatorCategory.APPLICATION_STATUS)
        self._ind.set_status(_IND.IndicatorStatus.ACTIVE)
        self._ind.set_menu(self._build_menu())

    def _build_menu(self):
        menu = Gtk.Menu()
        tog = Gtk.CheckMenuItem(label="Effects Enabled")
        tog.set_active(self.cfg.get("enabled", True))
        tog.connect("toggled", self._on_toggle)
        menu.append(tog)
        menu.append(Gtk.SeparatorMenuItem())
        s = Gtk.MenuItem(label="Settings...")
        s.connect("activate", lambda _: self._open_settings())
        menu.append(s)
        menu.append(Gtk.SeparatorMenuItem())
        q = Gtk.MenuItem(label="Exit")
        q.connect("activate", lambda _: self._quit())
        menu.append(q)
        menu.show_all()
        return menu

    def _on_toggle(self, item):
        self.cfg["enabled"] = item.get_active(); save_config(self.cfg)

    def _open_settings(self):
        if self._settings_win and self._settings_win.get_visible():
            self._settings_win.present(); return
        def on_change():
            self.engine.cfg = self.cfg
            self.engine.particles = []; self.engine.trail = []
        self._settings_win = SettingsWindow(self.cfg, on_change)

    def _quit(self):
        save_config(self.cfg); Gtk.main_quit()

    def run(self):
        Gtk.main()

def main():
    log.info("BriteMouse starting (GTK DOCK + Gdk seat pointer)")
    try:
        screen = Gdk.Screen.get_default()
        if screen and not screen.is_composited():
            print("ERROR: No compositor running. BriteMouse needs one for transparency.", file=sys.stderr)
            print("  sudo apt install picom && picom -b", file=sys.stderr)
            sys.exit(1)
    except Exception: pass
    try:
        App().run()
    except Exception as e:
        log.error("Fatal: %s\n%s", e, traceback.format_exc())
        print(f"BriteMouse crashed: {e}\nSee: {LOG_PATH}", file=sys.stderr)
        sys.exit(1)

if __name__ == "__main__":
    main()

PYEOF
chmod +x "$INSTALL_DIR/britemouse.py"
success "App written to $INSTALL_DIR/britemouse.py"

# ── Launcher ─────────────────────────────────────────────────────────────────
info "Creating launcher at $BIN_LINK..."
cat > "$BIN_LINK" << 'EOF'
#!/usr/bin/env bash
exec python3 /opt/britemouse/britemouse.py "$@"
EOF
chmod +x "$BIN_LINK"
success "Launcher created"

# ── Desktop entry ─────────────────────────────────────────────────────────────
info "Creating application menu entry..."
cat > "$DESKTOP_FILE" << 'EOF'
[Desktop Entry]
Name=BriteMouse
Comment=Cursor particle effects for your Linux desktop
Exec=/usr/local/bin/britemouse
Icon=input-mouse
Terminal=false
Type=Application
Categories=Utility;Accessibility;
Keywords=cursor;mouse;effects;particles;
StartupNotify=false
EOF
command -v update-desktop-database &>/dev/null && update-desktop-database /usr/share/applications 2>/dev/null || true
success "Application menu entry created"

# ── Write uninstaller ─────────────────────────────────────────────────────────
cat > "$INSTALL_DIR/uninstall.sh" << 'UNEOF'
#!/usr/bin/env bash
set -euo pipefail
echo "Removing BriteMouse..."
rm -f /usr/local/bin/britemouse
rm -f /usr/share/applications/britemouse.desktop
rm -rf /opt/britemouse
REAL_HOME=$(getent passwd "${SUDO_USER:-$USER}" | cut -d: -f6)
rm -f "$REAL_HOME/.config/autostart/britemouse.desktop"
echo "BriteMouse removed."
UNEOF
chmod +x "$INSTALL_DIR/uninstall.sh"

# ── Autostart ─────────────────────────────────────────────────────────────────
echo ""
read -r -p "  Start BriteMouse automatically at login? [y/N] " autostart_answer
if [[ "${autostart_answer,,}" == "y" ]]; then
    REAL_USER="${SUDO_USER:-$USER}"
    REAL_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6)
    mkdir -p "$REAL_HOME/.config/autostart"
    cp "$DESKTOP_FILE" "$REAL_HOME/.config/autostart/britemouse.desktop"
    chown "$REAL_USER":"$REAL_USER" "$REAL_HOME/.config/autostart/britemouse.desktop"
    success "Autostart enabled for $REAL_USER"
fi

# ── Done ─────────────────────────────────────────────────────────────────────
echo ""
echo -e "${GREEN}${BOLD}  Installation complete!${RESET}"
echo ""
echo "  Run now:     britemouse"
echo "  Or launch from your applications menu."
echo "  Left-click tray icon to open Settings."
echo "  Right-click tray icon for menu (Toggle / Settings / Exit)."
echo ""
echo "  To uninstall: sudo bash /opt/britemouse/uninstall.sh"
echo ""

read -r -p "  Launch BriteMouse now? [Y/n] " launch_answer
if [[ "${launch_answer,,}" != "n" ]]; then
    REAL_USER="${SUDO_USER:-$USER}"
    REAL_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6)
    BM_LOG="$REAL_HOME/.config/britemouse/britemouse.log"
    if [[ -n "${SUDO_USER:-}" ]]; then
        su - "$REAL_USER" -c "DISPLAY=${DISPLAY:-:0} XAUTHORITY=${XAUTHORITY:-$REAL_HOME/.Xauthority} /usr/local/bin/britemouse" &
    else
        /usr/local/bin/britemouse &
    fi
    BM_PID=$!
    sleep 2
    if kill -0 "$BM_PID" 2>/dev/null; then
        echo "  BriteMouse started. Look for the mouse icon in your system tray."
    else
        echo ""
        echo -e "${RED}${BOLD}  BriteMouse failed to start.${RESET}"
        if [[ -f "$BM_LOG" ]]; then
            echo "  Last log entries:"
            tail -10 "$BM_LOG" | sed 's/^/    /'
        fi
        echo ""
        echo "  Try running manually:  britemouse"
        echo "  Full log at: $BM_LOG"
    fi
fi
