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>
241 lines
9.0 KiB
Python
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
|