Files
pyssh-client/app/local_terminal.py
snoopy b322b95aa4 Terminaux locaux : detecter et proposer les shells Unix du poste
L'emulateur de terminal etait deja le notre (pyte + QPainter) ; ce qui manquait
pour se rapprocher de MobaXterm, c'etait le *shell*. MobaXterm ne fournit pas
"son propre terminal" mais son propre bash, issu d'un Cygwin embarque : c'est ce
qui donne ls/grep/sed/awk/vi sous Windows.

Plutot que d'embarquer un binaire tiers, l'application detecte maintenant les
shells deja installes et les propose tous : PowerShell, cmd, Bash de Git for
Windows, MSYS2, Cygwin, WSL, et un busybox-w32 pose a cote de l'executable pour
les postes qui n'ont rien. Un shell Unix est choisi par defaut quand il y en a un.

Deux details qui bloquaient :
- pywinpty ne sait pas decouper une commande passee en chaine avec des
  guillemets, et les chemins Windows en contiennent ("Program Files") : les
  commandes sont donc des listes d'arguments.
- le bash de MSYS2 demarre sans jamais afficher d'invite si MSYSTEM n'est pas
  definie ; chaque shell peut donc declarer ses variables d'environnement
  (CHERE_INVOKING pour demarrer dans le dossier courant).

Les menus et l'arborescence sont generes depuis la detection, ils s'adaptent
donc au poste sans configuration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:45:03 +02:00

241 lines
9.0 KiB
Python

"""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