#!/bin/bash
# Limit'Écran — installation sur un ordinateur Linux.
# Usage (depuis l'espace parent, onglet Appareils) :
#   curl -fsSL https://www.limitecran.fr/linux/install.sh | sudo bash -s -- CODE
# Désinstallation : sudo limitecran-agent uninstall
set -euo pipefail

CODE="${1:-}"
SERVER="${LIMITECRAN_SERVER:-https://www.limitecran.fr}"
BIN=/usr/local/bin/limitecran-agent
UNIT=/etc/systemd/system/limitecran-agent.service

say() { printf '\n\033[1m%s\033[0m\n' "$*"; }
die() { printf '\n\033[31mErreur : %s\033[0m\n' "$*" >&2; exit 1; }
ask() { local v; read -r -p "$1" v </dev/tty || true; printf '%s' "$v"; }

[ "$(id -u)" -eq 0 ] || die "lancez la commande avec sudo."
command -v systemctl >/dev/null && command -v loginctl >/dev/null || die "cette distribution n'utilise pas systemd : elle n'est pas prise en charge."
command -v python3 >/dev/null || die "Python 3 est nécessaire (paquet python3)."
python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)' || die "Python 3.8 ou plus récent est nécessaire."

# Paquets selon la famille de Linux : apt (Debian, Ubuntu, Mint, PrimTux), dnf (Fedora), zypper (openSUSE), pacman (Arch, Manjaro).
APT_UPDATED=0
install_pkg() { # $1 apt, $2 dnf, $3 zypper, $4 pacman
  if command -v apt-get >/dev/null; then
    [ "$APT_UPDATED" = 1 ] || { apt-get update -qq >/dev/null 2>&1 || true; APT_UPDATED=1; }
    DEBIAN_FRONTEND=noninteractive apt-get install -y -q "$1" >/dev/null 2>&1
  elif command -v dnf >/dev/null; then dnf install -y -q "$2" >/dev/null 2>&1
  elif command -v zypper >/dev/null; then zypper -q -n install "$3" >/dev/null 2>&1
  elif command -v pacman >/dev/null; then pacman -S --noconfirm --needed "$4" >/dev/null 2>&1
  else return 1; fi
}
has_chrome() {
  for b in google-chrome google-chrome-stable chromium chromium-browser brave-browser microsoft-edge microsoft-edge-stable; do
    command -v "$b" >/dev/null && return 0
  done
  return 1
}
say "Préparation…"
command -v nft >/dev/null || install_pkg nftables nftables nftables nftables || echo "  nftables absent : le blocage des sites pour adultes ne pourra pas s'appliquer."
command -v notify-send >/dev/null || install_pkg libnotify-bin libnotify libnotify-tools libnotify || echo "  notify-send absent : pas d'avertissement avant la fin du temps."
python3 -c 'import tkinter' 2>/dev/null || install_pkg python3-tk python3-tkinter python3-tk tk || echo "  Tkinter absent : la fenêtre de l'enfant ne s'ouvrira pas."
command -v xdg-user-dir >/dev/null || install_pkg xdg-user-dirs xdg-user-dirs xdg-user-dirs xdg-user-dirs || true
if ! has_chrome; then
  echo
  echo "Les sites libres (Pronote, ENT…) s'ouvrent dans Chromium, Google Chrome, Brave ou Edge : aucun n'est installé."
  case "$(ask "Installer Chromium maintenant ? [O/n] ")" in
    n|N|non|Non|NON) echo "  Sans Chromium, les sites libres ne s'ouvriront pas (le reste fonctionne)." ;;
    *) install_pkg chromium chromium chromium chromium || install_pkg chromium-browser chromium-browser chromium chromium || echo "  Chromium n'a pas pu être installé : installez Chromium ou Google Chrome à la main." ;;
  esac
fi

say "Installation de l'agent Limit'Écran…"
cat > "$BIN.tmp" <<'LIMITECRAN_AGENT_FIN'
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Limit'Écran — agent Linux (ordinateur de l'enfant).

Service systemd lancé en root. Il fait sur un ordinateur ce que l'appli Android fait sur un téléphone :
- le temps d'écran s'écoule quand la session de l'enfant est ouverte et active (pas verrouillée) ;
- plus de temps, couvre-feu, tâches obligatoires ou pause d'urgence : la session de l'enfant est verrouillée ;
- les applications bloquées par le parent sont fermées dès qu'elles s'ouvrent ;
- filtrage Internet réglé par le parent : sites pour adultes (DNS familial Cloudflare), recherche sécurisée,
  sites bloqués (fichier hosts + règles des navigateurs Firefox, Chrome, Chromium, Brave, Edge) ;
- hors ligne, le couvre-feu et les créneaux s'appliquent avec le dernier état connu, le temps est décompté localement.

