#!/usr/bin/env python3 """ Dashboard Launcher - Application GTK3 + WebKit2 Affiche une interface web pour lancer des connexions SSH et autres services. """ import os import sys import yaml import gi import subprocess import re import threading gi.require_version('Gtk', '3.0') gi.require_version('WebKit2', '4.1') gi.require_version('Gdk', '3.0') from gi.repository import Gtk, WebKit2, Gio, Gdk, GLib import cairo PID_FILE = '/tmp/dashboard-launcher.pid' SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(SCRIPT_DIR) CONFIG_FILE = os.path.join(PROJECT_DIR, 'config', 'equipements.yaml') ICONS_DIR = os.path.join(PROJECT_DIR, 'icons') # Cache pour les résultats de ping ping_results = {} def ping_host(ip, timeout=1): """Ping une IP et retourne True si accessible.""" try: # Nettoyer l'IP (enlever les doubles points, etc.) clean_ip = ip.replace('..', '.') result = subprocess.run( ['ping', '-c', '1', '-W', str(timeout), clean_ip], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout + 1 ) return result.returncode == 0 except Exception: return False def ping_all_hosts(hosts, callback): """Ping toutes les IPs en parallèle et appelle callback avec les résultats.""" def ping_thread(ip): result = ping_host(ip) ping_results[ip] = result threads = [] for ip in hosts: t = threading.Thread(target=ping_thread, args=(ip,)) t.daemon = True t.start() threads.append(t) def wait_and_callback(): for t in threads: t.join(timeout=2) GLib.idle_add(callback) threading.Thread(target=wait_and_callback, daemon=True).start() def load_config(): """Charge la configuration depuis le fichier YAML.""" try: with open(CONFIG_FILE, 'r', encoding='utf-8') as f: return yaml.safe_load(f) or {} except (FileNotFoundError, yaml.YAMLError) as e: print(f"Erreur config: {e}") return {} def get_icon_html(icon_name, size=32): """Retourne le HTML pour une icône.""" if not icon_name: return f'🔗' # Chemin vers image dans icons/ if icon_name.startswith('icons/'): icon_path = os.path.join(PROJECT_DIR, icon_name) if os.path.exists(icon_path): return f'' # Emojis de fallback icons = { 'utilities-terminal': '💻', 'terminal': '💻', 'web-browser': '🌐', 'applications-system': '⚙️', 'server': '🖥️', 'network-server': '🖧', 'preferences-desktop-remote-desktop': '🖥️', 'security-high': '🔒', 'go-home': '🏠', 'system-file-manager': '📁', 'firefox': '🦊', 'google-chrome': '🌐', 'code': '📝', } emoji = icons.get(icon_name, icon_name if len(icon_name) <= 2 else '🔗') return f'{emoji}' def generate_html(config): """Génère le HTML avec layout: local en haut, distant à gauche, url à droite.""" apparence = config.get('apparence', {}) fenetre = config.get('fenetre', {}) theme = apparence.get('theme', 'dark') font_size = apparence.get('police_taille', 14) icon_distant = apparence.get('icon_taille_distant', 48) icon_local = apparence.get('icon_taille_local', 64) icon_url = apparence.get('icon_taille_url', 64) icon_minitools = apparence.get('icon_taille_minitools', 32) show_local_labels = apparence.get('afficher_label_local', True) border_radius = apparence.get('border_radius', 12) espacement_local = apparence.get('espacement_local', 10) espacement_distant = apparence.get('espacement_distant', 6) espacement_url = apparence.get('espacement_url', 8) espacement_minitools = apparence.get('espacement_minitools', 8) icon_fermer = apparence.get('icon_taille_fermer', 22) icon_parametre = apparence.get('icon_taille_parametre', 22) icon_theme_width = apparence.get('icon_taille_theme', 60) icon_theme_height = int(icon_theme_width * 238 / 512) # Ratio de l'image night-day.png border_radius_local = apparence.get('border_radius_local', 12) # Colonnes configurables col_url = fenetre.get('section_url', {}).get('colonne', 2) distant = config.get('distant', []) local = config.get('local', []) urls = config.get('url', []) # Section DISTANT avec status ping distant_html = "" for machine in distant: ip = machine.get('ip', '') nom = machine.get('nom', '') # Vérifier le statut ping (vert si up, rouge si down, gris si inconnu) is_up = ping_results.get(ip) if is_up is None: status_class = "status-unknown" elif is_up: status_class = "status-up" else: status_class = "status-down" services_html = "" for service in machine.get('services', []): icon_html = get_icon_html(service.get('icon', ''), icon_distant) services_html += f''' {icon_html} {service.get('nom', '')} ''' distant_html += f'''
{nom}
{ip}
{services_html}
''' # Section LOCAL local_html = "" for app in local: icon_html = get_icon_html(app.get('icon', ''), icon_local) label_html = f'{app.get("nom", "")}' if show_local_labels else '' local_html += f''' {icon_html} {label_html} ''' minitools = config.get('minitools', []) minitools_html = "" for tool in minitools: icon_html = get_icon_html(tool.get('icon', ''), icon_minitools) label_html = f'{tool.get("nom", "")}' if show_local_labels else '' href = '' if tool.get('command'): href = f"app://run/{tool.get('command')}" elif tool.get('url'): href = tool.get('url') if not href: continue minitools_html += f''' {icon_html} {label_html} ''' # Section URL url_html = "" for bookmark in urls: icon_html = get_icon_html(bookmark.get('icon', ''), icon_url) url_html += f''' {icon_html} {bookmark.get('nom', '')} ''' # Couleurs thème Adwaita - box = fond équipement (clair), item = fond service (foncé) if theme == 'light': c = {'bg': '#fafafa', 'container': '#ffffff', 'header': '#ebebeb', 'border': '#d0d0d0', 'text': '#2e2e2e', 'text2': '#5e5e5e', 'box': '#e8e8e8', 'item': '#f5f5f5', 'hover': '#888b8f', 'status': '#2ec27e'} else: c = {'bg': '#1e1e1e', 'container': '#242424', 'header': '#303030', 'border': '#1a1a1a', 'text': '#ffffff', 'text2': '#999999', 'box': '#3d3d3d', 'item': '#2d2d2d', 'hover': '#888b8f', 'status': '#2ec27e'} # Surcharge avec les couleurs personnalisées si définies if apparence.get('couleur_fond'): c['container'] = apparence['couleur_fond'] if apparence.get('couleur_header'): c['header'] = apparence['couleur_header'] else: c['header'] = c['container'] # Par défaut, même couleur que le fond if apparence.get('couleur_item'): c['item'] = apparence['couleur_item'] if apparence.get('couleur_bordure'): c['border'] = apparence['couleur_bordure'] if apparence.get('couleur_hover'): c['hover'] = apparence['couleur_hover'] # Couleurs séparées pour chaque section (local, distant, url) c['box_local'] = apparence.get('couleur_box_local') or c['box'] c['box_distant'] = apparence.get('couleur_box_distant') or c['box'] c['box_url'] = apparence.get('couleur_box_url') or c['box'] c['box_minitools'] = apparence.get('couleur_box_minitools') or c['box_local'] # Image night-day: 512x238 (ratio 2.15:1) - affichée entièrement comme toggle html = f'''
{local_html}
{distant_html}
{url_html}
{minitools_html}
''' return html class DashboardLauncherWindow(Gtk.Window): def __init__(self): super().__init__(title="Dashboard Launcher") self.config = load_config() self.fenetre_config = self.config.get('fenetre', {}) # Activer la transparence pour éviter le flash blanc screen = self.get_screen() visual = screen.get_rgba_visual() if visual: self.set_visual(visual) self.set_app_paintable(True) self.connect('draw', self.on_draw) self.set_type_hint(Gdk.WindowTypeHint.POPUP_MENU) self.set_skip_taskbar_hint(True) self.set_skip_pager_hint(True) self.set_decorated(False) # Pas de décoration pour éviter le flash self.set_resizable(False) if self.fenetre_config.get('toujours_au_dessus', False): self.set_keep_above(True) largeur = self.fenetre_config.get('largeur', 800) hauteur = self.fenetre_config.get('hauteur', 450) self.set_default_size(largeur, hauteur) display = Gdk.Display.get_default() ecran_num = self.fenetre_config.get('ecran', -1) if ecran_num is None or ecran_num < 0: seat = display.get_default_seat() pointer = seat.get_pointer() _, mouse_x, mouse_y = pointer.get_position() monitor = display.get_monitor_at_point(mouse_x, mouse_y) else: n_monitors = display.get_n_monitors() monitor = display.get_monitor(ecran_num) if ecran_num < n_monitors else display.get_primary_monitor() or display.get_monitor(0) geom = monitor.get_geometry() centrer = self.fenetre_config.get('centrer', True) x = self.fenetre_config.get('x', 0) y = self.fenetre_config.get('y', 50) if centrer: self.move(geom.x + (geom.width - largeur) // 2, geom.y + y) else: self.move(geom.x + x, geom.y + y) self.connect('key-press-event', self.on_key_press) settings = WebKit2.Settings() settings.set_property('hardware-acceleration-policy', WebKit2.HardwareAccelerationPolicy.NEVER) self.webview = WebKit2.WebView() self.webview.set_settings(settings) self.webview.connect('decide-policy', self.on_decide_policy) # Fond transparent pour éviter le flash blanc bg_color = Gdk.RGBA() bg_color.red = 0 bg_color.green = 0 bg_color.blue = 0 bg_color.alpha = 0 # Transparent self.webview.set_background_color(bg_color) self.reload_content() self.add(self.webview) self.connect('destroy', self.on_destroy) # Lancer le ping en arrière-plan self.ping_timer_id = None self.autohide_timer_id = None self.mouse_inside = True self.start_ping_check() # Configurer le ping périodique ping_interval = self.config.get('apparence', {}).get('ping_intervalle', 360) if ping_interval > 0: self.ping_timer_id = GLib.timeout_add_seconds(ping_interval, self.on_ping_timer) # Fermeture sur perte de focus (clic extérieur) if self.fenetre_config.get('fermer_sur_clic_exterieur', True): self.connect('focus-out-event', self.on_focus_out) # Autohide: fermer si souris hors fenêtre pendant X secondes autohide_delay = self.fenetre_config.get('autohide', 0) if autohide_delay > 0: self.autohide_delay = autohide_delay self.connect('enter-notify-event', self.on_mouse_enter) self.connect('leave-notify-event', self.on_mouse_leave) def on_draw(self, widget, cr): """Dessine un fond transparent.""" cr.set_source_rgba(0, 0, 0, 0) cr.set_operator(cairo.OPERATOR_SOURCE) cr.paint() return False def reload_content(self): self.config = load_config() self.webview.load_html(generate_html(self.config), 'file://') def start_ping_check(self): """Lance le ping de toutes les IPs en arrière-plan.""" distant = self.config.get('distant', []) hosts = [m.get('ip', '') for m in distant if m.get('ip')] ping_all_hosts(hosts, self.on_ping_complete) def on_ping_complete(self): """Callback appelé quand tous les pings sont terminés.""" self.reload_content() def on_ping_timer(self): """Timer pour le ping périodique.""" self.start_ping_check() return True # Continuer le timer def on_destroy(self, widget): """Nettoyage à la fermeture.""" if self.ping_timer_id: GLib.source_remove(self.ping_timer_id) if self.autohide_timer_id: GLib.source_remove(self.autohide_timer_id) Gtk.main_quit() def on_focus_out(self, widget, event): """Ferme la fenêtre quand elle perd le focus.""" self.destroy() return True def on_mouse_enter(self, widget, event): """Souris entre dans la fenêtre - annuler le timer autohide.""" self.mouse_inside = True if self.autohide_timer_id: GLib.source_remove(self.autohide_timer_id) self.autohide_timer_id = None return False def on_mouse_leave(self, widget, event): """Souris quitte la fenêtre - démarrer le timer autohide.""" self.mouse_inside = False if self.autohide_timer_id: GLib.source_remove(self.autohide_timer_id) self.autohide_timer_id = GLib.timeout_add_seconds( self.autohide_delay, self.on_autohide_timeout ) return False def on_autohide_timeout(self): """Timer autohide expiré - fermer si souris toujours hors fenêtre.""" if not self.mouse_inside: self.destroy() return False # Ne pas répéter def toggle_theme(self): current = self.config.get('apparence', {}).get('theme', 'dark') new_theme = 'light' if current == 'dark' else 'dark' try: with open(CONFIG_FILE, 'r', encoding='utf-8') as f: content = f.read() # Regex précise: uniquement "theme:" en début de ligne (après espaces) content = re.sub(r'^(\s*)theme:\s*"?\w+"?', rf'\1theme: "{new_theme}"', content, flags=re.MULTILINE) with open(CONFIG_FILE, 'w', encoding='utf-8') as f: f.write(content) self.reload_content() except Exception as e: print(f"Erreur thème: {e}") def on_key_press(self, widget, event): if event.keyval == Gdk.KEY_Escape: self.destroy() return True return False def on_decide_policy(self, webview, decision, decision_type): if decision_type == WebKit2.PolicyDecisionType.NAVIGATION_ACTION: uri = decision.get_navigation_action().get_request().get_uri() if uri == 'app://close': self.destroy() elif uri == 'app://toggle-theme': self.toggle_theme() elif uri == 'app://settings': subprocess.Popen(['xdg-open', CONFIG_FILE]) elif uri and uri.startswith('app://run/'): subprocess.Popen(uri.replace('app://run/', '').split()) elif uri and (uri.startswith('smb://') or uri.startswith('nfs://')): # SMB/NFS: monter avec gio et ouvrir dans Nautilus self.mount_and_open(uri) elif uri and not uri.startswith('file://') and not uri.startswith('about:'): try: Gio.AppInfo.launch_default_for_uri(uri, None) except Exception as e: print(f"Erreur URI: {e}") else: return False decision.ignore() return True return False def mount_and_open(self, uri): """Monte un partage SMB/NFS avec gio et l'ouvre dans Nautilus.""" def open_nautilus(): subprocess.Popen(['nautilus', uri]) return False # Ne pas répéter def mount_thread(): try: # Monter le partage avec gio mount subprocess.run( ['gio', 'mount', uri], capture_output=True, text=True, timeout=30 ) except subprocess.TimeoutExpired: print(f"Timeout montage: {uri}") except Exception as e: print(f"Erreur montage {uri}: {e}") finally: # Ouvrir dans Nautilus une seule fois GLib.idle_add(open_nautilus) # Lancer le montage en arrière-plan pour ne pas bloquer l'UI threading.Thread(target=mount_thread, daemon=True).start() def is_running(): if os.path.exists(PID_FILE): try: with open(PID_FILE, 'r') as f: pid = int(f.read().strip()) os.kill(pid, 0) return pid except (OSError, ValueError): pass return None def main(): if '--toggle' in sys.argv and is_running(): import signal os.kill(is_running(), signal.SIGTERM) if os.path.exists(PID_FILE): os.remove(PID_FILE) sys.exit(0) if is_running(): print(f"Dashboard déjà en cours") sys.exit(0) os.environ['WEBKIT_DISABLE_COMPOSITING_MODE'] = '1' with open(PID_FILE, 'w') as f: f.write(str(os.getpid())) import atexit atexit.register(lambda: os.path.exists(PID_FILE) and os.remove(PID_FILE)) win = DashboardLauncherWindow() win.show_all() Gtk.main() if __name__ == '__main__': main()