Annexe : Noyau - desktop.py

Rôle et utilité

Si main.py allume la lumière, desktop.py est l'âme du système. Ce fichier est le composant le plus volumineux et le plus critique de GrimOS. Il dessine l'environnement visuel complet (Bureau, Barre des tâches, Menu Démarrer) et gère les processus vitaux en arrière-plan.

Implémentation technique

Pistes de modification

Code Source

import tkinter as tk
from tkinter import simpledialog
import subprocess
from time import strftime
import sys
import os

from core.app_manager import AppManager
from core.config import load_applications

class ToolTip:
    def __init__(self, widget, text):
        self.widget = widget
        self.text = text
        self.tooltip_window = None
        self.widget.bind("<Enter>", self.show_tooltip)
        self.widget.bind("<Leave>", self.hide_tooltip)

    def show_tooltip(self, event=None):
        if self.tooltip_window or not self.text:
            return
        x = self.widget.winfo_rootx()
        y = self.widget.winfo_rooty() - 25
        self.tooltip_window = tw = tk.Toplevel(self.widget)
        tw.wm_overrideredirect(True)
        tw.wm_geometry(f"+{x}+{y}")
        label = tk.Label(tw, text=self.text, justify='left',
                         background="#ffffe0", relief='solid', borderwidth=1,
                         font=("Arial", "9", "normal"))
        label.pack(ipadx=2, ipady=1)

    def hide_tooltip(self, event=None):
        tw = self.tooltip_window
        self.tooltip_window = None
        if tw:
            tw.destroy()