Historique de navigation : seul le nom des sites visités est relevé (jamais l'adresse des pages). Le contenu de l'écran n'est jamais lu.
Uniquement la bibliothèque standard de Python (3.8 ou plus récent).
"""
import argparse
import datetime as dt
import errno
import glob
import grp
import hashlib
import json
import os
import pwd
import re
import select
import shlex
import shutil
import signal
import socket
import subprocess
import struct
import sys
import threading
import time
import uuid
import urllib.error
import urllib.request

try:
    from zoneinfo import ZoneInfo
except ImportError:  # Python 3.8 (Ubuntu 20.04, Debian 11) : heure de Paris calculée à la main, voir _Paris
    ZoneInfo = None

VERSION = "0.6.5"
IS_MAC = sys.platform == "darwin"  # le même agent tourne sous Linux et macOS (couche système différente, voir « macOS »)
DEFAULT_SERVER = "https://www.limitecran.fr"
CONSENT_VERSION = "2026-11"
SESSION_PACKAGE = "linux.session"

CONF_DIR = "/etc/limitecran"
CONF_FILE = CONF_DIR + "/agent.json"
STATE_DIR = "/var/lib/limitecran"
CACHE_FILE = STATE_DIR + "/state.json"
APPLIED_FILE = STATE_DIR + "/applied.json"
STATUS_FILE = STATE_DIR + "/status.json"
HOSTS_FILE = "/etc/hosts"
NFT_TABLE = "limitecran"
RUN_DIR = "/var/run/limitecran" if IS_MAC else "/run/limitecran"
CHILD_SOCKET = RUN_DIR + "/enfant.sock"
# /usr/local : modifiable partout, même sur les systèmes en lecture seule (Fedora Silverblue…), et lu par tous les bureaux
LAUNCHER_FILE = "/usr/local/share/applications/limitecran.desktop"
ICON_FILE = "/usr/local/share/icons/hicolor/scalable/apps/limitecran.svg"
OLD_LAUNCHER_FILES = ("/usr/share/applications/limitecran.desktop", "/usr/share/icons/hicolor/scalable/apps/limitecran.svg")
TIME_REQUEST_MINUTES = (10, 20, 30)
FREE_PROXY_PORT = 47811  # filtre local du navigateur des sites libres (127.0.0.1 seulement)
FREE_PROFILE_NAME = "limitecran-sites-libres"
BLOCK_GRACE_SECONDS = 30  # temps fini : les applications limitées se ferment après cet avertissement
UPDATE_EVERY = 24 * 3600
AGENT_BIN = "/usr/local/bin/limitecran-agent"
# Exécutables trop généraux pour désigner une application (on ne les ferme jamais par leur nom).
GENERIC_EXEC = {"sh", "bash", "env", "python", "python3", "perl", "xdg-open", "gtk-launch", "flatpak", "snap", "limitecran-agent"}
# Programmes du bureau lui-même (barres, bureau, fenêtres, fichiers, son, réseau) : jamais comptés ni fermés, même si
# une entrée du menu les lance (ex. « Préférences du panneau » → xfce4-panel).
DESKTOP_EXEC = {
    "xfce4-panel", "xfdesktop", "xfwm4", "xfce4-session", "xfsettingsd", "xfce4-notifyd", "xfce4-power-manager", "xfconfd", "thunar", "Thunar",
    "lxpanel", "lxpanelctl", "lxsession", "lxqt-panel", "lxqt-session", "pcmanfm", "pcmanfm-qt", "openbox", "tint2", "plank", "conky", "fbpanel",
    "mate-panel", "mate-session", "marco", "caja", "cinnamon", "cinnamon-session", "nemo", "nemo-desktop", "muffin",
    "gnome-shell", "gnome-session", "gnome-panel", "nautilus", "nautilus-desktop", "mutter", "metacity",
    "plasmashell", "kwin_x11", "kwin_wayland", "krunner", "dolphin", "ksmserver", "budgie-panel", "budgie-wm",
    "Xorg", "Xwayland", "dbus-daemon", "pulseaudio", "pipewire", "nm-applet", "blueman-applet", "polkit-gnome-authentication-agent-1",
}

TICK_SECONDS = 15
STATE_EVERY = 30
HEARTBEAT_EVERY = 300
FILTER_CHECK_EVERY = 60
MAX_OFFLINE = 14_400

ADMIN_GROUPS = ("sudo", "wheel", "admin")
HOSTS_BEGIN = "# >>> Limit'Écran (ne pas modifier : rétabli automatiquement)"
HOSTS_END = "# <<< Limit'Écran"

# DNS familial de Cloudflare : bloque logiciels malveillants et contenus pour adultes.
FAMILY_DNS4 = "1.1.1.3"
FAMILY_DNS6 = "2606:4700:4700::1113"

# Recherche sécurisée : ces noms répondent avec l'adresse de la version filtrée du moteur.
SAFE_SEARCH = {
    "forcesafesearch.google.com": ["google.com", "www.google.com", "google.fr", "www.google.fr", "google.be", "www.google.be", "google.ch", "www.google.ch", "google.ca", "www.google.ca"],
    "restrict.youtube.com": ["www.youtube.com", "m.youtube.com", "youtube.com", "youtubei.googleapis.com", "youtube.googleapis.com", "www.youtube-nocookie.com"],
    "strict.bing.com": ["www.bing.com", "bing.com"],
    "safe.duckduckgo.com": ["duckduckgo.com", "www.duckduckgo.com"],
}

CHROMIUM_POLICY_DIRS = [
    "/etc/opt/chrome/policies/managed",
    "/etc/chromium/policies/managed",
    "/etc/chromium-browser/policies/managed",
    "/etc/brave/policies/managed",
    "/etc/opt/edge/policies/managed",
]
FIREFOX_POLICY_FILES = ["/etc/firefox/policies/policies.json", "/etc/firefox-esr/policies/policies.json"]
FIREFOX_INSTALL_DIRS = ["/usr/lib/firefox", "/usr/lib64/firefox", "/usr/lib/firefox-esr", "/opt/firefox"]

DESKTOP_DIRS = [
    "/usr/share/applications",
    "/usr/local/share/applications",
    "/var/lib/flatpak/exports/share/applications",
    "/var/lib/snapd/desktop/applications",
]
# Outils du système : jamais bloqués, signalés « système » au parent.
SYSTEM_PREFIXES = ("org.gnome.Settings", "gnome-", "org.kde.", "xfce", "mate-", "cinnamon-", "nm-", "polkit", "ibus", "yelp", "org.freedesktop.")
# Gestionnaires de fichiers : ils dessinent souvent le bureau, on ne les ferme jamais.
SYSTEM_APPS = {"org.gnome.Nautilus", "nautilus", "thunar", "nemo", "caja", "pcmanfm", "pcmanfm-qt", "org.kde.dolphin", "dolphin"}



class _Paris(dt.tzinfo):
    """Heure de Paris sans zoneinfo : UTC+1, UTC+2 du dernier dimanche de mars au dernier dimanche d'octobre (1 h UTC)."""

    @staticmethod
    def _last_sunday(year, month):
        d = dt.datetime(year, month, 31 if month in (3, 10) else 30)
        return d - dt.timedelta(days=(d.weekday() + 1) % 7)

    def _summer_utc(self, utc_naive):
        start = self._last_sunday(utc_naive.year, 3).replace(hour=1)
        end = self._last_sunday(utc_naive.year, 10).replace(hour=1)
        return start <= utc_naive < end

    def utcoffset(self, d):
        if d is None:
            return dt.timedelta(hours=1)
        # approximation locale → UTC suffisante (seule l'heure du changement est ambiguë)
        return dt.timedelta(hours=2 if self._summer_utc(d.replace(tzinfo=None) - dt.timedelta(hours=1)) else 1)

    def dst(self, d):
        return self.utcoffset(d) - dt.timedelta(hours=1)

    def fromutc(self, d):
        utc = d.replace(tzinfo=None)
        return (utc + dt.timedelta(hours=2 if self._summer_utc(utc) else 1)).replace(tzinfo=self)

    def tzname(self, d):
        return "CEST" if self.dst(d) else "CET"


def _paris_zone():
    if ZoneInfo:
        try:
            return ZoneInfo("Europe/Paris")
        except Exception:  # noqa: BLE001 - base des fuseaux absente
            pass
    return _Paris()


PARIS = _paris_zone()


# Lignes à envoyer au serveur (journal de diagnostic lisible par les parents, onglet Appareils) ; bornées en mémoire.
REMOTE_LOG = []
REMOTE_LOG_MAX = 300
LOGS_EVERY = 300
ERROR_WORDS = ("erreur", "impossible", "refus", "non envoy", "non lu", "modifié")


def log(*parts, remote=True):
    text = " ".join(str(p) for p in parts)
    print("[limitecran]", text, flush=True)
    if not remote:
        return
    level = "erreur" if any(w in text.lower() for w in ERROR_WORDS) else "info"
    last = REMOTE_LOG[-1] if REMOTE_LOG else None
    if last and last["message"] == text[:300]:
        return  # même ligne répétée (réseau coupé…) : une seule fois
    REMOTE_LOG.append({"at": dt.datetime.now(dt.timezone.utc).isoformat(), "level": level, "message": text[:300]})
    del REMOTE_LOG[:-REMOTE_LOG_MAX]


# ─────────────────────────────── Règles (sans système : testées à part) ───────────────────────────────


def paris_now(ts=None):
    t = time.time() if ts is None else ts
    return dt.datetime.fromtimestamp(t, PARIS)


def in_range(t, start, end):
    """`t` dans [start, end[ (« HH:MM ») ; une plage dont la fin précède le début passe minuit ; start == end : jamais."""
    t, f, e = t[:5], start[:5], end[:5]
    if f == e:
        return False
    return f <= t < e if f < e else (t >= f or t < e)


def current_slot(windows, t):
    """Créneau en cours (même règle que le serveur et l'appli Android) ; None sans horaires."""
    if not windows:
        return None
    t = t[:5]
    ws = sorted(windows, key=lambda w: w["startsAt"][:5])
    inside = next((w for w in ws if w["kind"] == "autre" and in_range(t, w["startsAt"], w["endsAt"])), None) or next(
        (w for w in ws if in_range(t, w["startsAt"], w["endsAt"])), None
    )
    if inside:
        return inside["kind"]
    return (next((w for w in ws if t < w["startsAt"][:5]), None) or ws[-1])["kind"]


def task_started(task, hhmm):
    """Horaire d'une tâche (ex. 20:00 → 21:00) : commencée à l'heure `hhmm` ? Sans heure de début, toujours (même règle que le serveur)."""
    s = (task.get("startTime") or "")[:5]
    if not s:
        return True
    e = (task.get("deadlineTime") or "")[:5]
    if e and e < s:
        return hhmm >= s or hhmm < e
    return hhmm >= s


def mandatory_remaining(state, now):
    """Tâches obligatoires non validées du créneau en cours (jour de l'état ou lendemain connu) ; 0 pour un jour inconnu."""
    schedule = state.get("schedule") or {}
    date = now.date().isoformat()
    hhmm = now.strftime("%H:%M")
    tomorrow = state.get("tomorrow") or {}
    day_date = (state.get("day") or {}).get("date")
    # Après minuit, tant qu'un créneau de la veille n'est pas fini (soir 19:00 → 05:00), la journée reste celle de la veille.
    overnight = is_overnight_tail(day_date, now.date(), schedule.get("slots") or [], hhmm)
    if overnight or date == day_date:
        tasks, windows = state.get("tasks") or [], schedule.get("slots") or []
    elif tomorrow and date == tomorrow.get("date"):
        tasks, windows = tomorrow.get("tasks") or [], tomorrow.get("slots") or schedule.get("slots") or []
    else:
        return 0
    slot = current_slot(windows, hhmm)
    if slot is None:
        if not overnight and date != day_date:
            return 0
        slot = state["day"].get("slot")
    # Tous les moments dont la plage contient l'heure comptent (une plage « Goûter » ne suspend pas l'après-midi).
    active = {w["kind"] for w in windows if in_range(hhmm, w["startsAt"], w["endsAt"])} | {slot}
    return sum(1 for t in tasks if t.get("mandatory") and t.get("slot") in active and t.get("status") != "validee" and (overnight or task_started(t, hhmm)))


def is_overnight_tail(day_date, today, windows, hhmm):
    """Vrai si `today` est le lendemain de la journée de l'état et qu'un de ses créneaux, qui passe minuit, n'est pas fini."""
    try:
        day = dt.date.fromisoformat(day_date or "")
    except ValueError:
        return False
    return today == day + dt.timedelta(days=1) and any(w["startsAt"][:5] > w["endsAt"][:5] and hhmm < w["endsAt"][:5] for w in windows)


def effective_access(state, offline, now, remaining_seconds):
    """
    Accès effectif (allowed, reason). En ligne, un refus du serveur fait foi ; l'heure qui tourne peut ajouter un couvre-feu
    ou des tâches obligatoires. Hors ligne : mêmes règles sur l'heure de l'ordinateur ; la pause d'urgence connue reste ;
    le temps restant est celui décompté localement.
    """
    access = state.get("access") or {"allowed": False, "reason": None}
    if (state.get("protection") or {}).get("removed"):
        return True, None
    if not offline and not access.get("allowed"):
        return False, access.get("reason")
    if access.get("reason") == "locked":
        return False, "locked"
    schedule = state.get("schedule")
    if schedule:
        hm = now.strftime("%H:%M")
        curfew = schedule.get("curfew")
        if curfew and in_range(hm, curfew["from"], curfew["to"]):
            return False, "curfew"
        if schedule.get("blockingRule") and mandatory_remaining(state, now) > 0:
            return False, "mandatory_tasks"
    if remaining_seconds <= 0:
        return False, "no_time"
    return True, None


REASON_TEXT = {
    "no_time": "Ton temps d'écran est fini pour l'instant. Gagne des Limis en faisant tes tâches.",
    "curfew": "C'est l'heure du couvre-feu : l'ordinateur se repose.",
    "mandatory_tasks": "Termine d'abord tes tâches obligatoires, puis l'écran se débloque.",
    "locked": "Un parent a mis l'écran en pause.",
}


# ─────────────────────────────── Filtrage Internet (rendu des fichiers) ───────────────────────────────

_DOMAIN = re.compile(r"^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{1,62}$")


def clean_filter(f):
    f = f or {}
    def domains(key):
        return sorted({d for d in (f.get(key) or []) if isinstance(d, str) and _DOMAIN.match(d)})

    return {"adult": f.get("adult") is True, "safeSearch": f.get("safeSearch") is True, "blocked": domains("blocked"), "free": domains("free")}


def free_sites(f):
    """Sites libres à proposer à l'enfant : [{domain, url}] ; l'adresse vient du parent (Pronote est sous /pronote/)."""
    f = f or {}
    free = clean_filter(f)["free"]
    urls = [u for u in (f.get("freeUrls") or []) if isinstance(u, str) and re.match(r"^https://[\w.\-]+(/[\w\-./~%]*)?$", u)]
    out = []
    for d in free:
        url = next((u for u in urls if host_allowed(u[8:].split("/")[0], [d])), None)
        if not url:
            url = "https://%s/%s" % (d, "pronote/" if d.endswith("index-education.net") else "")
        out.append({"domain": d, "url": url})
    return out


def filter_active(f):
    return f["adult"] or f["safeSearch"] or bool(f["blocked"])


def hosts_section(f, safe_ips):
    """Bloc du fichier hosts : sites bloqués (et www.), noms des moteurs redirigés vers leur version sécurisée."""
    lines = []
    for d in f["blocked"]:
        names = d if d.startswith("www.") else d + " www." + d
        lines.append("0.0.0.0 " + names)
        lines.append(":: " + names)
    if f["safeSearch"]:
        for target, names in SAFE_SEARCH.items():
            ip = safe_ips.get(target)
            if not ip:
                continue
            keep = [n for n in names if not any(n == d or n.endswith("." + d) for d in f["blocked"])]  # un site bloqué le reste
            if keep:
                lines.append(ip + " " + " ".join(keep))
    if not lines:
        return ""
    return "\n".join([HOSTS_BEGIN] + lines + [HOSTS_END]) + "\n"


_HOSTS_RE = re.compile(re.escape(HOSTS_BEGIN) + r".*?" + re.escape(HOSTS_END) + r"\n?", re.S)


def hosts_part(text):
    """Notre bloc dans le fichier hosts (le reste appartient à l'administrateur et peut changer librement)."""
    m = _HOSTS_RE.search(text or "")
    return m.group(0).rstrip("\n") + "\n" if m else ""


def merge_hosts(current, section):
    """Remplace (ou retire) notre bloc dans le contenu du fichier hosts, sans toucher au reste."""
    base = _HOSTS_RE.sub("", current)
    if base and not base.endswith("\n"):
        base += "\n"
    return base + section


def chromium_policy(f):
    p = {"DnsOverHttpsMode": "off", "BuiltInDnsClientEnabled": False}
    if f["blocked"]:
        p["URLBlocklist"] = list(f["blocked"])
    if f["safeSearch"]:
        p["ForceGoogleSafeSearch"] = True
        p["ForceYouTubeRestrict"] = 2  # strict
        p["ForceBingSafeSearch"] = 2
    return p


def firefox_policies(f):
    p = {"DNSOverHTTPS": {"Enabled": False, "Locked": True}}
    if f["blocked"]:
        block = []
        for d in f["blocked"]:
            block += ["*://%s/*" % d, "*://*.%s/*" % d]
        p["WebsiteFilter"] = {"Block": block}
    return p


def nft_ruleset(f):
    """Règles nftables : toute requête DNS en clair part vers le DNS familial ; DNS chiffré (DoT) refusé."""
    if not f["adult"]:
        return ""
    return """table inet %s {
  chain dns {
    type nat hook output priority -100; policy accept;
    ip daddr != 127.0.0.0/8 udp dport 53 dnat ip to %s
    ip daddr != 127.0.0.0/8 tcp dport 53 dnat ip to %s
    ip6 daddr != ::1 udp dport 53 dnat ip6 to [%s]
    ip6 daddr != ::1 tcp dport 53 dnat ip6 to [%s]
  }
  chain dot {
    type filter hook output priority 0; policy accept;
    tcp dport 853 reject
    udp dport 853 reject
  }
}
""" % (NFT_TABLE, FAMILY_DNS4, FAMILY_DNS4, FAMILY_DNS6, FAMILY_DNS6)


# ─────────────────────────────── Applications installées ───────────────────────────────


def parse_desktop(text):
    """Lit un lanceur .desktop : (nom, exécutable, caché). None si ce n'est pas une application."""
    section, values = None, {}
    for raw in text.splitlines():
        line = raw.strip()
        if line.startswith("["):
            section = line
            continue
        if section != "[Desktop Entry]" or "=" not in line or line.startswith("#"):
            continue
        k, v = line.split("=", 1)
        values.setdefault(k.strip(), v.strip())
    if values.get("Type") != "Application" or not values.get("Name"):
        return None
    hidden = values.get("NoDisplay", "").lower() == "true" or values.get("Hidden", "").lower() == "true"
    return values.get("Name"), exec_name(values.get("Exec", "")), hidden


def exec_name(exec_line):
    """Exécutable lancé par une ligne Exec= : « env X=1 /usr/bin/firefox %u » → « firefox » ; flatpak → identifiant."""
    try:
        words = shlex.split(exec_line)
    except ValueError:
        words = exec_line.split()
    parts = [p for p in words if p and not p.startswith("%")]
    while parts and (parts[0] == "env" or re.match(r"^\w+=", parts[0])):
        parts.pop(0)
    if not parts:
        return ""
    if os.path.basename(parts[0]) == "flatpak":
        ids = [p for p in parts[1:] if not p.startswith("-") and p != "run" and "." in p]
        return ids[0] if ids else ""
    if os.path.basename(parts[0]) in ("snap", "gtk-launch") and len(parts) > 2:
        return parts[2]
    return os.path.basename(parts[0])


def is_system(app_id, name, exe=""):
    return app_id.startswith(SYSTEM_PREFIXES) or app_id in SYSTEM_APPS or exe in DESKTOP_EXEC or "limitecran" in app_id.lower()


def scan_apps(home=None):
    """Applications visibles dans les menus : {id: {label, exec, system}} (id = nom du .desktop sans extension)."""
    dirs = list(DESKTOP_DIRS)
    if home:
        dirs += [home + "/.local/share/applications", home + "/.local/share/flatpak/exports/share/applications"]
    apps = {}
    for d in dirs:
        try:
            names = os.listdir(d)
        except OSError:
            continue
        for n in sorted(names):
            if not n.endswith(".desktop"):
                continue
            try:
                with open(os.path.join(d, n), encoding="utf-8", errors="replace") as fh:
                    parsed = parse_desktop(fh.read())
            except OSError:
                continue
            if not parsed or parsed[2]:
                continue
            app_id = n[: -len(".desktop")]
            if not re.match(r"^[\w.\-+@]{2,200}$", app_id):
                continue
            apps[app_id] = {"label": parsed[0][:80], "exec": parsed[1], "system": is_system(app_id, parsed[0], parsed[1])}
    return apps


def split_cmdline(argv):
    """
    Chromium (et d'autres) réécrivent leur ligne de commande en UNE chaîne séparée par des espaces : on la redécoupe,
    sinon ses paramètres (profil des sites libres, filtre local) passent inaperçus.
    """
    if len(argv) <= 2 and any(" --" in a for a in argv):
        return " ".join(argv).split()
    return argv


def process_matches(names, exe, argv):
    """Le processus appartient-il à une application bloquée ? (exécutable, premier argument, identifiant flatpak/snap)"""
    if not names:
        return False
    if os.path.basename(exe or "") in DESKTOP_EXEC or (argv and os.path.basename(argv[0]) in DESKTOP_EXEC):
        return False  # jamais le bureau lui-même
    base = {os.path.basename(exe)} if exe else set()
    if argv:
        base.add(os.path.basename(argv[0]))
    if base & names:
        return True
    if exe.startswith("/snap/"):
        parts = exe.split("/")
        if len(parts) > 2 and parts[2] in names:
            return True
    return any(a in names for a in argv[1:] if "." in a)


# ─────────────────────────────── Fenêtre de l'enfant (données) ───────────────────────────────


def child_view(state, online, remaining, refused=None, counting=None):
    """Ce que la fenêtre de l'enfant affiche : jamais le jeton ni les réglages du parent."""
    st = state or {}
    access = st.get("access") or {}
    tasks = [
        {k: t.get(k) for k in ("id", "title", "tokens", "mandatory", "bonus", "slot", "status", "startTime", "deadlineTime")}
        for t in (st.get("tasks") or [])
    ]
    rewards = [{k: r.get(k) for k in ("id", "title", "kind", "costTokens", "minutes")} for r in (st.get("rewards") or [])]
    return {
        "name": (st.get("child") or {}).get("firstName") or "",
        "balance": st.get("balance") or 0,
        "remainingSeconds": int(remaining or 0),
        "allowed": bool(access.get("allowed")),
        "reason": access.get("reason"),
        "reasonText": REASON_TEXT.get(access.get("reason") or "", ""),
        "tasks": tasks,
        "dayDate": (st.get("day") or {}).get("date"),
        "rewards": rewards,
        "goal": st.get("goal"),
        "timeRequest": st.get("timeRequest"),
        "canRequestTime": bool(st.get("canRequestTime")),
        "freeSites": free_sites(st.get("webFilter")),
        "countingApps": list(counting or []),
        # refusés dans l'heure par le navigateur des sites libres (et toujours pas libres)
        "refusedHosts": sorted(h for h, t in (refused or {}).items() if time.time() - t < 3600 and not host_allowed(h, clean_filter(st.get("webFilter"))["free"])),
        "online": bool(online),
    }


LAUNCHER = """[Desktop Entry]
Type=Application
Name=Limit'Écran
Comment=Mon temps d'écran, mes tâches et ma boutique
Exec=/usr/local/bin/limitecran-agent fenetre
Icon=limitecran
Terminal=false
Categories=Utility;
"""

ICON_SVG = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect x="4" y="10" width="56" height="38" rx="6" fill="#1F3A5F"/>
<rect x="24" y="50" width="16" height="4" rx="2" fill="#1F3A5F"/>
<circle cx="32" cy="29" r="13" fill="#F5A524"/>
<path d="M28 21v16h10" stroke="#5A3A0A" stroke-width="4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
"""


def app_names(apps, ids):
    """Noms d'exécutables (et identifiants flatpak) des applications `ids`, sans les noms trop généraux."""
    names = set()
    for i in ids:
        app = apps.get(i)
        if app and app.get("exec"):
            names.add(app["exec"])
        if "." in i:
            names.add(i)
    return names - GENERIC_EXEC - DESKTOP_EXEC


def limited_ids(apps, policy):
    """Applications qui comptent dans le temps : toutes sauf système, « libres » et « bloquées » (fermées de toute façon)."""
    free = set((policy or {}).get("alwaysAllowed") or [])
    forbidden = set((policy or {}).get("forbidden") or [])
    return [i for i, a in apps.items() if not a.get("system") and i not in free and i not in forbidden]


def free_browser_pids(procs):
    """
    Processus du navigateur des sites libres : lancé avec NOTRE profil ET notre filtre local, et ses sous-processus.
    Un navigateur lancé avec le profil mais sans le filtre n'est pas « libre » (il compterait et serait fermé).
    """
    flag = "--proxy-server=http://127.0.0.1:%d" % FREE_PROXY_PORT
    roots = {p["pid"] for p in procs if flag in p["argv"] and any(a.startswith("--user-data-dir=") and a.rstrip("/").endswith(FREE_PROFILE_NAME) for a in p["argv"])}
    # Rapport de plantage (crashpad) : il se détache du navigateur, on le reconnaît à son dossier dans notre profil.
    # Seulement ce programme-là : un jeu lancé avec ce mot dans ses arguments ne devient pas « libre ».
    for p in procs:
        name = os.path.basename(p.get("exe") or (p["argv"][0] if p["argv"] else ""))
        if name in ("chrome_crashpad_handler", "crashpad_handler") and any(FREE_PROFILE_NAME in a for a in p["argv"]):
            roots.add(p["pid"])
    children = {}
    for p in procs:
        children.setdefault(p["ppid"], []).append(p["pid"])
    free, todo = set(), list(roots)
    while todo:
        pid = todo.pop()
        if pid in free:
            continue
        free.add(pid)
        todo.extend(children.get(pid, []))
    return free


# Services internes de Chrome : refusés sans être proposés au parent (ils ne servent pas aux sites).
BROWSER_NOISE = ("content-autofill.googleapis.com", "optimizationguide-pa.googleapis.com", "update.googleapis.com", "accounts.google.com",
                 "clients2.google.com", "clientservices.googleapis.com", "safebrowsing.googleapis.com", "android.clients.google.com", "www.googleapis.com")


# Polices et bibliothèques de code des pages web (pas des sites à visiter) : toujours autorisées dans le navigateur des
# sites libres, sans que le parent ait à les ajouter (les ENT et Pronote en ont besoin pour s'afficher).
BUILTIN_FREE = ("fonts.googleapis.com", "fonts.gstatic.com", "cdnjs.cloudflare.com", "cdn.jsdelivr.net", "ajax.googleapis.com", "code.jquery.com")

# Extension du navigateur des sites libres : signe de vie vers ce nom (jamais résolu), vu par le filtre local.
EXT_HEARTBEAT_HOST = "heartbeat.limitecran.invalid"
EXT_ALIVE_SECONDS = 90
# Jamais ouverts en page principale depuis un site libre (un lien vers une vidéo intégrée reste possible dans la page).
ENTERTAINMENT = (
    "youtube.com", "youtu.be", "tiktok.com", "twitch.tv", "netflix.com", "primevideo.com", "disneyplus.com", "crunchyroll.com",
    "instagram.com", "facebook.com", "snapchat.com", "x.com", "twitter.com", "reddit.com", "discord.com", "discord.gg",
    "roblox.com", "steampowered.com", "steamcommunity.com", "epicgames.com", "jeuxvideo.com", "poki.com", "crazygames.com",
    "y8.com", "friv.com", "miniclip.com", "coolmathgames.com", "kongregate.com", "dailymotion.com", "pinterest.com",
)

EXT_MANIFEST = {
    "manifest_version": 3,
    "name": "Limit'Écran – sites libres",
    "version": "1.0",
    "description": "N'ouvre que les sites libres choisis par les parents (et les pages vers lesquelles ils mènent).",
    "permissions": ["declarativeNetRequest", "webNavigation", "alarms", "storage"],
    "host_permissions": ["<all_urls>"],
    "background": {"service_worker": "sw.js"},
    "web_accessible_resources": [{"resources": ["bloque.html", "bloque.js"], "matches": ["<all_urls>"]}],
    # règles actives dès le chargement (avant même le démarrage du service) : la première page est contrôlée aussi
    "declarative_net_request": {"rule_resources": [{"id": "regles", "enabled": True, "path": "rules.json"}]},
}


def ext_rules(free, deny):
    """Règles fixes de l'extension : page principale = site libre, ou lien/redirection depuis un site libre ; jamais de divertissement."""
    blocked = {"type": "redirect", "redirect": {"extensionPath": "/bloque.html"}}
    return [
        {"id": 1, "priority": 1, "action": blocked, "condition": {"resourceTypes": ["main_frame"], "excludedRequestDomains": free}},
        {"id": 2, "priority": 2, "action": {"type": "allow"}, "condition": {"resourceTypes": ["main_frame"], "initiatorDomains": free}},
        {"id": 3, "priority": 3, "action": blocked, "condition": {"resourceTypes": ["main_frame"], "requestDomains": deny}},
    ]

EXT_SW = r"""// Limit'Écran : pages principales limitées aux sites libres et à ce vers quoi ils mènent ; tout le reste de la page passe.
const HB = "https://%(hb)s/";
let cfg = null;
const config = async () => cfg || (cfg = await (await fetch(chrome.runtime.getURL("config.json"))).json());
const under = (h, list) => list.some((d) => h === d || h.endsWith("." + d));
const beat = () => fetch(HB + Date.now(), { cache: "no-store" }).catch(() => {});
async function learned() { return (await chrome.storage.session.get("learned")).learned || {}; }
// Les règles fixes (rules.json) couvrent les sites libres ; ici, les pages atteintes depuis eux pendant cette session
// (ENT, ÉduConnect…) : on peut y revenir et en repartir.
async function apply() {
  const l = Object.keys(await learned());
  const rules = l.length
    ? [
        { id: 10, priority: 2, action: { type: "allow" }, condition: { resourceTypes: ["main_frame"], requestDomains: l } },
        { id: 11, priority: 2, action: { type: "allow" }, condition: { resourceTypes: ["main_frame"], initiatorDomains: l } },
      ]
    : [];
  await chrome.declarativeNetRequest.updateSessionRules({ removeRuleIds: [10, 11], addRules: rules });
}
chrome.webNavigation.onCommitted.addListener(async (d) => {
  if (d.frameId !== 0 || !/^https?:/.test(d.url)) return;
  beat();
  const h = new URL(d.url).hostname.toLowerCase();
  const c = await config();
  if (under(h, c.free) || under(h, c.deny)) return;
  const l = await learned();
  if (!l[h] && Object.keys(l).length < 25) {
    l[h] = Date.now();
    await chrome.storage.session.set({ learned: l });
    await apply();
  }
});
chrome.alarms.create("battement", { periodInMinutes: 0.5 });
chrome.alarms.onAlarm.addListener(beat);
chrome.runtime.onStartup.addListener(() => { beat(); apply(); });
chrome.runtime.onInstalled.addListener(() => { beat(); apply(); });
beat();
apply();
""" % {"hb": EXT_HEARTBEAT_HOST}

EXT_BLOCKED_HTML = """<!doctype html><meta charset="utf-8"><title>Site non libre</title>
<body style="font-family:sans-serif;max-width:36em;margin:3em auto;padding:0 1em;color:#1F3A5F;background:#FFFBF5">
<h1>Ce site n’est pas libre</h1>
<p>Dans cette fenêtre, seuls s’ouvrent les sites libres choisis par tes parents (Pronote, ENT…) et les pages vers lesquelles ils mènent.</p>
<p>Pour un autre site, utilise le navigateur habituel : il compte alors dans ton temps d’écran.</p>
<p><button id="retour" style="font-size:1em;padding:.6em 1.2em">Revenir en arrière</button></p>
<script src="bloque.js"></script>
"""
EXT_BLOCKED_JS = 'document.getElementById("retour").onclick = () => history.length > 1 ? history.back() : window.close();\n'


def write_free_extension(path, free_domains):
    """Écrit l'extension du navigateur des sites libres (réécrite à chaque ouverture, avec la liste du moment)."""
    os.makedirs(path, exist_ok=True)
    files = {
        "manifest.json": json.dumps(EXT_MANIFEST, ensure_ascii=False, indent=1),
        "sw.js": EXT_SW,
        "bloque.html": EXT_BLOCKED_HTML,
        "bloque.js": EXT_BLOCKED_JS,
        "config.json": json.dumps({"free": sorted(set(free_domains) | set(BUILTIN_FREE)), "deny": list(ENTERTAINMENT)}),
        "rules.json": json.dumps(ext_rules(sorted(set(free_domains) | set(BUILTIN_FREE)), list(ENTERTAINMENT))),
    }
    for name, content in files.items():
        with open(os.path.join(path, name), "w", encoding="utf-8") as fh:
            fh.write(content)


def is_noise(host):
    return host_allowed(host, BROWSER_NOISE) or bool(re.match(r"^clients\d*\.google\.com$", host))


def host_allowed(host, domains):
    host = (host or "").lower().rstrip(".")
    return any(host == d or host.endswith("." + d) for d in domains)


def version_tuple(v):
    try:
        return tuple(int(x) for x in str(v).split("."))
    except ValueError:
        return (0,)


# ─────────────────────────────── Historique pour les parents ───────────────────────────────

ACTIVITY_EVERY = 300
# Historiques des navigateurs du compte de l'enfant (lus par l'agent ; seul le NOM du site est gardé).
CHROME_HISTORY = (
    ".config/google-chrome/*/History", ".config/chromium/*/History", ".config/BraveSoftware/Brave-Browser/*/History",
    ".config/microsoft-edge/*/History", ".config/vivaldi/*/History", "snap/chromium/common/chromium/*/History",
    ".var/app/*/config/*/*/History", ".local/share/%s/*/History" % FREE_PROFILE_NAME, "snap/*/common/%s/*/History" % FREE_PROFILE_NAME,
    ".var/app/*/%s/*/History" % FREE_PROFILE_NAME,
)
FIREFOX_HISTORY = (".mozilla/firefox/*/places.sqlite", "snap/firefox/common/.mozilla/firefox/*/places.sqlite", ".var/app/org.mozilla.firefox/.mozilla/firefox/*/places.sqlite")
CHROME_EPOCH = 11644473600  # secondes entre 1601 et 1970 (horloge de Chrome)


def site_name(url):
    """Nom du site d'une adresse (« https://www.YouTube.com/watch?v=… » → « youtube.com ») ; None hors pages web."""
    m = re.match(r"^https?://([^/:?#]+)", url or "", re.I)
    if not m:
        return None
    host = m.group(1).lower().rstrip(".")
    host = host[4:] if host.startswith("www.") else host
    return host if _DOMAIN.match(host) else None


def history_files(home):
    files = []
    for pattern in CHROME_HISTORY:
        files += [("chrome", f) for f in glob.glob(os.path.join(home, pattern))]
    for pattern in FIREFOX_HISTORY:
        files += [("firefox", f) for f in glob.glob(os.path.join(home, pattern))]
    return files


def read_visits(kind, path, since_ts):
    """Visites postérieures à `since_ts` (secondes) : [(horodatage, adresse)]. Copie le fichier (le navigateur le verrouille)."""
    import sqlite3
    import tempfile
    tmp = tempfile.mktemp(prefix="limitecran-hist-")
    try:
        shutil.copyfile(path, tmp)
        for ext in ("-wal",):
            if os.path.exists(path + ext):
                shutil.copyfile(path + ext, tmp + ext)
        con = sqlite3.connect("file:%s?mode=ro" % tmp, uri=True)
        try:
            if kind == "safari":
                rows = con.execute(
                    "select v.visit_time, i.url from history_visits v join history_items i on i.id = v.history_item where v.visit_time > ? order by v.visit_time",
                    (since_ts - SAFARI_EPOCH,),
                ).fetchall()
                return [(t + SAFARI_EPOCH, u) for t, u in rows]
            if kind == "chrome":
                rows = con.execute(
                    "select v.visit_time, u.url from visits v join urls u on u.id = v.url where v.visit_time > ? order by v.visit_time",
                    (int((since_ts + CHROME_EPOCH) * 1_000_000),),
                ).fetchall()
                return [(t / 1_000_000 - CHROME_EPOCH, u) for t, u in rows]
            rows = con.execute(
                "select v.visit_date, p.url from moz_historyvisits v join moz_places p on p.id = v.place_id where v.visit_date > ? order by v.visit_date",
                (int(since_ts * 1_000_000),),
            ).fetchall()
            return [(t / 1_000_000, u) for t, u in rows]
        finally:
            con.close()
    except Exception:  # noqa: BLE001 - historique illisible (navigateur ouvert, format inconnu) : réessayé plus tard
        return []
    finally:
        for f in (tmp, tmp + "-wal"):
            try:
                os.remove(f)
            except OSError:
                pass


def collect_sites(home, watermarks, now_ts):
    """Sites visités depuis le dernier relevé, par jour : {(date, site): visites}. Met à jour `watermarks` (par fichier)."""
    out = {}
    for kind, path in history_files(home):
        since = watermarks.get(path, now_ts - 3600)  # premier relevé : la dernière heure seulement
        latest = since
        for ts, url in read_visits(kind, path, since):
            latest = max(latest, ts)
            name = site_name(url)
            if name:
                key = (paris_now(ts).date().isoformat(), name)
                out[key] = out.get(key, 0) + 1
        watermarks[path] = latest
    return out


# ─────────────────────────────── Système ───────────────────────────────


def run(cmd, timeout=15, **kw):
    try:
        return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, **kw)
    except (OSError, subprocess.TimeoutExpired) as e:
        return subprocess.CompletedProcess(cmd, 127, "", str(e))


