"""Terminal local (PowerShell / Invite de commandes) via pseudo-console Windows (pywinpty). Independant de toute session enregistree : permet d'ouvrir un terminal Windows fonctionnel en un clic, comme le fait MobaXterm avec ses "terminaux locaux". """ from __future__ import annotations import os import queue import shutil import subprocess import threading from pathlib import Path import winpty from PySide6.QtCore import QThread, Signal SHELL_POWERSHELL = "powershell" SHELL_CMD = "cmd" SHELL_GITBASH = "gitbash" SHELL_MSYS2 = "msys2" SHELL_CYGWIN = "cygwin" SHELL_WSL = "wsl" SHELL_BUSYBOX = "busybox" # Shells Unix cherches sur le poste. Comme MobaXterm, on veut pouvoir travailler avec # ls/grep/sed/awk/vi sous Windows ; mais plutot que d'embarquer un binaire tiers, on # reutilise ce qui est deja installe (Git for Windows en fournit un tres complet). # CHERE_INVOKING : demarrer dans le dossier courant plutot que dans le home. # MSYSTEM : sans elle, le bash de MSYS2 se lance mais n'affiche jamais d'invite. _UNIX_CANDIDATES = [ (SHELL_GITBASH, "Bash (Git for Windows)", [ r"C:\Program Files\Git\bin\bash.exe", r"C:\Program Files (x86)\Git\bin\bash.exe", ], ["--login", "-i"], {"CHERE_INVOKING": "1"}), (SHELL_MSYS2, "Bash (MSYS2)", [r"C:\msys64\usr\bin\bash.exe"], ["--login", "-i"], {"CHERE_INVOKING": "1", "MSYSTEM": "MSYS"}), (SHELL_CYGWIN, "Bash (Cygwin)", [r"C:\cygwin64\bin\bash.exe"], ["--login", "-i"], {"CHERE_INVOKING": "1"}), ] # Les commandes sont des listes d'arguments : pywinpty ne sait pas decouper une chaine # contenant des guillemets, et les chemins Windows en contiennent ("Program Files"). _BASE_SHELLS = { SHELL_POWERSHELL: ("PowerShell", ["powershell.exe", "-NoLogo"], {}), SHELL_CMD: ("Invite de commandes (cmd)", ["cmd.exe"], {}), } def _busybox_path() -> str | None: """busybox-w32 pose a cote de l'application (pour les postes sans Git installe).""" from app.paths import DATA_DIR for candidate in ( DATA_DIR.parent / "busybox.exe", DATA_DIR.parent / "busybox64.exe", shutil.which("busybox") or "", ): if candidate and Path(candidate).exists(): return str(candidate) return None def _wsl_available() -> bool: if not shutil.which("wsl.exe"): return False try: out = subprocess.run( ["wsl.exe", "-l", "-q"], capture_output=True, timeout=3, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) # la sortie de wsl est en UTF-16 return bool(out.stdout.decode("utf-16-le", errors="ignore").strip()) except Exception: return False def discover_shells() -> dict[str, tuple[str, list[str], dict[str, str]]]: """Renvoie les shells disponibles : {identifiant: (libelle, arguments, env)}.""" shells = dict(_BASE_SHELLS) for shell_id, label, paths, args, env in _UNIX_CANDIDATES: for path in paths: if Path(path).exists(): shells[shell_id] = (label, [path] + args, env) break busybox = _busybox_path() if busybox: shells[SHELL_BUSYBOX] = ("Shell BusyBox", [busybox, "sh"], {}) if _wsl_available(): shells[SHELL_WSL] = ("WSL (Linux)", ["wsl.exe"], {}) return shells SHELLS = discover_shells() SHELL_LABELS = {k: v[0] for k, v in SHELLS.items()} SHELL_COMMANDS = {k: v[1] for k, v in SHELLS.items()} SHELL_ENV = {k: v[2] for k, v in SHELLS.items()} def default_shell() -> str: """Shell ouvert par defaut : un shell Unix s'il y en a un sur le poste (on y gagne ls/grep/sed/awk/ssh/scp, comme le terminal local de MobaXterm), sinon PowerShell.""" for candidate in (SHELL_GITBASH, SHELL_MSYS2, SHELL_CYGWIN, SHELL_BUSYBOX): if candidate in SHELLS: return candidate return SHELL_POWERSHELL DEFAULT_SHELL = default_shell() class LocalTerminalConnection(QThread): """Meme interface de signaux que SSHConnection, pour reutiliser TerminalWidget tel quel.""" connected = Signal() data_received = Signal(bytes) disconnected = Signal(str) error = Signal(str) def __init__(self, shell: str = SHELL_POWERSHELL, cwd: str | None = None, cols: int = 80, rows: int = 24, parent=None): super().__init__(parent) self.shell = shell self.cwd = cwd self.cols = cols self.rows = rows self.pty: winpty.PtyProcess | None = None self._stop = threading.Event() # File d'envoi + thread dedie : send() est appele directement depuis le thread GUI # (une frappe clavier). pty.write() peut bloquer si la console Windows n'est pas # assez rapide a consommer son pipe d'entree ; sans decouplage, ce blocage gele # toute l'interface le temps de l'ecriture (pty.read() n'a pas de timeout pour # entrelacer l'ecriture dans la meme boucle, contrairement au canal SSH). self._out_queue: queue.Queue[bytes] = queue.Queue() self._writer_thread: threading.Thread | None = None def run(self) -> None: try: command = SHELL_COMMANDS.get(self.shell, SHELL_COMMANDS[SHELL_POWERSHELL]) self.pty = self._spawn(command) # La taille reelle du widget est connue apres la mise en page, souvent *apres* # la creation de la connexion : on reapplique ici la derniere taille demandee, # sinon la console reste en 80x24 alors que l'affichage est bien plus grand # (desynchronisation = retours a la ligne et redraws incoherents). try: self.pty.setwinsize(self.rows, self.cols) except Exception: pass self._writer_thread = threading.Thread(target=self._writer_loop, daemon=True) self._writer_thread.start() self.connected.emit() while not self._stop.is_set(): try: data = self.pty.read(4096) except EOFError: break if not data: break self.data_received.emit(data.encode("utf-8", errors="replace")) self.disconnected.emit("Terminal ferme") except Exception as exc: # noqa: BLE001 self.error.emit(str(exc)) finally: self._cleanup() def _spawn(self, command: list[str]) -> winpty.PtyProcess: """Ouvre la pseudo-console, en privilegiant ConPTY. ConPTY est l'API pseudo-console native de Windows (10 1809+) : c'est un vrai passe-plat. L'ancien backend WinPTY, lui, *scrute* le tampon d'ecran de la console et resynthetise l'affichage : mesure faite, il perd des caracteres a la frappe (16 echos sur 23) et affiche ~32 ms de latence contre ~2,5 ms pour ConPTY, avec des artefacts de curseur qui saute et de saisie reecrite. On ne retombe dessus que si ConPTY est indisponible (Windows trop ancien). """ extra = SHELL_ENV.get(self.shell) or {} env = {**os.environ, **extra} if extra else None try: return winpty.PtyProcess.spawn( command, dimensions=(self.rows, self.cols), cwd=self.cwd, env=env, backend=winpty.Backend.ConPTY, ) except Exception: return winpty.PtyProcess.spawn( command, dimensions=(self.rows, self.cols), cwd=self.cwd, env=env, backend=winpty.Backend.WinPTY, ) def send(self, data: bytes) -> None: """Appele depuis le thread GUI (frappe clavier) : ne doit jamais bloquer.""" self._out_queue.put(data) def _writer_loop(self) -> None: """Tourne dans son propre thread : effectue les ecritures potentiellement bloquantes. Une erreur d'ecriture ponctuelle ne doit pas tuer le thread : sinon, toute frappe suivante serait silencieusement perdue pour le reste de la session (le process local restant lui-meme actif). On n'arrete la boucle que si le process est mort.""" while not self._stop.is_set(): try: data = self._out_queue.get(timeout=0.2) except queue.Empty: continue if not (self.pty and self.pty.isalive()): continue try: self.pty.write(data.decode("utf-8", errors="ignore")) except Exception: if not (self.pty and self.pty.isalive()): break def resize(self, cols: int, rows: int) -> None: self.cols, self.rows = cols, rows if self.pty and self.pty.isalive(): try: self.pty.setwinsize(rows, cols) except Exception: pass def close_connection(self) -> None: self._stop.set() self._cleanup() def _cleanup(self) -> None: try: if self.pty and self.pty.isalive(): self.pty.terminate(force=True) except Exception: pass