class Desktop(tk.Frame):
    def __init__(self, parent, settings):
        super().__init__(parent)
        self.parent = parent
        self.settings = settings
        self.pack(fill="both", expand=True)

        self.app_manager = AppManager(self)
        self.apps = load_applications()

        self.toasts = []

        # Desktop area
        import sys, os
        sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
        from core.theme import THEMES
        theme_name = self.settings.get("theme", "GrimOS")
        self.theme_data = THEMES.get(theme_name, THEMES["GrimOS"])
        bg_color = self.theme_data.get("desktop_bg", "gray")

        # Taskbar
        self.taskbar = tk.Frame(self, height=40, bg=self.theme_data.get("taskbar_bg", "#333"))
        self.taskbar.pack(side="bottom", fill="x")
        self.taskbar.pack_propagate(False)

        self.desktop_frame = tk.Frame(self, bg=bg_color)
        self.desktop_frame.pack(fill="both", expand=True)

        self.load_desktop_icons()

        # Load icons
        self.icons = {}
        icon_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "icons")
        icon_theme = self.theme_data.get("icon_theme", "grimos")
        icon_theme_dir = os.path.join(icon_dir, "themes", icon_theme)

        for iname in ['menu_poweroff', 'menu_reboot', 'menu_logoff', 'btn_apply', 'btn_refresh', 'btn_kill', 'btn_start', 'btn_wifi', 'btn_usb', 'btn_help', 'menu_grimoire', 'btn_audio', 'btn_trash']:
            ipath = os.path.join(icon_theme_dir, iname + ".png")
            if not os.path.exists(ipath):
                ipath = os.path.join(icon_dir, iname + ".png")
            if os.path.exists(ipath):
                self.icons[iname] = tk.PhotoImage(file=ipath)

        # Load app icons
        self.app_icons = {}
        for app in self.apps:
            icon_path = app.get("icon")
            if icon_path:
                full_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), icon_path)
                if os.path.exists(full_path):
                    self.app_icons[app.get("module")] = tk.PhotoImage(file=full_path)

        # Start button
        self.start_btn = tk.Button(self.taskbar, image=self.icons.get("btn_start"),
                                   bg=self.theme_data.get("start_btn_bg", "blue"), 
                                   fg=self.theme_data.get("start_btn_fg", "white"), 
                                   command=self.toggle_start_menu)
        self.start_btn.pack(side="left", padx=5, pady=5)
        ToolTip(self.start_btn, "Démarrer")

        # Taskbar window buttons container
        self.taskbar_windows = tk.Frame(self.taskbar, bg=self.theme_data.get("taskbar_bg", "#333"))
        self.taskbar_windows.pack(side="left", fill="both", expand=True, padx=5)
        self.taskbar_btns = {}

        # Clock
        self.clock_lbl = tk.Label(self.taskbar, bg=self.theme_data.get("taskbar_bg", "#333"), fg=self.theme_data.get("taskbar_fg", "white"), font=("Arial", 10))
        self.clock_lbl.pack(side="right", padx=10)
        self.update_clock()

        # System Monitor
        self.sysmon_canvas = tk.Canvas(self.taskbar, height=24, bg=self.theme_data.get("taskbar_bg", "#333"), highlightthickness=0)
        self.sysmon_canvas.pack(side="right", padx=10, pady=3)

        self.last_cpu_total = 0
        self.last_cpu_idle = 0
        self.update_sysmon()

        # Keyboard Layout
        self.kb_layout = "fr"
        self.kb_btn = tk.Button(self.taskbar, text="FR", bg=self.theme_data.get("panel_btn_bg", "#555"), fg=self.theme_data.get("taskbar_fg", "white"), font=("Arial", 10, "bold"), command=self.toggle_keyboard)
        self.kb_btn.pack(side="right", padx=5, pady=5)
        self.apply_keyboard_layout()



        # Wi-Fi Module
        from core.wifi import WifiManager
        self.wifi_manager = WifiManager(self)
        self.sudo_pwd = None
        self.wifi_btn = tk.Button(self.taskbar, image=self.icons.get("btn_wifi"), bg=self.theme_data.get("panel_btn_bg", "#555"), fg=self.theme_data.get("taskbar_fg", "white"), command=self.show_wifi_menu)
        self.wifi_btn.pack(side="right", padx=5, pady=5)
        ToolTip(self.wifi_btn, "Wi-Fi")

        # USB Module
        self.usb_btn = tk.Button(self.taskbar, image=self.icons.get("btn_usb"), bg=self.theme_data.get("panel_btn_bg", "#555"), fg=self.theme_data.get("taskbar_fg", "white"), command=self.show_usb_menu)
        self.usb_btn.pack(side="right", padx=5, pady=5)
        ToolTip(self.usb_btn, "Périphériques USB")
        self.known_unmounted_usbs = set()
        self.after(3000, self.update_usb)

        # Audio Module
        from core.audio import AudioManager
        self.audio_manager = AudioManager(self)
        self.audio_btn = tk.Button(self.taskbar, image=self.icons.get("btn_audio"), bg=self.theme_data.get("panel_btn_bg", "#555"), fg=self.theme_data.get("taskbar_fg", "white"), command=self.show_audio_menu)
        self.audio_btn.pack(side="right", padx=5, pady=5)
        ToolTip(self.audio_btn, "Contrôle du volume")

        # Start Menu (hidden by default)
        self.start_menu = tk.Frame(self.desktop_frame, bg="white", relief="raised", bd=2)
        self.start_menu_visible = False
        self.build_start_menu()

        # Hide menu when clicking desktop
        self.desktop_frame.bind("<ButtonPress-1>", self.hide_start_menu)

        # Context menu (Right click)
        self.context_menu = tk.Menu(self, tearoff=0)
        self.context_menu.add_command(label="Paramètres", command=lambda: self.app_manager.launch_app({"name": "Paramètres", "module": "apps.parametres.app"}))
        self.context_menu.add_separator()
        self.context_menu.add_command(label="Redémarrer GrimOS", command=self.shutdown_grimos)
        self.context_menu.add_separator()
        self.context_menu.add_command(label="Annuler", command=lambda: None)

        def show_context_menu(event):
            try:
                self.context_menu.tk_popup(event.x_root, event.y_root)
            finally:
                self.context_menu.grab_release()

        self.desktop_frame.bind("<Button-3>", show_context_menu)

        # Restore previous session if any
        self.app_manager.restore_session()

        # Start power management
        self.after(5000, self.check_power)

        # Start clipboard sync
        self.last_prim = ""
        self.after(1000, self.sync_clipboards)

        # Start IPC notifications watcher
        self.after(2000, self.check_notifications)

    def check_notifications(self):
        notify_file = "/tmp/grimos_notify"
        if os.path.exists(notify_file):
            try:
                with open(notify_file, "r", encoding="utf-8") as f:
                    msg = f.read().strip()
                os.remove(notify_file)
                if msg:
                    if msg.startswith("POPUP:"):
                        from tkinter import messagebox
                        messagebox.showinfo("Message Système", msg[6:])
                    else:
                        self.show_toast(msg)
            except Exception:
                pass
        self.after(1000, self.check_notifications)

    def sync_clipboards(self):
        try:
            prim = self.selection_get(selection="PRIMARY")
            if prim and prim != self.last_prim:
                self.clipboard_clear()
                self.clipboard_append(prim)
                self.last_prim = prim
        except:
            pass
        self.after(500, self.sync_clipboards)

    def check_power(self):
        try:
            is_ac = True
            if os.path.exists('/sys/class/power_supply/AC/online'):
                with open('/sys/class/power_supply/AC/online', 'r') as f:
                    val = f.read().strip()
                    if val == '0':
                        is_ac = False
            elif os.path.exists('/sys/class/power_supply/ACAD/online'):
                with open('/sys/class/power_supply/ACAD/online', 'r') as f:
                    val = f.read().strip()
                    if val == '0':
                        is_ac = False

            if is_ac:
                subprocess.run(['xset', 's', 'off'], capture_output=True)
                subprocess.run(['xset', '-dpms'], capture_output=True)
            else:
                res = subprocess.run(['xprintidle'], capture_output=True, text=True)
                if res.returncode == 0:
                    idle_ms = int(res.stdout.strip())
                    if idle_ms > 180000: # 3 minutes
                        pwd = self.settings.get("sudo_pwd", "")
                        subprocess.run(['sudo', '-S', 'poweroff'], input=pwd+'\n', text=True, capture_output=True)
        except Exception:
            pass
        self.after(10000, self.check_power)

    def update_clock(self):
        time_string = strftime('%H:%M')
        self.clock_lbl.config(text=time_string)
        self.after(1000, self.update_clock)

    def update_sysmon(self):
        try:
            with open('/proc/meminfo', 'r') as f:
                lines = f.readlines()
            mem = {}
            for line in lines:
                parts = line.split()
                if len(parts) >= 2:
                    mem[parts[0].strip(':')] = int(parts[1])
            ram_pct = int(100 * (1 - mem.get('MemAvailable', mem.get('MemFree', 0)) / mem.get('MemTotal', 1)))
            swap_tot = mem.get('SwapTotal', 0)
            swap_pct = int(100 * (1 - mem.get('SwapFree', 0) / swap_tot)) if swap_tot > 0 else 0

            with open('/proc/stat', 'r') as f:
                cpu_line = f.readline().split()
            cpu_idle = int(cpu_line[4]) + int(cpu_line[5])
            cpu_total = sum(int(x) for x in cpu_line[1:8])
            cpu_diff = cpu_total - self.last_cpu_total
            idle_diff = cpu_idle - self.last_cpu_idle
            cpu_pct = int(100 * (1 - idle_diff / cpu_diff)) if cpu_diff > 0 else 0
            self.last_cpu_total = cpu_total
            self.last_cpu_idle = cpu_idle

            graph_width = self.settings.get("cpu_graph_width", 10)
            if not hasattr(self, 'cpu_history'):
                self.cpu_history = [0] * graph_width
                self.ram_history = [0] * graph_width
                self.swap_history = [0] * graph_width

            while len(self.cpu_history) > graph_width:
                self.cpu_history.pop(0)
                self.ram_history.pop(0)
                self.swap_history.pop(0)
            while len(self.cpu_history) < graph_width:
                self.cpu_history.insert(0, 0)
                self.ram_history.insert(0, 0)
                self.swap_history.insert(0, 0)

            self.cpu_history.pop(0)
            self.cpu_history.append(cpu_pct)
            self.ram_history.pop(0)
            self.ram_history.append(ram_pct)
            self.swap_history.pop(0)
            self.swap_history.append(swap_pct)

            temp = 0
            if os.path.exists('/sys/class/thermal/thermal_zone0/temp'):
                with open('/sys/class/thermal/thermal_zone0/temp', 'r') as f:
                    temp = int(int(f.read().strip()) / 1000)

            cap = None
            status = ""
            try:
                for bat_dir in os.listdir('/sys/class/power_supply'):
                    if bat_dir.startswith('BAT') or bat_dir.startswith('macsmc-battery'):
                        with open(f'/sys/class/power_supply/{bat_dir}/capacity', 'r') as f:
                            cap = int(f.read().strip())

                        with open(f'/sys/class/power_supply/{bat_dir}/status', 'r') as f:
                            stat_txt = f.read().strip()
                            if stat_txt == "Charging": status = "⚡"
                            elif stat_txt == "Discharging": status = "🔋"
                            elif stat_txt == "Full": status = "🔌"
                        break
            except Exception:
                pass

            self.sysmon_canvas.delete("all")
            bar_w = 3
            graph_px_width = graph_width * bar_w
            graph_h = 20
            pad_y = 2
            current_x = 5
            fg_color = self.theme_data.get("taskbar_fg", "white")

            self.sysmon_canvas.create_rectangle(current_x, pad_y, current_x + graph_px_width, pad_y + graph_h, outline="black")
            for i, val in enumerate(self.cpu_history):
                h = int((val / 100) * graph_h)
                if h > 0:
                    self.sysmon_canvas.create_rectangle(current_x + i * bar_w, pad_y + graph_h - h, current_x + (i + 1) * bar_w, pad_y + graph_h, fill="green", outline="")

            current_x += graph_px_width + 5
            self.sysmon_canvas.create_text(current_x, pad_y + graph_h//2, text=f"{temp:02d}°C", fill=fg_color, anchor="w", font=("Arial", 9))
            current_x += 35

            self.sysmon_canvas.create_rectangle(current_x, pad_y, current_x + graph_px_width, pad_y + graph_h, outline="black")
            for i, val in enumerate(self.ram_history):
                h = int((val / 100) * graph_h)
                if h > 0:
                    self.sysmon_canvas.create_rectangle(current_x + i * bar_w, pad_y + graph_h - h, current_x + (i + 1) * bar_w, pad_y + graph_h, fill="blue", outline="")

            current_x += graph_px_width + 5

            self.sysmon_canvas.create_rectangle(current_x, pad_y, current_x + graph_px_width, pad_y + graph_h, outline="black")
            for i, val in enumerate(self.swap_history):
                h = int((val / 100) * graph_h)
                if h > 0:
                    self.sysmon_canvas.create_rectangle(current_x + i * bar_w, pad_y + graph_h - h, current_x + (i + 1) * bar_w, pad_y + graph_h, fill="red", outline="")

            current_x += graph_px_width + 10

            if cap is not None:
                bat_w = 20
                bat_h = 10
                bat_y = pad_y + (graph_h - bat_h) // 2
                self.sysmon_canvas.create_rectangle(current_x, bat_y, current_x + bat_w, bat_y + bat_h, outline=fg_color)
                self.sysmon_canvas.create_rectangle(current_x + bat_w, bat_y + 3, current_x + bat_w + 2, bat_y + bat_h - 3, fill=fg_color, outline="")

                fill_w = int((cap / 100) * (bat_w - 2))
                if fill_w > 0:
                    self.sysmon_canvas.create_rectangle(current_x + 1, bat_y + 1, current_x + 1 + fill_w, bat_y + bat_h - 1, fill="orange", outline="")

                current_x += bat_w + 5
                self.sysmon_canvas.create_text(current_x, pad_y + graph_h//2, text=f"{status} {cap}%", fill=fg_color, anchor="w", font=("Arial", 9))
                current_x += 45

            self.sysmon_canvas.config(width=current_x + 5)
        except Exception:
            pass
        self.after(1000, self.update_sysmon)

    def update_usb(self):
        pwd = self.settings.get("sudo_pwd")
        if pwd:
            try:
                res = subprocess.run(['lsblk', '-J', '-o', 'NAME,RM,MOUNTPOINT'], text=True, capture_output=True)
                import json
                data = json.loads(res.stdout)

                current_unmounted = set()

                for bd in data.get('blockdevices', []):
                    if bd.get('rm') in [True, '1', 1]:
                        children = bd.get('children', [])
                        if children:
                            for child in children:
                                if not child.get('mountpoint'):
                                    current_unmounted.add(child.get('name'))
                        else:
                            if not bd.get('mountpoint'):
                                current_unmounted.add(bd.get('name'))

                # Find new unmounted drives
                new_drives = current_unmounted - self.known_unmounted_usbs
                for d in new_drives:
                    mnt_dir = f"/tmp/usb_{d}"
                    os.makedirs(mnt_dir, exist_ok=True)
                    subprocess.run(['sudo', '-S', 'mount', f'/dev/{d}', mnt_dir], input=pwd+'\n', text=True, capture_output=True)

                self.known_unmounted_usbs = current_unmounted
            except:
                pass
        self.after(3000, self.update_usb)

    def show_usb_menu(self):
        pwd = self.settings.get("sudo_pwd")
        if not pwd:
            from tkinter import messagebox
            messagebox.showerror("Erreur", "Mot de passe Sudo non configuré dans Paramètres.")
            return

        try:
            res = subprocess.run(['lsblk', '-J', '-o', 'NAME,RM,MOUNTPOINT,SIZE'], text=True, capture_output=True)
            import json
            data = json.loads(res.stdout)

            mounted_devices = []
            for bd in data.get('blockdevices', []):
                if bd.get('rm') in [True, '1', 1]:
                    children = bd.get('children', [])
                    if children:
                        for child in children:
                            if child.get('mountpoint'):
                                mounted_devices.append(child)
                    else:
                        if bd.get('mountpoint'):
                            mounted_devices.append(bd)

            if not mounted_devices:
                from tkinter import messagebox
                messagebox.showinfo("USB", "Aucun périphérique USB monté actuellement.")
                return

            if hasattr(self, 'usb_popup') and self.usb_popup.winfo_exists():
                self.usb_popup.destroy()
                return

            self.usb_popup = tk.Toplevel(self)
            self.usb_popup.title("Éjecter USB")
            self.usb_popup.geometry("300x200")

            self.usb_popup.update_idletasks()
            x = self.winfo_width() - 310
            y = self.winfo_height() - 250
            self.usb_popup.geometry(f"+{x}+{y}")
            self.usb_popup.transient(self.winfo_toplevel())

            tk.Label(self.usb_popup, text="Cliquez pour éjecter :", font=("Arial", 10, "bold")).pack(pady=5)

            for dev in mounted_devices:
                name = dev.get('name')
                mountpoint = dev.get('mountpoint')
                size = dev.get('size')

                def unmount(n=name, m=mountpoint):
                    r = subprocess.run(['sudo', '-S', 'umount', f'/dev/{n}'], input=pwd+'\n', text=True, capture_output=True)
                    from tkinter import messagebox
                    if r.returncode == 0:
                        self.known_unmounted_usbs.add(n)
                        subprocess.run(['sudo', '-S', 'rmdir', m], input=pwd+'\n', text=True)
                        messagebox.showinfo("USB", f"Le périphérique {n} a été éjecté en toute sécurité.")
                        if self.usb_popup.winfo_exists():
                            self.usb_popup.destroy()
                    else:
                        messagebox.showerror("Erreur", r.stderr)

                tk.Button(self.usb_popup, text=f"⏏️ /dev/{name} ({size})\n{mountpoint}", command=unmount).pack(fill="x", padx=5, pady=2)

        except Exception as e:
            from tkinter import messagebox
            messagebox.showerror("Erreur", str(e))

    def show_audio_menu(self):
        if hasattr(self, 'audio_popup') and self.audio_popup.winfo_exists():
            self.audio_popup.destroy()
            return

        self.audio_popup = tk.Toplevel(self)
        self.audio_popup.overrideredirect(True)
        self.audio_popup.configure(bg="#222", bd=1, relief="solid")

        # Positionnement au-dessus du bouton
        x = self.audio_btn.winfo_rootx()
        y = self.audio_btn.winfo_rooty() - 230
        self.audio_popup.geometry(f"120x220+{x-30}+{y}")

        # Titre
        tk.Label(self.audio_popup, text="Volume", font=("Arial", 10, "bold"), bg="#222", fg="white").pack(pady=5)

        # Slider
        current_vol = self.audio_manager.get_volume()

        def on_volume_change(val):
            self.audio_manager.set_volume(int(val))

        vol_scale = tk.Scale(self.audio_popup, from_=100, to=0, orient="vertical", bg="#222", fg="white", highlightthickness=0, command=on_volume_change)
        vol_scale.set(current_vol)
        vol_scale.pack(fill="y", expand=True)

        # Bouton de test
        btn_test = tk.Button(self.audio_popup, text="Tester le son", bg="#4CAF50", fg="white", relief="flat", command=self.audio_manager.test_audio)
        btn_test.pack(pady=5, padx=10, fill="x")

        # Binding pour fermer quand on clique ailleurs
        def check_focus(e):
            if str(e.widget) not in str(self.audio_popup):
                self.audio_popup.destroy()

        # self.audio_popup.bind("<FocusOut>", lambda e: self.audio_popup.destroy())
        self.audio_popup.focus_set()

    def show_wifi_menu(self):
        if not self.wifi_manager.iface:
            from tkinter import messagebox
            messagebox.showerror("Wi-Fi", "Aucune interface Wi-Fi détectée sur ce système.")
            return

        pwd = self.settings.get("sudo_pwd")
        if not pwd:
            from tkinter import messagebox
            messagebox.showerror("Wi-Fi", "Veuillez configurer votre mot de passe système (Sudo) dans l'application Paramètres.")
            return

        if hasattr(self, 'wifi_popup') and self.wifi_popup.winfo_exists():
            self.wifi_popup.destroy()
            return

        self.wifi_popup = tk.Toplevel(self)
        self.wifi_popup.title("Réseaux Wi-Fi")
        self.wifi_popup.geometry("300x450")

        self.wifi_popup.update_idletasks()
        x = self.winfo_width() - 310
        y = self.winfo_height() - 500
        self.wifi_popup.geometry(f"+{x}+{y}")
        self.wifi_popup.transient(self.winfo_toplevel())

        ssid_actuel, ip_actuelle = self.wifi_manager.get_current_status(pwd)
        if ssid_actuel:
            info_frame = tk.Frame(self.wifi_popup, bg="#e0f7fa", bd=1, relief="solid")
            info_frame.pack(fill="x", padx=5, pady=5)
            tk.Label(info_frame, text=f"Connecté : {ssid_actuel}", font=("Arial", 10, "bold"), bg="#e0f7fa", fg="#006064").pack()
            if ip_actuelle:
                tk.Label(info_frame, text=f"IP : {ip_actuelle}", font=("Arial", 9), bg="#e0f7fa", fg="#00838f").pack()

        lbl = tk.Label(self.wifi_popup, text="Recherche de réseaux en cours...", font=("Arial", 10, "italic"))
        lbl.pack(pady=10)

        listbox = tk.Listbox(self.wifi_popup, font=("Arial", 10))
        listbox.pack(fill="both", expand=True, padx=5, pady=5)

        btn_connect = tk.Button(self.wifi_popup, text="Se connecter", state="disabled")
        btn_connect.pack(fill="x", padx=5, pady=5)

        networks_data = []

        def on_select(event):
            if listbox.curselection():
                btn_connect.config(state="normal")

        listbox.bind('<<ListboxSelect>>', on_select)

        def do_connect():
            sel = listbox.curselection()
            if not sel: return
            ssid = networks_data[sel[0]]['ssid']
            psk = simpledialog.askstring("Wi-Fi", f"Clé de sécurité pour '{ssid}' (vide si réseau ouvert) :")
            if psk is not None:
                lbl.config(text="Connexion en cours...")
                def connect_thread():
                    success, msg = self.wifi_manager.connect_network(pwd, ssid, psk)
                    def callback():
                        from tkinter import messagebox
                        if success:
                            messagebox.showinfo("Wi-Fi", f"Connecté à {ssid}.")
                            self.wifi_popup.destroy()
                        else:
                            messagebox.showerror("Erreur", msg)
                            lbl.config(text="Erreur.")
                    self.after(0, callback)
                import threading
                threading.Thread(target=connect_thread, daemon=True).start()

        btn_connect.config(command=do_connect)

        def scan_thread():
            nets = self.wifi_manager.scan_networks(pwd)
            def callback():
                if not self.wifi_popup.winfo_exists(): return
                lbl.config(text=f"{len(nets)} réseaux détectés.")
                listbox.delete(0, tk.END)
                networks_data.clear()
                for net in nets:
                    networks_data.append(net)
                    listbox.insert(tk.END, f"{net['ssid']} ({net['signal']} dBm)")
            self.after(0, callback)

        import threading
        threading.Thread(target=scan_thread, daemon=True).start()

    def toggle_keyboard(self):
        if self.kb_layout == "fr":
            self.kb_layout = "us"
            self.kb_btn.config(text="US")
        else:
            self.kb_layout = "fr"
            self.kb_btn.config(text="FR")
        self.apply_keyboard_layout()

    def apply_keyboard_layout(self):
        try:
            subprocess.run(["setxkbmap", self.kb_layout])
        except Exception as e:
            print(f"Erreur clavier: {e}")

    def build_start_menu(self):
        for widget in self.start_menu.winfo_children():
            widget.destroy()

        tk.Label(self.start_menu, text="GrimOS", bg="lightgray", font=("Arial", 10, "bold")).pack(fill="x", pady=2)

        self.start_menu_container = tk.Frame(self.start_menu, bg="white")
        self.start_menu_container.pack(fill="both", expand=True)

        self.start_menu_canvas = tk.Canvas(self.start_menu_container, bg="white", highlightthickness=0)
        self.start_menu_scrollbar = tk.Scrollbar(self.start_menu_container, orient="vertical", command=self.start_menu_canvas.yview)

        self.start_menu_content = tk.Frame(self.start_menu_canvas, bg="white")

        self.start_menu_content.bind(
            "<Configure>",
            lambda e: self.start_menu_canvas.configure(scrollregion=self.start_menu_canvas.bbox("all"))
        )

        self.start_menu_canvas.create_window((0, 0), window=self.start_menu_content, anchor="nw", width=230)
        self.start_menu_canvas.configure(yscrollcommand=self.start_menu_scrollbar.set)

        def _on_mousewheel(event):
            if self.start_menu_visible:
                self.start_menu_canvas.yview_scroll(int(-1*(event.delta/120)), "units")
        self.start_menu.bind_all("<MouseWheel>", _on_mousewheel)
        self.start_menu.bind_all("<Button-4>", lambda e: self.start_menu_canvas.yview_scroll(-1, "units") if self.start_menu_visible else None)
        self.start_menu.bind_all("<Button-5>", lambda e: self.start_menu_canvas.yview_scroll(1, "units") if self.start_menu_visible else None)

        categories = {}
        for app in self.apps:
            cat = app.get("category", "Autres")
            if cat not in categories:
                categories[cat] = []
            categories[cat].append(app)

        for cat in sorted(categories.keys()):
            tk.Label(self.start_menu_content, text=f"── {cat} ──", bg="white", fg="gray", font=("Arial", 9, "italic")).pack(fill="x", pady=(5,0))
            for app in categories[cat]:
                icon_img = self.app_icons.get(app.get("module"))
                btn = tk.Button(self.start_menu_content, text=" " + app.get("name"), image=icon_img, compound="left", relief="flat", anchor="w", 
                                command=lambda a=app: self.launch_from_menu(a))
                btn.pack(fill="x", padx=10, pady=1)

        tk.Frame(self.start_menu_content, height=2, bg="black").pack(fill="x", pady=2)

        def generate_grimoire():
            self.hide_start_menu()
            import os
            from tkinter import messagebox
            grimoire_path = os.path.expanduser("~/Grimoire_GrimOS.md")

            content = "# Le Grimoire GrimOS\n\n"
            content += "Bienvenue dans GrimOS (Graphical Runtime Integrated Minimal Operating System).\n"
            content += "Ce document est généré automatiquement et compile la documentation du système.\n\n"

            content += "## 1. Créer et intégrer une application\n"
            content += "Pour créer une application compatible GrimOS, créez un fichier `.py` contenant :\n"
            content += "```python\nimport tkinter as tk\n"
            content += "def start(window, app_manager=None, **kwargs):\n"
            content += "    # 'window' est un tk.Frame pré-créé par GrimOS.\n"
            content += "    tk.Label(window, text=\"Mon Appli\").pack()\n```\n\n"
            content += "**Note de compatibilité :** Ne créez JAMAIS de `tk.Tk()` dans la fonction `start()`, cela casserait l'affichage sans gestionnaire de fenêtres.\n\n"
            content += "Ensuite, pour ajouter l'application au Menu Démarrer, éditez le fichier `config/applications.json` dans le dossier de GrimOS :\n"
            content += "```json\n{\n  \"name\": \"Mon App\",\n  \"module\": \"chemin.vers.le.module\",\n  \"category\": \"Autres\"\n}\n```\n"
            content += "Relancez la session, et votre application apparaîtra !\n\n"

            content += "## 2. Aides des Applications Intégrées\n"
            content += "### Éditeur de Code\n"
            content += "Conçu pour le développement natif. Utilisez le bouton 'Nouveau GUI' pour générer un code complet (boilerplate) pour démarrer une application GrimOS, testable même en standalone via son propre bloc `__main__`.\n\n"

            content += "### Explorateur\n"
            content += "Double-cliquez pour naviguer. Clic-droit pour renommer ou supprimer. La case 'Fichiers cachés' permet d'afficher les éléments systèmes. Le bouton `>_ Terminal` permet d'ouvrir une invite de commande dans le dossier visité.\n\n"

            content += "### Terminal\n"
            content += "Environnement émulé. Commandes courantes supportées avec historique. La commande `sudo` est volontairement bloquée par sécurité (utilisez l'application Xterm pour l'administration système pure).\n"

            try:
                with open(grimoire_path, "w", encoding="utf-8") as f:
                    f.write(content)
                self.app_manager.launch_app({"name": "Éditeur de Code", "module": "apps.editeur.app"}, filepath=grimoire_path)
            except Exception as e:
                messagebox.showerror("Erreur", f"Impossible de générer le Grimoire : {e}")

        btn_help = tk.Button(self.start_menu_content, text=" Générer le Grimoire", image=self.icons.get('menu_grimoire'), compound="left", relief="flat", anchor="w", fg="blue", command=generate_grimoire)
        btn_help.pack(fill="x", padx=5, pady=2)

        tk.Frame(self.start_menu_content, height=1, bg="lightgray").pack(fill="x", pady=2)

        btn_logoff = tk.Button(self.start_menu_content, text=" Fermer la session", image=self.icons.get('menu_logoff'), compound="left", relief="flat", anchor="w", fg="black", command=self.shutdown_grimos)
        btn_logoff.pack(fill="x", padx=5, pady=2)

        btn_reboot = tk.Button(self.start_menu_content, text=" Redémarrer", image=self.icons.get('menu_reboot'), compound="left", relief="flat", anchor="w", fg="orange", command=self.reboot_system)
        btn_reboot.pack(fill="x", padx=5, pady=2)

        btn_poweroff = tk.Button(self.start_menu_content, text=" Arrêter", image=self.icons.get('menu_poweroff'), compound="left", relief="flat", anchor="w", fg="red", command=self.poweroff_system)
        btn_poweroff.pack(fill="x", padx=5, pady=2)

    def toggle_start_menu(self):
        if self.start_menu_visible:
            self.hide_start_menu()
        else:
            self.start_menu_content.update_idletasks()
            req_h = self.start_menu_content.winfo_reqheight() + 30 # Label de titre et bordures
            max_h = self.desktop_frame.winfo_height() - 20

            if req_h > max_h:
                self.start_menu_scrollbar.pack(side="right", fill="y")
                self.start_menu_canvas.pack(side="left", fill="both", expand=True)
                final_h = max_h
            else:
                self.start_menu_scrollbar.pack_forget()
                self.start_menu_canvas.pack(side="left", fill="both", expand=True)
                final_h = req_h

            self.start_menu.place(x=5, y=self.desktop_frame.winfo_height() - final_h - 5, height=final_h, width=250)
            self.start_menu.lift()
            self.start_menu_visible = True

    def hide_start_menu(self, event=None):
        if self.start_menu_visible:
            self.start_menu.place_forget()
            self.start_menu_visible = False

    def launch_from_menu(self, app):
        self.hide_start_menu()
        self.app_manager.launch_app(app)

    def register_window(self, win):
        if win not in self.taskbar_btns:
            icon_img = self.app_icons.get(win.app_config.get("module"))
            tooltip_text = win.filepath if hasattr(win, "filepath") and win.filepath else win.title
            btn = tk.Button(self.taskbar_windows, image=icon_img, bg="#444", fg="white", 
                            command=lambda: self.toggle_window(win))
            btn.pack(side="left", padx=2, pady=5)
            self.taskbar_btns[win] = btn
            ToolTip(btn, tooltip_text)

    def update_taskbar_btn(self, win):
        if win in self.taskbar_btns:
            if not win.winfo_ismapped():
                # Fenêtre minimisée -> fond plus sombre ou différent pour l'indiquer
                self.taskbar_btns[win].config(bg="#888", relief="sunken")
            else:
                self.taskbar_btns[win].config(bg="#444", relief="raised")

    def toggle_window(self, win):
        if win.winfo_ismapped():
            win.minimize()
        else:
            win.restore()

    def remove_window(self, win):
        if win in self.taskbar_btns:
            self.taskbar_btns[win].destroy()
            del self.taskbar_btns[win]
        if win in self.app_manager.active_windows:
            self.app_manager.active_windows.remove(win)

    def save_and_quit(self):
        self.app_manager.save_session()
        self.parent.destroy()

    def shutdown_grimos(self):
        self.save_and_quit()

    def reboot_system(self):
        from tkinter import messagebox
        pwd = self.settings.get("sudo_pwd")
        if not pwd:
            messagebox.showerror("Erreur", "Veuillez configurer votre mot de passe système (Sudo) dans les Paramètres.")
            return
        if pwd is not None:
            self.app_manager.save_session()
            try:
                res = subprocess.run(['sudo', '-S', 'systemctl', 'reboot'], input=pwd + '\n', text=True, capture_output=True)
                if res.returncode != 0:
                    messagebox.showerror("Erreur", f"Échec de la commande:\n{res.stderr}")
            except Exception as e:
                print(f"Erreur reboot: {e}")

    def poweroff_system(self):
        from tkinter import messagebox
        pwd = self.settings.get("sudo_pwd")
        if not pwd:
            messagebox.showerror("Erreur", "Veuillez configurer votre mot de passe système (Sudo) dans les Paramètres.")
            return
        if pwd is not None:
            self.app_manager.save_session()
            try:
                res = subprocess.run(['sudo', '-S', 'systemctl', 'poweroff'], input=pwd + '\n', text=True, capture_output=True)
                if res.returncode != 0:
                    messagebox.showerror("Erreur", f"Échec de la commande:\n{res.stderr}")
            except Exception as e:
                print(f"Erreur poweroff: {e}")

    def show_toast(self, message):
        toast = tk.Label(self.desktop_frame, text=message, bg="#333", fg="white", font=("Arial", 10, "bold"), padx=15, pady=10, relief="solid", bd=1)

        y_pos = -10
        for t in self.toasts:
            self.desktop_frame.update_idletasks()
            y_pos -= t.winfo_reqheight() + 5

        toast.place(relx=1.0, rely=1.0, x=-10, y=y_pos, anchor="se")
        self.toasts.append(toast)

        def remove_toast():
            if toast in self.toasts:
                self.toasts.remove(toast)
            toast.destroy()

            current_y = -10
            for t in self.toasts:
                self.desktop_frame.update_idletasks()
                t.place(relx=1.0, rely=1.0, x=-10, y=current_y, anchor="se")
                current_y -= t.winfo_reqheight() + 5

        self.after(4000, remove_toast)

    def load_desktop_icons(self):
        import json
        desktop_config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config", "desktop.json")
        icon_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "icons")
        icon_theme = self.theme_data.get("icon_theme", "grimos")
        icon_theme_dir = os.path.join(icon_dir, "themes", icon_theme)

        if not os.path.exists(desktop_config_path):
            try:
                with open(desktop_config_path, "w") as f:
                    json.dump([], f)
            except:
                pass

        for w in self.desktop_frame.winfo_children():
            if getattr(w, "_is_desktop_icon", False):
                w.destroy()

        try:
            with open(desktop_config_path, "r") as f:
                icons = json.load(f)

            for icon_data in icons:
                name = icon_data.get("name", "Fichier")
                path = icon_data.get("path", "")
                x = icon_data.get("x", 50)
                y = icon_data.get("y", 50)

                img_name = "blocnotes.png"
                if os.path.isdir(path):
                    img_name = "explorateur.png"
                elif path.endswith(".py"):
                    img_name = "terminal.png"

                img_path = os.path.join(icon_theme_dir, img_name)
                if not os.path.exists(img_path):
                    img_path = os.path.join(icon_dir, img_name)

                try:
                    photo = tk.PhotoImage(file=img_path)
                except:
                    photo = None

                # Truncate long names
                disp_name = name if len(name) < 15 else name[:12]+"..."

                # Use theme colors for icon label
                fg_color = self.theme_data.get("desktop_fg", "white")

                btn = tk.Button(self.desktop_frame, text=disp_name, image=photo, compound="top", relief="flat", bg=self.desktop_frame.cget("bg"), fg=fg_color, justify="center", width=80, command=lambda p=path: self.launch_file(p))
                btn.image = photo # Keep reference
                btn._is_desktop_icon = True
                btn.place(x=x, y=y)

                def make_draggable_and_menu(widget, data, icon_list):
                    def start_drag(event):
                        widget._drag_start_x = event.x
                        widget._drag_start_y = event.y
                    def do_drag(event):
                        nx = widget.winfo_x() - widget._drag_start_x + event.x
                        ny = widget.winfo_y() - widget._drag_start_y + event.y
                        widget.place(x=nx, y=ny)
                    def stop_drag(event):
                        # Don't register drag if the widget was just clicked
                        if getattr(widget, "_is_menu_open", False): return
                        data["x"] = widget.winfo_x()
                        data["y"] = widget.winfo_y()
                        self.save_desktop_icons(icon_list)

                    def show_menu(event):
                        menu = tk.Menu(self.desktop_frame, tearoff=0)

                        def delete_shortcut():
                            if data in icon_list:
                                icon_list.remove(data)
                                self.save_desktop_icons(icon_list)
                                widget.destroy()
                                self.show_toast(f"Raccourci supprimé")

                        menu.add_command(label=" Supprimer le raccourci", image=self.icons.get('btn_trash'), compound="left", command=delete_shortcut)

                        widget._is_menu_open = True
                        try:
                            menu.tk_popup(event.x_root, event.y_root)
                        finally:
                            menu.grab_release()
                            widget._is_menu_open = False

                    widget.bind("<Button-1>", start_drag)
                    widget.bind("<B1-Motion>", do_drag)
                    widget.bind("<ButtonRelease-1>", stop_drag)
                    widget.bind("<Button-3>", show_menu)

                make_draggable_and_menu(btn, icon_data, icons)
        except Exception as e:
            print("Erreur desktop icons:", e)

    def save_desktop_icons(self, icons):
        import json
        desktop_config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config", "desktop.json")
        try:
            with open(desktop_config_path, "w") as f:
                json.dump(icons, f, indent=4)
        except:
            pass

    def launch_file(self, path):
        if not os.path.exists(path):
            self.show_toast(f"Fichier introuvable:\n{path}")
            return

        if os.path.isdir(path):
            self.app_manager.launch_app({"name": "Explorateur", "module": "apps.explorateur.app"}, filepath=path)
        elif path.endswith(".py"):
            self.app_manager.launch_app({"name": "Éditeur de Code", "module": "apps.editeur.app"}, filepath=path)
        elif path.endswith((".png", ".jpg", ".gif")):
            self.app_manager.launch_app({"name": "Visionneuse", "module": "apps.visionneuse.app"}, filepath=path)
        else:
            self.app_manager.launch_app({"name": "Bloc-notes", "module": "apps.blocnotes.app"}, filepath=path)