def read_json(path, default=None):
    try:
        with open(path, encoding="utf-8") as fh:
            return json.load(fh)
    except (OSError, ValueError):
        return default


def write_file(path, content, mode=0o644):
    os.makedirs(os.path.dirname(path), exist_ok=True)
    tmp = path + ".limitecran-tmp"
    with open(tmp, "w", encoding="utf-8") as fh:
        fh.write(content)
    os.chmod(tmp, mode)
    try:
        os.replace(tmp, path)
    except OSError as e:
        # fichier monté à part (conteneurs, certains systèmes pour /etc/hosts) : réécrit sur place
        if e.errno not in (errno.EBUSY, errno.EXDEV, errno.EPERM):
            raise
        with open(path, "w", encoding="utf-8") as fh:
            fh.write(content)
        os.remove(tmp)


def write_json(path, data, mode=0o600):
    write_file(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n", mode)


def sha(text):
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


def read_text(path):
    try:
        with open(path, encoding="utf-8", errors="replace") as fh:
            return fh.read()
    except OSError:
        return None


class Child:
    def __init__(self, user):
        pw = pwd.getpwnam(user)
        self.user, self.uid, self.home = user, pw.pw_uid, pw.pw_dir

    def is_admin(self):
        groups = {g.gr_name for g in grp.getgrall() if self.user in g.gr_mem}
        try:
            groups.add(grp.getgrgid(pwd.getpwnam(self.user).pw_gid).gr_name)
        except KeyError:
            pass
        return self.uid == 0 or bool(groups & set(ADMIN_GROUPS))

    def sessions(self):
        """Sessions de l'enfant : [{id, active, locked, idle, type}] (loginctl)."""
        r = run(["loginctl", "show-user", self.user, "-p", "Sessions", "--value"])
        out = []
        for sid in r.stdout.split():
            s = run(["loginctl", "show-session", sid, "-p", "Active", "-p", "LockedHint", "-p", "IdleHint", "-p", "Type", "-p", "Class", "-p", "State"])
            props = dict(line.split("=", 1) for line in s.stdout.splitlines() if "=" in line)
            if props.get("Class", "user") != "user" or props.get("State") == "closing":
                continue
            out.append({
                "id": sid,
                "active": props.get("Active") == "yes",
                "locked": props.get("LockedHint") == "yes",
                "idle": props.get("IdleHint") == "yes",
                "type": props.get("Type", ""),
            })
        return out

    def in_use(self, sessions):
        return any(s["active"] and not s["locked"] and not s["idle"] for s in sessions)

    def notify(self, title, body, urgent=False):
        bus = "unix:path=/run/user/%d/bus" % self.uid
        if not os.path.exists("/run/user/%d/bus" % self.uid) or not shutil.which("notify-send"):
            return
        cmd = ["runuser", "-u", self.user, "--", "env", "DBUS_SESSION_BUS_ADDRESS=" + bus, "notify-send", "-a", "Limit'Écran", "-i", "appointment-soon"]
        if urgent:
            cmd += ["-u", "critical"]
        run(cmd + [title, body], timeout=10)

    def desktop_dir(self):
        """Dossier « Bureau » de l'enfant (xdg-user-dir), None s'il n'en a pas."""
        r = run(["runuser", "-u", self.user, "--", "xdg-user-dir", "DESKTOP"], timeout=10)
        d = r.stdout.strip() if r.returncode == 0 else ""
        if d and os.path.isdir(d) and os.path.realpath(d) != os.path.realpath(self.home):
            return d
        for name in ("Bureau", "Desktop"):
            if os.path.isdir(os.path.join(self.home, name)):
                return os.path.join(self.home, name)
        return None

    def install_launcher(self):
        """Icône « Limit'Écran » dans le menu des applications et sur le bureau de l'enfant (remise si elle a disparu)."""
        for path, content in ((ICON_FILE, ICON_SVG), (LAUNCHER_FILE, LAUNCHER)):
            if read_text(path) != content:
                write_file(path, content)
        for old in OLD_LAUNCHER_FILES:  # ancien emplacement (0.2 à 0.3.7) : retiré, sinon l'icône apparaît deux fois
            if read_text(old) in (LAUNCHER, ICON_SVG):
                try:
                    os.remove(old)
                except OSError:
                    pass
        desk = self.desktop_dir()
        if not desk:
            return
        target = os.path.join(desk, "limitecran.desktop")
        if read_text(target) != LAUNCHER:
            write_file(target, LAUNCHER, mode=0o755)
            os.chown(target, self.uid, pwd.getpwnam(self.user).pw_gid)
            # GNOME : un lanceur sur le bureau doit être marqué « de confiance » pour s'ouvrir d'un clic
            bus = "/run/user/%d/bus" % self.uid
            if os.path.exists(bus) and shutil.which("gio"):
                run(["runuser", "-u", self.user, "--", "env", "DBUS_SESSION_BUS_ADDRESS=unix:path=" + bus, "gio", "set", target, "metadata::trusted", "true"], timeout=10)

    def processes(self):
        """Processus de l'enfant : [{pid, ppid, exe, argv}]."""
        out = []
        for pid in os.listdir("/proc"):
            if not pid.isdigit():
                continue
            try:
                if os.stat("/proc/" + pid).st_uid != self.uid:
                    continue
                with open("/proc/%s/cmdline" % pid, "rb") as fh:
                    argv = split_cmdline([a.decode("utf-8", "replace") for a in fh.read().split(b"\0") if a])
                with open("/proc/%s/stat" % pid) as fh:
                    ppid = int(fh.read().rsplit(")", 1)[1].split()[1])
                try:
                    exe = os.readlink("/proc/%s/exe" % pid)
                except OSError:
                    exe = ""
                out.append({"pid": int(pid), "ppid": ppid, "exe": exe, "argv": argv})
            except (OSError, ValueError, IndexError):
                continue
        return out

    def matching(self, names, procs=None, exclude=()):
        """Processus de l'enfant appartenant aux applications `names` (hors navigateur des sites libres et Limit'Écran)."""
        if not names:
            return []
        procs = self.processes() if procs is None else procs
        return [p for p in procs if p["pid"] not in exclude and not any("limitecran-agent" in a for a in p["argv"][:2]) and process_matches(names, p["exe"], p["argv"])]

    def kill_apps(self, names, procs=None, exclude=()):
        """Ferme les processus de l'enfant qui appartiennent à ces applications. Renvoie le nombre fermé."""
        killed = 0
        for p in self.matching(names, procs, exclude):
            try:
                os.kill(p["pid"], signal.SIGKILL)
                killed += 1
                # journal (journalctl -u limitecran-agent) : ce qui a été fermé, pour comprendre un blocage inattendu
                log("fermé :", p["pid"], p.get("exe") or "?", " ".join(p["argv"][:3])[:200], remote=False)
                log("fermé :", os.path.basename(p.get("exe") or (p["argv"][0] if p["argv"] else "?")), "(%s)" % next((x.split("=", 1)[1] for x in p["argv"] if x.startswith("--type=")), "principal"))
            except OSError:
                pass
        return killed


class ApiError(Exception):
    def __init__(self, status, code, message=""):
        super().__init__("%s %s %s" % (status, code, message))
        self.status, self.code = status, code


class Api:
    def __init__(self, server, token=None):
        self.server, self.token = server.rstrip("/"), token

    def call(self, method, path, body=None, timeout=15):
        data = json.dumps(body).encode("utf-8") if body is not None else None
        req = urllib.request.Request(self.server + path, data=data, method=method)
        req.add_header("content-type", "application/json")
        req.add_header("user-agent", "LimitEcran-Linux/" + VERSION)
        if self.token:
            req.add_header("authorization", "Bearer " + self.token)
        try:
            with urllib.request.urlopen(req, timeout=timeout) as r:
                raw = r.read()
                return json.loads(raw) if raw else None
        except urllib.error.HTTPError as e:
            try:
                err = json.loads(e.read()).get("error", {})
            except (ValueError, AttributeError):
                err = {}
            raise ApiError(e.code, err.get("code", "http_%d" % e.code), err.get("message", "")) from None


def resolve_ipv4(name):
    try:
        infos = socket.getaddrinfo(name, 443, socket.AF_INET, socket.SOCK_STREAM)
        return infos[0][4][0] if infos else None
    except OSError:
        return None


class WebFilterApplier:
    """Écrit les fichiers du filtrage et vérifie qu'ils n'ont pas été modifiés (sinon : rétablis et signalés)."""

    def __init__(self):
        self.applied = read_json(APPLIED_FILE, {}) or {}
        self.safe_ips = {}

    def _save(self):
        write_json(APPLIED_FILE, self.applied)

    def desired(self, f):
        files = {}
        active = filter_active(f)
        if f["safeSearch"]:
            for target in SAFE_SEARCH:
                ip = resolve_ipv4(target) or self.safe_ips.get(target)
                if ip:
                    self.safe_ips[target] = ip
        hosts = read_text(HOSTS_FILE) or "127.0.0.1 localhost\n"
        files[HOSTS_FILE] = merge_hosts(hosts, hosts_section(f, self.safe_ips))
        chromium = json.dumps(chromium_policy(f), indent=2) + "\n" if active else None
        for d in CHROMIUM_POLICY_DIRS:
            files[d + "/limitecran.json"] = chromium
        ff_paths = list(FIREFOX_POLICY_FILES) + [d + "/distribution/policies.json" for d in FIREFOX_INSTALL_DIRS if os.path.isdir(d)]
        for p in ff_paths:
            files[p] = self._firefox_file(p, f) if active else self._firefox_restore(p)
        return files

    def _firefox_file(self, path, f):
        """Nos règles Firefox, ajoutées à celles qui existaient déjà (gardées de côté, rétablies à la désinstallation)."""
        backup = path + ".avant-limitecran"
        current = read_text(path)
        if current is not None and self.applied.get(path) != sha(current) and not os.path.exists(backup):
            write_file(backup, current)  # fichier de l'administrateur, pas le nôtre
        base = read_json(backup, {}) if os.path.exists(backup) else {}
        policies = dict((base or {}).get("policies") or {})
        policies.update(firefox_policies(f))
        return json.dumps({"policies": policies}, indent=2, ensure_ascii=False) + "\n"

    def _firefox_restore(self, path):
        backup = path + ".avant-limitecran"
        return read_text(backup) if os.path.exists(backup) else None

    def apply(self, f, report_tamper):
        """Applique le filtrage. Renvoie True si tout est en place (sinon le parent est prévenu par le signe de vie)."""
        ok = True
        tampered = False
        for path, content in self.desired(f).items():
            current = read_text(path)
            known = self.applied.get(path)
            if path == HOSTS_FILE:
                mine, theirs = hosts_part(content), hosts_part(current)
                if known and sha(theirs) != known:
                    tampered = True
                if mine != theirs:
                    try:
                        write_file(path, content)
                    except OSError as e:
                        log("écriture impossible", path, e)
                        ok = False
                        continue
                if mine:
                    self.applied[path] = sha(mine)
                else:
                    self.applied.pop(path, None)
                continue
            if content is None:
                if known and current is not None and sha(current) == known:
                    os.remove(path)
                if known:
                    self.applied.pop(path, None)
                continue
            if current == content:
                self.applied[path] = sha(content)
                continue
            if known and current is not None and sha(current) != known:
                tampered = True
            try:
                write_file(path, content)
                self.applied[path] = sha(content)
            except OSError as e:
                log("écriture impossible", path, e)
                ok = False
        ok = self._apply_nft(f, report_tamper) and ok
        self._save()
        if tampered:
            log("filtrage modifié par quelqu'un : rétabli")
            report_tamper("web_filter")
        return ok

    def _apply_nft(self, f, report_tamper):
        rules = nft_ruleset(f)
        if not shutil.which("nft"):
            return not rules
        listed = run(["nft", "list", "table", "inet", NFT_TABLE])
        present = listed.returncode == 0
        if not rules:
            if present:
                run(["nft", "delete", "table", "inet", NFT_TABLE])
                run(["resolvectl", "flush-caches"])
            self.applied.pop("nft", None)
            return True
        boot = (read_text("/proc/sys/kernel/random/boot_id") or "").strip()
        same = self.applied.get("nft") == sha(rules) and self.applied.get("nftBoot") == boot
        if present and same and "dnat" in listed.stdout:
            return True
        if same and not present:
            report_tamper("web_filter")  # table retirée pendant ce démarrage : quelqu'un l'a supprimée
        script = ("delete table inet %s\n" % NFT_TABLE if present else "") + rules
        r = run(["nft", "-f", "-"], input=script)
        if r.returncode != 0:
            log("nftables refuse les règles :", r.stderr.strip()[:300])
            return False
        self.applied["nft"] = sha(rules)
        self.applied["nftBoot"] = boot
        run(["resolvectl", "flush-caches"])
        return True

    def remove_all(self):
        self.apply({"adult": False, "safeSearch": False, "blocked": []}, lambda _k: None)


# ─────────────────────────────── Agent ───────────────────────────────


class Agent:
    def __init__(self, conf):
        self.conf = conf
        self.api = Api(conf["server"], conf["token"])
        self.child = Child(conf["user"])
        self.filter = WebFilterApplier()
        cached = read_json(CACHE_FILE) or {}
        self.state = cached.get("state")
        self.offline = cached.get("offline", 0)  # secondes d'écran comptées sans réseau, pas encore envoyées
        self.remaining = cached.get("remaining", 0)
        self.online = False
        self.session_open = False
        self.blocked_since = None
        self.warned = set()
        self.last = {"state": 0, "heartbeat": 0, "filter": 0, "inventory": cached.get("inventoryAt", 0)}
        self.apps = {}
        self.filter_ok = True
        self.running = True
        self.lock = threading.RLock()  # la boucle et la fenêtre de l'enfant partagent l'état
        self.counting_apps = []
        self.activity = {}  # (date, kind, nom) -> {seconds, visits} pas encore envoyé
        self.history_marks = (read_json(CACHE_FILE) or {}).get("historyMarks") or {}
        self.refused = {}  # sites refusés par le navigateur des sites libres : {hôte: date}
        self.refused_sent = 0
        self.last["launcher"] = 0

    # — échanges avec le serveur —
    def _call(self, method, path, body=None):
        try:
            r = self.api.call(method, path, body)
            self.online = True
            return r
        except ApiError as e:
            self.online = True
            if e.status == 401:
                self.revoked()
            raise
        except (urllib.error.URLError, OSError, ValueError) as e:
            self.online = False
            raise ConnectionError(str(e)) from None

    def revoked(self):
        log("appareil retiré par le parent : protection levée, l'agent s'arrête")
        self.filter.remove_all()
        try:
            os.remove(CONF_FILE)
        except OSError:
            pass
        service_disable()
        sys.exit(0)

    def report_tamper(self, kind):
        try:
            self._call("POST", "/api/device/tamper", {"kind": kind})
        except Exception as e:  # noqa: BLE001 - l'alerte est perdue, le signe de vie le dira
            log("alerte non envoyée :", e)

    def refresh_state(self):
        try:
            st = self._call("GET", "/api/device/state")
        except (ApiError, ConnectionError) as e:
            log("état non lu :", e)
            return
        self.state = st
        self.remaining = max(0, int(st.get("screenSeconds") or 0))
        self.last["state"] = time.time()
        self.save_cache()

    def save_cache(self):
        write_json(CACHE_FILE, {"state": self.state, "offline": self.offline, "remaining": self.remaining, "inventoryAt": self.last["inventory"], "historyMarks": getattr(self, "history_marks", {})})

    def screen(self, action):
        body = {"packageName": SESSION_PACKAGE} if action == "start" else {}
        if self.offline:
            body["offlineSeconds"] = min(MAX_OFFLINE, int(self.offline))
        try:
            st = self._call("POST", "/api/device/screen/" + action, body)
        except ApiError as e:
            log("séance", action, "refusée :", e)
            return None
        except ConnectionError:
            return None
        self.offline = 0
        if st:
            self.remaining = max(0, int(st.get("remainingSeconds") or 0))
            self.session_open = bool(st.get("sessionOpen"))
            if self.state is not None:
                self.state["access"] = {"allowed": st.get("allowed"), "reason": st.get("reason")}
        self.save_cache()
        return st

    def heartbeat(self):
        try:
            self._call("POST", "/api/device/heartbeat", {
                "accessibilityEnabled": self.filter_ok,
                "deviceAdminEnabled": not self.child.is_admin(),
                "appVersion": "linux-" + VERSION,
            })
            self.last["heartbeat"] = time.time()
        except (ApiError, ConnectionError) as e:
            log("signe de vie non envoyé :", e)

    def send_inventory(self, nightly):
        self.apps = scan_apps(self.child.home)
        payload = [{"id": k, "label": v["label"], "system": v["system"]} for k, v in sorted(self.apps.items())][:500]
        try:
            self._call("POST", "/api/device/apps", {"apps": payload, "nightly": nightly})
            self.last["inventory"] = time.time()
            self.save_cache()
        except (ApiError, ConnectionError) as e:
            log("liste des applications non envoyée :", e)

    # — une boucle —
    def step(self, now_ts):
        st = self.state
        if st is None:
            self.refresh_state()
            return
        if now_ts - self.last["state"] >= STATE_EVERY:
            self.refresh_state()
            st = self.state

        removed = bool((st.get("protection") or {}).get("removed"))
        f = clean_filter(st.get("webFilter")) if not removed else clean_filter(None)
        if now_ts - self.last["filter"] >= FILTER_CHECK_EVERY or f != self.last.get("filterValue"):
            self.filter_ok = self.filter.apply(f, self.report_tamper)
            self.last["filter"], self.last["filterValue"] = now_ts, f
        if now_ts - self.last["heartbeat"] >= HEARTBEAT_EVERY:
            self.heartbeat()
        hour = paris_now(now_ts).hour
        if not self.apps or (now_ts - self.last["inventory"] > 20 * 3600 and 1 <= hour < 5):
            self.send_inventory(nightly=bool(self.apps))

        sessions = self.child.sessions()
        using = self.child.in_use(sessions)
        policy = st.get("policy") or {}
        procs = self.child.processes() if using else []
        free_pids = free_browser_pids(procs)

        # applications bloquées : fermées dès qu'elles s'ouvrent, même avec du temps
        if not removed and using:
            forbidden = policy.get("forbidden") or []
            if self.child.kill_apps(app_names(self.apps, forbidden) | set(forbidden), procs, free_pids):
                self.child.notify("Application bloquée", "Un parent a bloqué cette application sur ton ordinateur.")

        # le temps ne s'écoule que si une application limitée est ouverte (les libres et les sites libres ne comptent pas)
        limited_list = limited_ids(self.apps, policy)
        limited = app_names(self.apps, limited_list)
        counting = using and bool(self.child.matching(limited, procs, free_pids))
        # historique : applications ouvertes (libres comme limitées) pendant ce passage
        if using:
            day = paris_now(now_ts).date().isoformat()
            for i, app in self.apps.items():
                if not app.get("system") and self.child.matching(app_names(self.apps, [i]), procs, free_pids):
                    k = (day, "app", app["label"])
                    self.activity.setdefault(k, {"seconds": 0, "visits": 0})["seconds"] += TICK_SECONDS
        # pour l'enfant (et le parent) : quelles applications font compter le temps en ce moment
        self.counting_apps = sorted({self.apps[i]["label"] for i in limited_list if self.child.matching(app_names(self.apps, [i]), procs, free_pids)}) if counting else []

        # décompte du temps
        if counting and not removed:
            if self.session_open:
                if self.screen("tick") is None and not self.online:
                    self.count_offline()
            else:
                allowed, _ = effective_access(st, not self.online, paris_now(now_ts), self.remaining)
                if allowed and self.screen("start") is None and not self.online:
                    self.count_offline()
        elif self.session_open:
            self.screen("stop")
            self.session_open = False

        allowed, reason = effective_access(st, not self.online, paris_now(now_ts), self.remaining)
        self.write_status(allowed, reason)
        if counting and not allowed:
            self.block(limited, procs, free_pids, reason, now_ts)
        elif allowed:
            self.blocked_since = None  # le délai d'avertissement ne repart qu'une fois le temps revenu
            if counting:
                self.warn()

    def count_offline(self):
        self.offline = min(MAX_OFFLINE, self.offline + TICK_SECONDS)
        self.remaining = max(0, self.remaining - TICK_SECONDS)
        self.save_cache()

    def warn(self):
        for limit, text in ((300, "Plus que 5 minutes d'écran."), (60, "Plus qu'une minute d'écran.")):
            if self.remaining <= limit and limit not in self.warned and self.remaining > 0:
                self.warned.add(limit)
                self.child.notify("Limit'Écran", text, urgent=limit == 60)
        if self.remaining > 300:
            self.warned.clear()

    def block(self, limited, procs, free_pids, reason, now_ts):
        """Comme sur le téléphone : les applications limitées se ferment (après un avertissement), les libres restent."""
        if self.blocked_since is None:
            self.blocked_since = now_ts
            log("blocage :", reason, "— applications limitées ouvertes :", ", ".join(getattr(self, "counting_apps", [])) or "?", "— navigateur des sites libres :", len(free_pids), "processus")
            apps = getattr(self, "counting_apps", []) or ["les applications limitées"]
            self.child.notify(
                "Pause écran",
                REASON_TEXT.get(reason or "", "L'écran est en pause.")
                + " %s va se fermer dans 30 secondes. Pronote et les autres sites libres restent ouverts si tu les ouvres avec le bouton « Ouvrir » de ta fenêtre Limit'Écran." % ", ".join(apps[:3]),
                urgent=True,
            )
            return
        if now_ts - self.blocked_since >= BLOCK_GRACE_SECONDS and self.child.kill_apps(limited, procs, free_pids):
            if now_ts - self.last.get("killNotice", 0) > 60:
                self.last["killNotice"] = now_ts
                self.child.notify("Application fermée", REASON_TEXT.get(reason or "", "L'écran est en pause."))

    def write_status(self, allowed, reason):
        st = self.state or {}
        status = {
            "child": (st.get("child") or {}).get("firstName"),
            "allowed": allowed,
            "reason": reason,
            "remainingSeconds": self.remaining,
            "balance": st.get("balance"),
            "online": self.online,
            "countingApps": getattr(self, "counting_apps", []),
            "filter": clean_filter(st.get("webFilter")),
            "updatedAt": int(time.time()),
        }
        try:
            write_json(STATUS_FILE, status, mode=0o644)
        except OSError:
            pass

    # — navigateur des sites libres —
    def serve_free_proxy(self):
        """
        Filtre local (127.0.0.1) du navigateur des sites libres : seuls les sites que le parent a déclarés libres passent.
        HTTPS (CONNECT) relayé tel quel ; tout le reste est refusé avec une page claire.
        """
        srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        srv.bind(("127.0.0.1", FREE_PROXY_PORT))
        srv.listen(64)

        def refuse(conn, host):
            body = ("<!doctype html><meta charset=utf-8><title>Site non libre</title><body style='font-family:sans-serif;padding:2em'>"
                    "<h1>Ce site n’est pas libre</h1><p>%s ne fait pas partie des sites libres choisis par tes parents. "
                    "Ouvre-le dans le navigateur habituel : il compte alors dans ton temps d’écran.</p>" % (host or "Ce site")).encode("utf-8")
            conn.sendall(b"HTTP/1.1 403 Forbidden\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: %d\r\nConnection: close\r\n\r\n" % len(body) + body)

        def relay(a, b):
            socks = [a, b]
            while True:
                ready, _, _ = select.select(socks, [], [], 300)
                if not ready:
                    return
                for r in ready:
                    data = r.recv(65536)
                    if not data:
                        return
                    (b if r is a else a).sendall(data)

        def handle(conn):
            with conn:
                try:
                    conn.settimeout(30)
                    head = b""
                    while b"\r\n\r\n" not in head and len(head) < 16384:
                        chunk = conn.recv(4096)
                        if not chunk:
                            return
                        head += chunk
                    parts = head.split(b"\r\n", 1)[0].decode("latin-1").split()
                    if len(parts) < 2:
                        return
                    method, target = parts[0].upper(), parts[1]
                    free = clean_filter((self.state or {}).get("webFilter"))["free"] + list(BUILTIN_FREE)
                    target_host = target.rpartition(":")[0].lower()
                    if method == "CONNECT" and target_host == EXT_HEARTBEAT_HOST:
                        self.ext_seen = time.time()  # l'extension des sites libres est active
                        conn.sendall(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
                        return
                    # Extension active : elle contrôle les pages ouvertes ; tout ce dont elles ont besoin passe ici.
                    # Sans elle (coupée, ou navigateur qui la refuse) : seuls les sites libres passent.
                    if method == "CONNECT" and time.time() - getattr(self, "ext_seen", 0) < EXT_ALIVE_SECONDS:
                        free = [target_host]
                    if method != "CONNECT":
                        host = re.sub(r"^[a-z]+://", "", target).split("/")[0].split(":")[0]
                        if not is_noise(host.lower()):
                            self.refused[host.lower()] = time.time()
                        refuse(conn, host)
                        return
                    host, _, port = target.rpartition(":")
                    if port != "443" or not host_allowed(host, free):
                        if not is_noise(host.lower()):
                            self.refused[host.lower()] = time.time()
                        refuse(conn, host)
                        return
                    upstream = socket.create_connection((host, 443), timeout=30)
                    with upstream:
                        conn.sendall(b"HTTP/1.1 200 Connection established\r\n\r\n")
                        conn.settimeout(None)
                        upstream.settimeout(None)
                        relay(conn, upstream)
                except (OSError, ValueError):
                    return

        while self.running:
            try:
                conn, _ = srv.accept()
            except OSError:
                continue
            threading.Thread(target=handle, args=(conn,), daemon=True).start()

    # — historique pour les parents —
    def send_activity(self, now_ts):
        try:
            for (day, site), n in collect_sites(self.child.home, self.history_marks, now_ts).items():
                self.activity.setdefault((day, "site", site), {"seconds": 0, "visits": 0})["visits"] += n
        except Exception as e:  # noqa: BLE001
            log("historique des sites illisible :", repr(e))
        if not self.activity:
            return
        items = [{"date": d, "kind": k, "name": n, **v} for (d, k, n), v in list(self.activity.items())[:500]]
        try:
            self.api.call("POST", "/api/device/activity", {"items": items})
            for it in items:
                self.activity.pop((it["date"], it["kind"], it["name"]), None)
            cache = read_json(CACHE_FILE) or {}
            cache["historyMarks"] = self.history_marks
            write_json(CACHE_FILE, cache)
        except Exception as e:  # noqa: BLE001 - gardé pour le prochain envoi
            print("[limitecran] historique non envoyé :", e, flush=True)

    # — mise à jour automatique —
    def check_update(self):
        """
        Une fois par jour : nouvelle version publiée sur le site ? Téléchargée, empreinte SHA-256 vérifiée, code relu
        par Python, puis l'agent s'arrête et systemd le relance sur la nouvelle version.
        """
        base = self.conf["server"].rstrip("/") + "/linux/"
        try:
            with urllib.request.urlopen(base + "version.json", timeout=30) as r:
                info = json.loads(r.read())
            if version_tuple(info.get("version")) <= version_tuple(VERSION):
                return False
            with urllib.request.urlopen(base + "limitecran_agent.py", timeout=60) as r:
                code = r.read()
            if hashlib.sha256(code).hexdigest() != info.get("sha256"):
                log("mise à jour refusée : empreinte différente")
                return False
            compile(code, AGENT_BIN, "exec")
            tmp = AGENT_BIN + ".nouveau"
            with open(tmp, "wb") as fh:
                fh.write(code)
            os.chmod(tmp, 0o755)
            os.replace(tmp, AGENT_BIN)
            log("mise à jour installée :", VERSION, "→", info.get("version"), "; redémarrage")
            return True
        except (OSError, ValueError, SyntaxError) as e:
            log("mise à jour impossible pour l'instant :", e)
            return False

    # — fenêtre de l'enfant —
    def child_request(self, req):
        """Une demande de la fenêtre de l'enfant. Renvoie la vue à jour, ou {"error": message lisible}."""
        op = req.get("op")
        with self.lock:
            if op == "state":
                if time.time() - self.last["state"] > 5:
                    self.refresh_state()
                return child_view(self.state, self.online, self.remaining, self.refused, getattr(self, "counting_apps", []))
            st = self.state or {}
            try:
                if op == "complete":
                    task = str(req.get("taskId", ""))
                    if not any(t.get("id") == task for t in st.get("tasks") or []):
                        return {"error": "Tâche inconnue."}
                    self._call("POST", "/api/device/tasks/%s/complete" % task, {"clientOpId": str(uuid.uuid4()), "forDate": (st.get("day") or {}).get("date")})
                elif op == "buy":
                    reward = str(req.get("rewardId", ""))
                    if not any(r.get("id") == reward for r in st.get("rewards") or []):
                        return {"error": "Récompense inconnue."}
                    self._call("POST", "/api/device/rewards/%s/redeem" % reward, {"clientOpId": str(uuid.uuid4())})
                elif op == "request_time":
                    minutes = req.get("minutes")
                    if minutes not in TIME_REQUEST_MINUTES:
                        return {"error": "Choisis 10, 20 ou 30 minutes."}
                    self._call("POST", "/api/device/time-requests", {"minutes": minutes})
                else:
                    return {"error": "Demande inconnue."}
            except ApiError as e:
                return {"error": str(e.args[0]).split(" ", 2)[-1] or "Le serveur a refusé."}
            except ConnectionError:
                return {"error": "Il faut Internet pour faire ça. Réessaie quand l'ordinateur est connecté."}
            self.refresh_state()
            return child_view(self.state, self.online, self.remaining, self.refused, getattr(self, "counting_apps", []))

    def serve_child(self):
        """Canal local de la fenêtre : seul le compte de l'enfant (ou root) peut s'y connecter (vérifié par le noyau)."""
        os.makedirs(RUN_DIR, exist_ok=True)
        os.chmod(RUN_DIR, 0o755)
        try:
            os.remove(CHILD_SOCKET)
        except OSError:
            pass
        srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        srv.bind(CHILD_SOCKET)
        os.chmod(CHILD_SOCKET, 0o666)  # le contrôle se fait sur l'identité du client, ci-dessous
        srv.listen(8)

        def handle(conn):
            with conn:
                try:
                    uid = peer_uid(conn)
                    if uid not in (self.child.uid, 0):
                        conn.sendall(json.dumps({"error": "Réservé au compte de l’enfant."}, ensure_ascii=False).encode("utf-8") + b"\n")
                        return
                    conn.settimeout(30)
                    data = b""
                    while not data.endswith(b"\n") and len(data) < 8192:
                        chunk = conn.recv(4096)
                        if not chunk:
                            break
                        data += chunk
                    reply = self.child_request(json.loads(data.decode("utf-8") or "{}"))
                except Exception as e:  # noqa: BLE001
                    log("fenêtre :", repr(e))
                    reply = {"error": "Erreur inattendue, réessaie."}
                conn.sendall(json.dumps(reply, ensure_ascii=False).encode("utf-8") + b"\n")

        while self.running:
            try:
                conn, _ = srv.accept()
            except OSError:
                continue
            threading.Thread(target=handle, args=(conn,), daemon=True).start()

    def loop(self):
        signal.signal(signal.SIGTERM, lambda *_: setattr(self, "running", False))
        log("démarrage, version", VERSION, "— compte surveillé :", self.child.user)
        threading.Thread(target=self.serve_child, daemon=True).start()
        threading.Thread(target=self.serve_free_proxy, daemon=True).start()
        self.last["update"] = time.time() - UPDATE_EVERY + 600  # première vérification 10 min après le démarrage
        if self.child.is_admin():
            self.report_tamper("device_admin")
        while self.running:
            started = time.time()
            try:
                with self.lock:
                    self.step(started)
                # historique des parents : sites visités et applications, envoyés toutes les 5 minutes
                if started - self.last.get("activity", 0) > ACTIVITY_EVERY:
                    self.last["activity"] = started
                    self.send_activity(started)
                # journal de diagnostic : envoyé toutes les 5 minutes (gardé s'il n'est pas passé)
                if REMOTE_LOG and started - self.last.get("logs", 0) > LOGS_EVERY:
                    self.last["logs"] = started
                    batch = REMOTE_LOG[:100]
                    try:
                        self.api.call("POST", "/api/device/logs", {"entries": batch})
                        del REMOTE_LOG[: len(batch)]
                    except Exception as e:  # noqa: BLE001 - réessayé au prochain passage
                        print("[limitecran] journal non envoyé :", e, flush=True)
                # sites refusés : envoyés au parent (onglet Ordinateur), au plus une fois par minute
                fresh = [h for h, t in list(self.refused.items()) if t > self.refused_sent]
                if fresh and started - self.refused_sent > 60:
                    self.refused_sent = started
                    try:
                        self._call("POST", "/api/device/free-refused", {"hosts": fresh[:50]})
                    except (ApiError, ConnectionError) as e:
                        log("sites refusés non envoyés :", e)
                if started - self.last["launcher"] > 3600:
                    self.last["launcher"] = started
                    self.child.install_launcher()
                if started - self.last["update"] > UPDATE_EVERY and os.path.abspath(sys.argv[0]) == AGENT_BIN:
                    self.last["update"] = started
                    if self.check_update():
                        self.running = False
            except SystemExit:
                raise
            except Exception as e:  # noqa: BLE001 - une erreur ne doit jamais arrêter la surveillance
                log("erreur :", repr(e))
            time.sleep(max(1.0, TICK_SECONDS - (time.time() - started)))
        if self.session_open:
            self.screen("stop")


# ─────────────────────────────── Fenêtre de l'enfant (Tkinter) ───────────────────────────────


def ask_agent(req, path=CHILD_SOCKET, timeout=30):
    """Envoie une demande à l'agent (service root) par le canal local ; {"error": …} si l'agent ne répond pas."""
    try:
        with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as c:
            c.settimeout(timeout)
            c.connect(path)
            c.sendall(json.dumps(req).encode("utf-8") + b"\n")
            data = b""
            while not data.endswith(b"\n"):
                chunk = c.recv(65536)
                if not chunk:
                    break
                data += chunk
        return json.loads(data.decode("utf-8"))
    except (OSError, ValueError):
        return {"error": "Limit'Écran ne répond pas. Redémarre l'ordinateur ou préviens un parent."}


def fmt_minutes(seconds):
    m = max(0, int(seconds)) // 60
    return "%d h %02d" % (m // 60, m % 60) if m >= 60 else "%d min" % m


def limis(n):
    return "%d Limi%s" % (n, "s" if abs(n) > 1 else "")


# Google Chrome en dernier : il refuse l'extension des sites libres (le filtre reste alors strict).
BROWSERS = ("chromium", "chromium-browser", "chromium-freeworld", "brave-browser", "brave", "microsoft-edge", "microsoft-edge-stable",
            "vivaldi", "vivaldi-stable", "google-chrome", "google-chrome-stable")
# Versions Flatpak (Fedora, Linux Mint, Silverblue…) : lancées par leur raccourci exporté.
FLATPAK_BROWSERS = ("org.chromium.Chromium", "com.google.Chrome", "com.brave.Browser", "com.microsoft.Edge", "com.vivaldi.Vivaldi")


def find_free_browser(home, which=shutil.which, exists=os.path.exists):
    """(commande, dossier du profil) du premier navigateur de la famille Chrome trouvé : paquet, snap ou Flatpak."""
    for name in BROWSERS:
        path = which(name)
        if not path:
            continue
        if path.startswith("/snap/") or os.path.realpath(path).endswith("/snap"):
            return path, os.path.join(home, "snap", name, "common", FREE_PROFILE_NAME)  # un navigateur snap n'écrit que là
        return path, os.path.join(home, ".local", "share", FREE_PROFILE_NAME)
    for app in FLATPAK_BROWSERS:
        for exports in (os.path.join(home, ".local/share/flatpak/exports/bin"), "/var/lib/flatpak/exports/bin"):
            path = os.path.join(exports, app)
            if exists(path):
                return path, os.path.join(home, ".var", "app", app, FREE_PROFILE_NAME)  # dossier accessible dans le bac à sable
    return None


def free_browser_cmd(url, home=None, which=shutil.which, exists=os.path.exists, extension=None):
    """
    Commande du navigateur des sites libres (famille Chrome) : profil à part + filtre local, et l'extension Limit'Écran
    (`extension` = son dossier) qui ne laisse ouvrir en page principale que les sites libres et ce vers quoi ils mènent.
    None sans navigateur.
    """
    home = home or os.path.expanduser("~")
    found = find_free_browser(home, which, exists)
    if found:
        path, data = found
        ext = ["--load-extension=" + extension, "--disable-extensions-except=" + extension] if extension else []
        return [
            path,
            "--user-data-dir=" + data,
            *ext,
            "--proxy-server=http://127.0.0.1:%d" % FREE_PROXY_PORT,
            "--no-first-run",
            "--no-default-browser-check",
            # services internes de Chrome (mises à jour, synchronisation, remplissage, optimisations) : inutiles ici
            "--disable-background-networking",
            "--disable-sync",
            "--disable-component-update",
            "--disable-features=OptimizationHints,AutofillServerCommunication,MediaRouter",
            "--new-window",
            url,
        ]
    return None


TASK_STATUS = {"en_attente": "En attente d'un parent", "validee": "Validée", "refusee": "Refusée : refais-la"}


def cmd_fenetre(_args):
    try:
        import tkinter as tk
        from tkinter import messagebox, ttk
    except ImportError:
        sys.exit("La fenêtre a besoin de Tkinter (paquet python3-tk).")

    NAVY, AMBER, CREAM, MINT, SLATE, WHITE = "#1F3A5F", "#F5A524", "#FFFBF5", "#127A6B", "#5B6B7F", "#FFFFFF"
    root = tk.Tk()
    root.title("Limit'Écran")
    root.geometry("500x660")
    root.minsize(380, 480)
    root.configure(bg=CREAM)
    base = ("TkDefaultFont", 11)
    bold = ("TkDefaultFont", 11, "bold")
    style = ttk.Style(root)
    try:
        style.theme_use("clam")
    except tk.TclError:
        pass
    style.configure("TNotebook", background=CREAM, borderwidth=0)
    style.configure("TNotebook.Tab", padding=(8, 6), font=bold)
    style.configure("Go.TButton", font=bold, padding=(10, 6), foreground=WHITE, background=MINT)
    style.map("Go.TButton", background=[("disabled", "#B9C4CF"), ("active", "#0E6357")])
    style.configure("Plain.TButton", font=bold, padding=(10, 6))

    def coin(parent, bg, size=22):
        """Le logo Limi : pièce ambrée marquée « L », dessinée par Tk."""
        c = tk.Canvas(parent, width=size, height=size, bg=bg, highlightthickness=0, bd=0)
        c.create_oval(1, 2, size - 1, size, fill="#C97F0A", outline="")
        c.create_oval(1, 1, size - 1, size - 2, fill=AMBER, outline="#C97F0A")
        c.create_text(size / 2, size / 2 - 1, text="L", fill="#5A3A0A", font=("TkDefaultFont", max(7, int(size * 0.45)), "bold"))
        return c

    head = tk.Frame(root, bg=NAVY, padx=16, pady=14)
    head.pack(fill="x")
    hello = tk.Label(head, text="", bg=NAVY, fg=WHITE, font=("TkDefaultFont", 16, "bold"), anchor="w")
    hello.pack(fill="x")
    numbers = tk.Frame(head, bg=NAVY)
    numbers.pack(fill="x", pady=(8, 0))
    time_lbl = tk.Label(numbers, text="", bg=NAVY, fg=WHITE, font=("TkDefaultFont", 22, "bold"))
    time_lbl.pack(side="left")
    tk.Label(numbers, text=" d'écran", bg=NAVY, fg="#C9D4E2", font=base).pack(side="left", pady=(8, 0))
    limi_box = tk.Frame(numbers, bg=AMBER, padx=8, pady=3)
    limi_box.pack(side="right")
    limi_lbl = tk.Label(limi_box, text="", bg=AMBER, fg="#5A3A0A", font=("TkDefaultFont", 13, "bold"))
    limi_lbl.pack(side="left")
    coin(limi_box, AMBER, 20).pack(side="left", padx=(4, 0))
    status = tk.Label(head, text="", bg=NAVY, fg="#FFD98A", font=base, anchor="w", justify="left", wraplength=460)
    status.pack(fill="x", pady=(8, 0))

    tabs = ttk.Notebook(root)
    tabs.pack(fill="both", expand=True, padx=8, pady=8)
    message = tk.Label(root, text="", bg=CREAM, fg=SLATE, font=base, wraplength=420, justify="left")
    message.pack(fill="x", padx=12, pady=(0, 2))
    # information de l'enfant (RGPD), discrète
    tk.Label(root, text="Tes parents voient les applications et les sites que tu utilises.", bg=CREAM, fg="#9AA5B1", font=("TkDefaultFont", 8), anchor="w").pack(fill="x", padx=12, pady=(0, 6))

    def scroll_tab(title):
        outer = tk.Frame(tabs, bg=CREAM)
        canvas = tk.Canvas(outer, bg=CREAM, highlightthickness=0)
        bar = ttk.Scrollbar(outer, orient="vertical", command=canvas.yview)
        inner = tk.Frame(canvas, bg=CREAM)
        inner.bind("<Configure>", lambda _e: canvas.configure(scrollregion=canvas.bbox("all")))
        win = canvas.create_window((0, 0), window=inner, anchor="nw")
        canvas.bind("<Configure>", lambda e: canvas.itemconfigure(win, width=e.width))
        canvas.configure(yscrollcommand=bar.set)
        canvas.pack(side="left", fill="both", expand=True)
        bar.pack(side="right", fill="y")
        def wheel(_e):  # la molette fait défiler l'onglet sous la souris
            root.bind_all("<Button-4>", lambda _e: canvas.yview_scroll(-2, "units"))
            root.bind_all("<Button-5>", lambda _e: canvas.yview_scroll(2, "units"))
            root.bind_all("<MouseWheel>", lambda e: canvas.yview_scroll(-1 if e.delta > 0 else 1, "units"))

        outer.bind("<Enter>", wheel)
        tabs.add(outer, text=title)
        return inner

    tasks_box, shop_box, free_box, time_box = scroll_tab("Mes tâches"), scroll_tab("Boutique"), scroll_tab("Sites libres"), scroll_tab("Plus de temps")
    view = {}

    def row(parent, title, sub, right, badge=None):
        card = tk.Frame(parent, bg=WHITE, padx=12, pady=10, highlightbackground=NAVY if badge else "#E8DFD0", highlightthickness=2 if badge else 1)
        card.pack(fill="x", padx=6, pady=4)
        right(card)  # le bouton d'abord : il garde toute sa largeur, le texte passe à la ligne
        text = tk.Frame(card, bg=WHITE)
        text.pack(side="left", fill="x", expand=True)
        if badge:
            tk.Label(text, text=badge, bg=NAVY, fg=CREAM, font=("TkDefaultFont", 9, "bold"), padx=6).pack(anchor="w", pady=(0, 3))
        tk.Label(text, text=title, bg=WHITE, fg=NAVY, font=bold, anchor="w", justify="left", wraplength=230).pack(fill="x")
        tk.Label(text, text=sub, bg=WHITE, fg=SLATE, font=base, anchor="w", justify="left", wraplength=230).pack(fill="x")

    def clear(box):
        for w in box.winfo_children():
            w.destroy()

    def act(req, ok_text):
        message.configure(text="Un instant…")
        root.update_idletasks()
        r = ask_agent(req)
        if "error" in r:
            message.configure(text="")
            messagebox.showwarning("Limit'Écran", r["error"], parent=root)
            return
        message.configure(text=ok_text)
        render(r)

    def render(v):
        view.clear()
        view.update(v)
        if "error" in v:
            hello.configure(text="Limit'Écran")
            status.configure(text=v["error"])
            return
        hello.configure(text="Bonjour %s" % v["name"] if v["name"] else "Limit'Écran")
        time_lbl.configure(text=fmt_minutes(v["remainingSeconds"]))
        limi_lbl.configure(text=str(v["balance"]))
        st = v["reasonText"] if not v["allowed"] else "Tu peux utiliser l'ordinateur."
        counting_apps = v.get("countingApps") or []
        st += "\n" + ("Le temps compte pour : " + ", ".join(counting_apps[:4]) if counting_apps else "Le temps ne compte pas en ce moment.")
        if not v["online"]:
            st += "  (hors ligne : les achats et les tâches attendent Internet)"
        status.configure(text=st)

        clear(tasks_box)
        if not v["tasks"]:
            tk.Label(tasks_box, text="Pas de tâche aujourd'hui.", bg=CREAM, fg=SLATE, font=base).pack(pady=20)
        todo_mandatory = [t for t in v["tasks"] if t.get("mandatory") and t.get("status") in ("a_faire", "refusee", None)]
        if todo_mandatory:
            n = len(todo_mandatory)
            tk.Label(tasks_box, text="%d tâche%s obligatoire%s à faire avant l'écran" % (n, "s" if n > 1 else "", "s" if n > 1 else ""), bg=CREAM, fg=NAVY, font=bold, anchor="w").pack(fill="x", padx=8, pady=(6, 2))
        # Obligatoires d'abord, puis le reste (ordre stable).
        for t in sorted(v["tasks"], key=lambda t: 0 if t.get("mandatory") else 1):
            start, end = t.get("startTime"), t.get("deadlineTime")
            window = ("%s – %s" % (start, end)) if start and end else ("dès " + start) if start else ("avant " + end) if end else ""
            sub = "+" + limis(t.get("tokens") or 0) + (("  ·  🕗 " + window) if window else "")
            state = t.get("status")
            overnight = (v.get("dayDate") or dt.date.today().isoformat()) != dt.date.today().isoformat()
            not_yet = state in ("a_faire", "refusee", None) and not overnight and not task_started(t, dt.datetime.now().strftime("%H:%M"))
            if not_yet:
                right = lambda card, t=t: tk.Label(card, text="À partir de %s" % t["startTime"], bg=WHITE, fg=SLATE, font=bold).pack(side="right")
            elif state in ("a_faire", "refusee", None):
                right = lambda card, t=t: ttk.Button(card, text="C'est fait !", style="Go.TButton", command=lambda: act({"op": "complete", "taskId": t["id"]}, "Bravo ! Un parent va valider « %s »." % t["title"])).pack(side="right")
            else:
                right = lambda card, state=state: tk.Label(card, text=TASK_STATUS.get(state, state), bg=WHITE, fg=MINT if state == "validee" else SLATE, font=bold).pack(side="right")
            if state == "refusee":
                sub += "  ·  refusée, tu peux la refaire"
            row(tasks_box, t.get("title") or "", sub, right, "⚠ Obligatoire" if t.get("mandatory") else None)

        clear(shop_box)
        goal = v.get("goal")
        if goal:
            tk.Label(shop_box, text="Objectif : %s (%s / %s Limis)" % (goal["title"], v["balance"], goal["targetTokens"]), bg=CREAM, fg=NAVY, font=bold, anchor="w").pack(fill="x", padx=8, pady=(6, 2))
        if not v["rewards"]:
            tk.Label(shop_box, text="La boutique est vide pour le moment.", bg=CREAM, fg=SLATE, font=base).pack(pady=20)
        for r in v["rewards"]:
            cost = r.get("costTokens") or 0
            kind = {"temps_ecran": "Temps d'écran", "temps_switch": "Temps de Switch"}.get(r.get("kind"), "Récompense (un parent valide)")

            def buy(r=r, cost=cost):
                if messagebox.askyesno("Limit'Écran", "Acheter « %s » pour %s ?" % (r["title"], limis(cost)), parent=root):
                    act({"op": "buy", "rewardId": r["id"]}, "C'est acheté : « %s »." % r["title"])

            def right(card, buy=buy, cost=cost):
                ttk.Button(card, text="Acheter", style="Go.TButton", command=buy, state="normal" if v["balance"] >= cost else "disabled").pack(side="right")
                # Prix : le chiffre puis le logo Limi.
                price = tk.Frame(card, bg="#EEF2F6", padx=8, pady=3)
                price.pack(side="right", padx=(0, 8))
                tk.Label(price, text=str(cost), bg="#EEF2F6", fg=NAVY, font=("TkDefaultFont", 13, "bold")).pack(side="left")
                coin(price, "#EEF2F6", 20).pack(side="left", padx=(4, 0))

            missing = cost - v["balance"]
            row(shop_box, r.get("title") or "", kind + (("  ·  il te manque %d" % missing) if missing > 0 else ""), right)

        clear(free_box)
        sites = v.get("freeSites") or []
        if not sites:
            tk.Label(free_box, text="Aucun site libre pour le moment. Tes parents peuvent en ajouter (Pronote, ENT…).", bg=CREAM, fg=SLATE, font=base, wraplength=380, justify="left").pack(fill="x", padx=8, pady=12)
        else:
            tk.Label(free_box, text="Ces sites ne comptent pas dans ton temps d'écran, même quand il est fini :", bg=CREAM, fg=NAVY, font=bold, wraplength=380, justify="left", anchor="w").pack(fill="x", padx=8, pady=(12, 6))
        refused = v.get("refusedHosts") or []
        if refused:
            tk.Label(
                free_box,
                text="Bloqués à l'instant (pas dans la liste) : " + ", ".join(refused[:6]) + ". Tes parents les voient et peuvent les ajouter.",
                bg="#FFF1D6", fg="#5A3A0A", font=base, wraplength=440, justify="left", anchor="w", padx=8, pady=6,
            ).pack(fill="x", padx=6, pady=(4, 6))
        for site in sites:
            if isinstance(site, str):  # agent plus ancien que la fenêtre
                site = {"domain": site, "url": "https://" + site + "/"}

            def open_site(site=site):
                found = find_free_browser(os.path.expanduser("~"))
                ext = None
                if found:
                    ext = found[1] + "-extension"
                    try:
                        write_free_extension(ext, [x["domain"] if isinstance(x, dict) else x for x in sites])
                    except OSError:
                        ext = None
                cmd = free_browser_cmd(site["url"], extension=ext)
                if not cmd:
                    messagebox.showinfo("Limit'Écran", "Pour ouvrir les sites libres, il faut Google Chrome ou Chromium sur l'ordinateur. Demande à un parent de l'installer.", parent=root)
                    return
                subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
                message.configure(text="Ouverture de %s…" % site["domain"])

            row(free_box, site["domain"], "Libre : ne compte pas", lambda card, open_site=open_site: ttk.Button(card, text="Ouvrir", style="Go.TButton", command=open_site).pack(side="right"))

        clear(time_box)
        req = v.get("timeRequest")
        if req and req.get("status") == "en_attente":
            tk.Label(time_box, text="Tu as demandé %s min de plus. Un parent va répondre." % req.get("minutes"), bg=CREAM, fg=NAVY, font=bold, wraplength=380, justify="left").pack(fill="x", padx=8, pady=12)
        elif v["canRequestTime"]:
            tk.Label(time_box, text="Demander du temps en plus à un parent :", bg=CREAM, fg=NAVY, font=bold, anchor="w").pack(fill="x", padx=8, pady=(12, 6))
            for m in TIME_REQUEST_MINUTES:
                ttk.Button(time_box, text="%d minutes" % m, style="Plain.TButton", command=lambda m=m: act({"op": "request_time", "minutes": m}, "Demande envoyée : %d min." % m)).pack(fill="x", padx=8, pady=3)
        else:
            tk.Label(time_box, text="Les demandes de temps ne sont pas disponibles. Gagne des Limis avec tes tâches !", bg=CREAM, fg=SLATE, font=base, wraplength=380, justify="left").pack(fill="x", padx=8, pady=12)

    def refresh():
        render(ask_agent({"op": "state"}))
        root.after(15000, refresh)

    refresh()
    root.mainloop()


# ─────────────────────────────── macOS ───────────────────────────────
# Même agent, autre couche système : session devant l'écran (console), processus (ps), notifications (osascript),
# applications (.app), DNS familial posé sur les services réseau + pare-feu pf, règles des navigateurs (Managed
# Preferences, Firefox distribution), service launchd. Safari ne prend pas de règles : DNS et hosts s'y appliquent.

MAC_DAEMON = "/Library/LaunchDaemons/fr.limitecran.agent.plist"
MAC_APP = "/Applications/Limit'Écran.app"
MAC_FREE_PROFILE_DIR = ".limitecran"  # sans espace : le profil des sites libres se repère dans la liste des processus
MAC_IDLE_SECONDS = 300
MAC_PF_ANCHOR = "com.apple/limitecran"  # inclus par le pf.conf d'origine (anchor "com.apple/*")
MAC_FAMILY_DNS = ["1.1.1.3", "1.0.0.3", "2606:4700:4700::1113", "2606:4700:4700::1003"]
MAC_DESKTOP_EXEC = {"Finder", "Dock", "loginwindow", "SystemUIServer", "WindowServer", "ControlCenter", "NotificationCenter", "Spotlight"}
# Applications Apple qui comptent comme les autres (les autres outils Apple sont « système »)
MAC_APPLE_LIMITED = {"com.apple.Safari", "com.apple.Music", "com.apple.TV", "com.apple.Chess", "com.apple.PhotoBooth", "com.apple.MobileSMS",
                     "com.apple.FaceTime", "com.apple.AppStore", "com.apple.podcasts", "com.apple.iBooksX", "com.apple.news", "com.apple.garageband10"}
MAC_BROWSERS = (  # Google Chrome en dernier (refuse l'extension des sites libres)
    ("Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium", "org.chromium.Chromium"),
    ("Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", "com.brave.Browser"),
    ("Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", "com.microsoft.Edge"),
    ("Vivaldi", "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi", "com.vivaldi.Vivaldi"),
    ("Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "com.google.Chrome"),
)
MAC_CHROME_HISTORY = (
    "Library/Application Support/Google/Chrome/*/History", "Library/Application Support/Chromium/*/History",
    "Library/Application Support/BraveSoftware/Brave-Browser/*/History", "Library/Application Support/Microsoft Edge/*/History",
    "Library/Application Support/Vivaldi/*/History", MAC_FREE_PROFILE_DIR + "/" + FREE_PROFILE_NAME + "/*/History",
)
MAC_FIREFOX_HISTORY = ("Library/Application Support/Firefox/Profiles/*/places.sqlite",)
MAC_SAFARI_HISTORY = "Library/Safari/History.db"  # protégé par macOS (accès complet au disque) : lu seulement si autorisé
SAFARI_EPOCH = 978307200  # 1er janvier 2001


def peer_uid(conn):
    """Identité (uid) du programme connecté au canal local, donnée par le noyau."""
    if IS_MAC:
        raw = conn.getsockopt(0, 0x001, struct.calcsize("=IIh2x16I"))  # SOL_LOCAL, LOCAL_PEERCRED → struct xucred
        return struct.unpack("=IIh2x16I", raw)[1]
    _pid, uid, _gid = struct.unpack("3i", conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i")))
    return uid


def service_disable(now=False):
    if IS_MAC:
        run(["launchctl", "bootout", "system/fr.limitecran.agent"])
        try:
            os.remove(MAC_DAEMON)
        except OSError:
            pass
        return
    run(["systemctl", "disable"] + (["--now"] if now else []) + ["limitecran-agent.service"])
    if now:
        run(["systemctl", "daemon-reload"])


def parse_ps(comm_output, args_output):
    """
    Processus macOS à partir de deux sorties de ps : `-o pid=,ppid=,uid=,comm=` (chemin de l'exécutable, avec ses
    espaces) et `-o pid=,args=` (paramètres). Les paramètres se lisent mot par mot (le profil des sites libres et le
    filtre local n'ont pas d'espace).
    """
    args = {}
    for line in args_output.splitlines():
        m = re.match(r"\s*(\d+)\s+(.*)$", line)
        if m:
            args[int(m.group(1))] = m.group(2)
    procs = []
    for line in comm_output.splitlines():
        m = re.match(r"\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$", line)
        if not m:
            continue
        pid, exe = int(m.group(1)), m.group(4).strip()
        a = args.get(pid, "")
        rest = a[len(exe):] if a.startswith(exe) else " ".join(a.split()[1:])
        procs.append({"pid": pid, "ppid": int(m.group(2)), "uid": int(m.group(3)), "exe": exe, "argv": [exe] + rest.split()})
    return procs


def mac_app_info(app_path):
    import plistlib
    try:
        with open(os.path.join(app_path, "Contents", "Info.plist"), "rb") as fh:
            info = plistlib.load(fh)
    except Exception:  # noqa: BLE001
        return None
    bid = info.get("CFBundleIdentifier")
    if not bid or not re.match(r"^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$", bid):
        return None
    label = info.get("CFBundleDisplayName") or info.get("CFBundleName") or os.path.basename(app_path)[:-4]
    system = (bid.startswith("com.apple.") and bid not in MAC_APPLE_LIMITED) or "limitecran" in bid.lower()
    return bid, {"label": str(label)[:80], "exec": info.get("CFBundleExecutable") or "", "system": system}


def scan_apps_mac(home=None):
    apps = {}
    dirs = ["/Applications", "/Applications/Utilities", "/System/Applications", "/System/Applications/Utilities"]
    if home:
        dirs.append(os.path.join(home, "Applications"))
    for d in dirs:
        for app in sorted(glob.glob(os.path.join(d, "*.app"))):
            got = mac_app_info(app)
            if got:
                apps[got[0]] = got[1]
    return apps


class MacChild(Child):
    def is_admin(self):
        r = run(["dseditgroup", "-o", "checkmember", "-m", self.user, "admin"])
        return self.uid == 0 or r.stdout.startswith("yes")

    def sessions(self):
        """Une seule session graphique : celle de la personne devant l'écran (console)."""
        try:
            console_uid = os.stat("/dev/console").st_uid
        except OSError:
            return []
        if console_uid != self.uid:
            return []
        idle = 0.0
        m = re.search(r'"HIDIdleTime"\s*=\s*(\d+)', run(["ioreg", "-c", "IOHIDSystem", "-d", "4"]).stdout)
        if m:
            idle = int(m.group(1)) / 1e9
        locked = bool(run(["pgrep", "-x", "ScreenSaverEngine"]).stdout.strip())  # économiseur / écran verrouillé
        return [{"id": "console", "active": True, "locked": locked, "idle": idle > MAC_IDLE_SECONDS, "type": "aqua"}]

    def _as_user(self, cmd):
        return ["launchctl", "asuser", str(self.uid), "sudo", "-u", self.user, "--"] + cmd

    def notify(self, title, body, urgent=False):
        esc = lambda t: t.replace("\\", "\\\\").replace('"', '\\"')
        run(self._as_user(["osascript", "-e", 'display notification "%s" with title "%s"' % (esc(body), esc(title))]), timeout=10)

    def desktop_dir(self):
        d = os.path.join(self.home, "Desktop")
        return d if os.path.isdir(d) else None

    def install_launcher(self):
        """Petite application « Limit'Écran » dans /Applications (et un alias sur le bureau de l'enfant)."""
        contents = os.path.join(MAC_APP, "Contents")
        script = "#!/bin/sh\nexec /usr/bin/python3 %s fenetre\n" % AGENT_BIN
        plist = ('<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
                 '<plist version="1.0"><dict><key>CFBundleName</key><string>Limit\'Écran</string><key>CFBundleIdentifier</key><string>fr.limitecran.fenetre</string>'
                 '<key>CFBundleExecutable</key><string>limitecran</string><key>CFBundlePackageType</key><string>APPL</string></dict></plist>\n')
        if read_text(os.path.join(contents, "MacOS", "limitecran")) != script:
            write_file(os.path.join(contents, "MacOS", "limitecran"), script, mode=0o755)
            write_file(os.path.join(contents, "Info.plist"), plist)
        desk = self.desktop_dir()
        if desk:
            link = os.path.join(desk, "Limit'Écran.app")
            if not os.path.lexists(link):
                try:
                    os.symlink(MAC_APP, link)
                    os.lchown(link, self.uid, pwd.getpwnam(self.user).pw_gid)
                except OSError:
                    pass

    def processes(self):
        comm = run(["ps", "-axww", "-o", "pid=,ppid=,uid=,comm="]).stdout
        args = run(["ps", "-axww", "-o", "pid=,args="]).stdout
        return [p for p in parse_ps(comm, args) if p["uid"] == self.uid]

    def matching(self, names, procs=None, exclude=()):
        names = set(names) - MAC_DESKTOP_EXEC
        return super().matching(names, procs, exclude)


class MacWebFilterApplier(WebFilterApplier):
    """hosts identique ; règles des navigateurs en Managed Preferences ; DNS familial + pare-feu pf au lieu de nftables."""

    def desired(self, f):
        files = super().desired(f)
        files = {p: c for p, c in files.items() if p == HOSTS_FILE}
        active = filter_active(f)
        import plistlib
        for _name, _path, bid in MAC_BROWSERS:
            files["/Library/Managed Preferences/%s.plist" % bid] = plistlib.dumps(chromium_policy(f)).decode("utf-8") if active else None
        ff = "/Applications/Firefox.app/Contents/Resources/distribution/policies.json"
        if os.path.isdir("/Applications/Firefox.app"):
            files[ff] = self._firefox_file(ff, f) if active else self._firefox_restore(ff)
        return files

    def apply(self, f, report_tamper):
        before = read_text(HOSTS_FILE)
        ok = super().apply(f, report_tamper)
        if read_text(HOSTS_FILE) != before:
            run(["dscacheutil", "-flushcache"])
            run(["killall", "-HUP", "mDNSResponder"])
        return ok

    def _apply_nft(self, f, report_tamper):  # sous macOS : DNS des services réseau + pare-feu pf
        services = [l for l in run(["networksetup", "-listallnetworkservices"]).stdout.splitlines()[1:] if l and not l.startswith("*")]
        saved = self.applied.setdefault("dnsBefore", {})
        if f["adult"]:
            for svc in services:
                current = run(["networksetup", "-getdnsservers", svc]).stdout.split()
                if current != MAC_FAMILY_DNS:
                    if svc in saved and self.applied.get("dns"):
                        report_tamper("web_filter")  # quelqu'un a changé le DNS : rétabli
                    saved.setdefault(svc, current if current and current[0] != "There" else ["Empty"])
                    run(["networksetup", "-setdnsservers", svc] + MAC_FAMILY_DNS)
            self.applied["dns"] = True
            rules = ("block drop out quick proto { tcp udp } to any port 853\n"
                     "block drop out quick proto { tcp udp } to ! { %s } port 53\n") % ", ".join(MAC_FAMILY_DNS + ["127.0.0.1", "::1"])
            ok = True
            if "853" not in run(["pfctl", "-a", MAC_PF_ANCHOR, "-s", "rules"]).stdout:
                if self.applied.get("pf"):
                    report_tamper("web_filter")  # règles retirées pendant ce démarrage
                ok = run(["pfctl", "-a", MAC_PF_ANCHOR, "-f", "-"], input=rules).returncode == 0
                self.applied["pf"] = ok
            if "Status: Enabled" not in run(["pfctl", "-s", "info"]).stdout:
                run(["pfctl", "-E"])
            run(["dscacheutil", "-flushcache"])
            return ok
        if self.applied.get("dns"):
            for svc, before in saved.items():
                run(["networksetup", "-setdnsservers", svc] + (before or ["Empty"]))
            run(["pfctl", "-a", MAC_PF_ANCHOR, "-F", "all"])
            self.applied["dns"] = False
            self.applied["pf"] = False
            self.applied["dnsBefore"] = {}
        return True


def find_free_browser_mac(home, which=None, exists=os.path.exists):
    for _name, path, _bid in MAC_BROWSERS:
        if exists(path):
            return path, os.path.join(home, MAC_FREE_PROFILE_DIR, FREE_PROFILE_NAME)
    return None


def history_files_mac(home):
    files = []
    for pattern in MAC_CHROME_HISTORY:
        files += [("chrome", f) for f in glob.glob(os.path.join(home, pattern))]
    for pattern in MAC_FIREFOX_HISTORY:
        files += [("firefox", f) for f in glob.glob(os.path.join(home, pattern))]
    safari = os.path.join(home, MAC_SAFARI_HISTORY)
    if os.path.exists(safari):
        files.append(("safari", safari))
    return files


if IS_MAC:
    Child = MacChild  # noqa: F811
    WebFilterApplier = MacWebFilterApplier  # noqa: F811
    scan_apps = scan_apps_mac  # noqa: F811
    find_free_browser = find_free_browser_mac  # noqa: F811
    history_files = history_files_mac  # noqa: F811
    DESKTOP_EXEC = DESKTOP_EXEC | MAC_DESKTOP_EXEC


# ─────────────────────────────── Commandes ───────────────────────────────


def require_root():
    if os.geteuid() != 0:
        sys.exit("Cette commande doit être lancée en administrateur (sudo).")


def cmd_pair(args):
    require_root()
    try:
        child = Child(args.user)
    except KeyError:
        sys.exit("Compte Linux introuvable : %s" % args.user)
    if child.uid == 0:
        sys.exit("Choisissez le compte de l'enfant, pas le compte administrateur.")
    code = re.sub(r"\D", "", args.code)
    api = Api(args.server)
    try:
        r = api.call("POST", "/api/device/pair", {"code": code, "name": (args.name or socket.gethostname())[:40], "platform": "macos" if IS_MAC else "linux"})
    except ApiError as e:
        sys.exit("Association refusée : %s" % (e.code if e.code != "invalid_code" else "code invalide ou expiré (générez-en un nouveau)"))
    except (urllib.error.URLError, OSError) as e:
        sys.exit("Serveur injoignable : %s" % e)
    conf = {"server": args.server.rstrip("/"), "token": r["token"], "deviceId": r["deviceId"], "user": args.user, "childName": r.get("childName")}
    os.makedirs(CONF_DIR, exist_ok=True)
    os.chmod(CONF_DIR, 0o700)
    write_json(CONF_FILE, conf, 0o600)
    os.makedirs(STATE_DIR, exist_ok=True)
    os.chmod(STATE_DIR, 0o755)
    if args.consent:
        try:
            Api(conf["server"], conf["token"]).call("POST", "/api/device/consent", {"version": CONSENT_VERSION})
        except (ApiError, urllib.error.URLError, OSError) as e:
            log("consentement non enregistré :", e)
    print("Ordinateur associé à %s (compte Linux « %s »)." % (r.get("childName"), args.user))
    if child.is_admin():
        print("Attention : le compte « %s » a les droits d'administrateur. Retirez-les (sudo deluser %s sudo), sinon il peut couper Limit'Écran." % (args.user, args.user))


def cmd_run(_args):
    require_root()
    conf = read_json(CONF_FILE)
    if not conf:
        sys.exit("Ordinateur pas encore associé : lancez « sudo limitecran-agent pair CODE --user COMPTE ».")
    Agent(conf).loop()


def cmd_status(_args):
    s = read_json(STATUS_FILE)
    if not s:
        print("Limit'Écran n'a pas encore d'information.")
        return
    mins = int(s.get("remainingSeconds") or 0) // 60
    print("Limit'Écran — %s" % (s.get("child") or ""))
    print("Temps d'écran restant : %d h %02d min" % (mins // 60, mins % 60) if mins >= 60 else "Temps d'écran restant : %d min" % mins)
    if s.get("balance") is not None:
        print("Limis : %s" % s["balance"])
    if not s.get("allowed"):
        print(REASON_TEXT.get(s.get("reason") or "", "Écran en pause."))
    apps = s.get("countingApps") or []
    print("Le temps compte pour : " + ", ".join(apps) if apps else "Le temps ne compte pas en ce moment.")
    if not s.get("online"):
        print("(hors ligne : dernières règles connues)")


def cmd_uninstall(_args):
    require_root()
    conf = read_json(CONF_FILE)
    if conf:
        try:
            Api(conf["server"], conf["token"]).call("POST", "/api/device/tamper", {"kind": "uninstall"})
        except Exception:  # noqa: BLE001
            pass
    service_disable(now=True)
    if IS_MAC:
        shutil.rmtree(MAC_APP, ignore_errors=True)
    WebFilterApplier().remove_all()
    for p in [CONF_FILE, CACHE_FILE, APPLIED_FILE, STATUS_FILE, LAUNCHER_FILE, ICON_FILE, *OLD_LAUNCHER_FILES, "/etc/systemd/system/limitecran-agent.service", "/usr/local/bin/limitecran-agent"]:
        try:
            os.remove(p)
        except OSError:
            pass
    run(["systemctl", "daemon-reload"])
    print("Limit'Écran est retiré de cet ordinateur. Pensez à retirer l'appareil dans l'espace parent.")


def main(argv=None):
    p = argparse.ArgumentParser(prog="limitecran-agent", description="Limit'Écran — agent Linux")
    p.add_argument("--version", action="version", version=VERSION)
    sub = p.add_subparsers(dest="cmd", required=True)
    pa = sub.add_parser("pair", help="associer l'ordinateur avec le code de l'espace parent")
    pa.add_argument("code")
    pa.add_argument("--user", required=True, help="compte Linux de l'enfant")
    pa.add_argument("--server", default=os.environ.get("LIMITECRAN_SERVER", DEFAULT_SERVER))
    pa.add_argument("--name", default=None)
    pa.add_argument("--consent", action="store_true", help="l'enfant a été informé (comme l'écran d'accueil du téléphone)")
    pa.set_defaults(fn=cmd_pair)
    sub.add_parser("run", help="surveillance (service systemd)").set_defaults(fn=cmd_run)
    sub.add_parser("status", help="temps restant (pour l'enfant)").set_defaults(fn=cmd_status)
    sub.add_parser("uninstall", help="retirer le filtrage et l'association").set_defaults(fn=cmd_uninstall)
    sub.add_parser("fenetre", help="fenêtre de l'enfant : temps, tâches, boutique").set_defaults(fn=cmd_fenetre)
    args = p.parse_args(argv)
    args.fn(args)


if __name__ == "__main__":
    main()
LIMITECRAN_AGENT_FIN
install -m 0755 "$BIN.tmp" "$BIN" && rm -f "$BIN.tmp"

if [ -f /etc/limitecran/agent.json ]; then
  say "Cet ordinateur est déjà associé : mise à jour de l'agent seulement."
else
  [ -n "$CODE" ] || CODE="$(ask "Code d'association (espace parent → Appareils) : ")"
  say "Comptes de cet ordinateur :"
  getent passwd | awk -F: '$3 >= 1000 && $3 < 60000 { print "  - " $1 " (" $5 ")" }' | sed 's/ ()//; s/,,,//'
  CHILD="$(ask "Nom du compte Linux de l'enfant : ")"
  id "$CHILD" >/dev/null 2>&1 || die "le compte « $CHILD » n'existe pas. Créez-lui un compte standard, puis relancez."
  [ "$CHILD" != "${SUDO_USER:-}" ] || die "c'est votre compte : choisissez celui de l'enfant (un compte standard, sans droits d'administrateur)."
  echo
  echo "Limit'Écran va compter le temps d'écran de « $CHILD », verrouiller sa session quand le temps est fini,"
  echo "fermer les applications que vous bloquez et filtrer Internet sur tout l'ordinateur."
  echo "Les parents voient les applications utilisées et le nom des sites visités (jamais les pages, ni le contenu de l'écran)."
  OK="$(ask "Avez-vous expliqué cela à votre enfant ? [o/N] ")"
  CONSENT=()
  case "$OK" in o|O|oui|Oui|OUI|y|Y) CONSENT=(--consent) ;; esac
  "$BIN" pair "$CODE" --user "$CHILD" --server "$SERVER" ${CONSENT[@]+"${CONSENT[@]}"}
fi

cat > "$UNIT" <<'UNIT_FIN'
[Unit]
Description=Limit'Écran (contrôle parental)
After=network-online.target systemd-logind.service
Wants=network-online.target

[Service]
ExecStart=/usr/local/bin/limitecran-agent run
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
UNIT_FIN
systemctl daemon-reload
systemctl enable limitecran-agent.service >/dev/null 2>&1
systemctl restart limitecran-agent.service

say "C'est prêt."
echo "L'ordinateur apparaît dans l'espace parent (onglet Appareils). Le filtrage et les sites libres se règlent dans l'onglet Ordinateur."
echo "Sur le bureau de l'enfant, l'icône « Limit'Écran » ouvre sa fenêtre : temps restant, tâches à cocher, boutique."
