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>
This commit is contained in:
snoopy
2026-09-04 11:45:03 +02:00
parent 6de0aad79c
commit b322b95aa4
3 changed files with 2641 additions and 865 deletions

View File

@@ -15,7 +15,12 @@ Client SSH/SFTP personnel (type MobaXterm/Termius/Bitvise), 100% local, sans com
avec des serveurs de test qui redemarrent souvent avec des serveurs de test qui redemarrent souvent
- Les hotes deja approuves par votre commande `ssh` (`~/.ssh/known_hosts`) sont reconnus : pas de - Les hotes deja approuves par votre commande `ssh` (`~/.ssh/known_hosts`) sont reconnus : pas de
double confirmation d'empreinte. Les nouvelles cles vont dans le fichier propre a l'application double confirmation d'empreinte. Les nouvelles cles vont dans le fichier propre a l'application
- Terminaux locaux Windows (PowerShell / Invite de commandes) accessibles en un clic, **sans avoir besoin d'une session enregistree** — pratique pour un usage ponctuel, comme le terminal Windows natif - Terminaux locaux accessibles en un clic, **sans session enregistree**. L'application detecte les
shells presents sur le poste et les propose tous : PowerShell, cmd, et surtout les **shells Unix**
s'ils sont installes (Bash de Git for Windows, MSYS2, Cygwin, WSL, ou un busybox pose a cote de
l'application). Comme le terminal local de MobaXterm, on retrouve alors `ls`, `grep`, `sed`, `awk`,
`vi`, `scp`... sous Windows. **Un shell Unix est choisi par defaut** quand il y en a un ;
sinon PowerShell. Rien n'est embarque : l'application reutilise ce qui est deja installe
- Carnet d'adresses de serveurs (JSON local, `data/sessions.json`) organise par dossiers, connexion en un clic - Carnet d'adresses de serveurs (JSON local, `data/sessions.json`) organise par dossiers, connexion en un clic
- Gestion des cles SSH : generation (Ed25519 / RSA), import de cles existantes, export (cle publique/privee) - Gestion des cles SSH : generation (Ed25519 / RSA), import de cles existantes, export (cle publique/privee)
- Client SFTP integre facon WinSCP : double panneau local/distant avec colonnes triables (nom, taille, date, droits), - Client SFTP integre facon WinSCP : double panneau local/distant avec colonnes triables (nom, taille, date, droits),

View File

@@ -5,25 +5,110 @@ fonctionnel en un clic, comme le fait MobaXterm avec ses "terminaux locaux".
""" """
from __future__ import annotations from __future__ import annotations
import os
import queue import queue
import shutil
import subprocess
import threading import threading
from pathlib import Path
import winpty import winpty
from PySide6.QtCore import QThread, Signal from PySide6.QtCore import QThread, Signal
SHELL_POWERSHELL = "powershell" SHELL_POWERSHELL = "powershell"
SHELL_CMD = "cmd" SHELL_CMD = "cmd"
SHELL_GITBASH = "gitbash"
SHELL_MSYS2 = "msys2"
SHELL_CYGWIN = "cygwin"
SHELL_WSL = "wsl"
SHELL_BUSYBOX = "busybox"
SHELL_LABELS = { # Shells Unix cherches sur le poste. Comme MobaXterm, on veut pouvoir travailler avec
SHELL_POWERSHELL: "PowerShell", # ls/grep/sed/awk/vi sous Windows ; mais plutot que d'embarquer un binaire tiers, on
SHELL_CMD: "Invite de commandes (cmd)", # 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.
SHELL_COMMANDS = { # MSYSTEM : sans elle, le bash de MSYS2 se lance mais n'affiche jamais d'invite.
SHELL_POWERSHELL: "powershell.exe -NoLogo", _UNIX_CANDIDATES = [
SHELL_CMD: "cmd.exe", (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): class LocalTerminalConnection(QThread):
"""Meme interface de signaux que SSHConnection, pour reutiliser TerminalWidget tel quel.""" """Meme interface de signaux que SSHConnection, pour reutiliser TerminalWidget tel quel."""
@@ -83,7 +168,7 @@ class LocalTerminalConnection(QThread):
finally: finally:
self._cleanup() self._cleanup()
def _spawn(self, command: str) -> winpty.PtyProcess: def _spawn(self, command: list[str]) -> winpty.PtyProcess:
"""Ouvre la pseudo-console, en privilegiant ConPTY. """Ouvre la pseudo-console, en privilegiant ConPTY.
ConPTY est l'API pseudo-console native de Windows (10 1809+) : c'est un vrai ConPTY est l'API pseudo-console native de Windows (10 1809+) : c'est un vrai
@@ -93,11 +178,14 @@ class LocalTerminalConnection(QThread):
artefacts de curseur qui saute et de saisie reecrite. On ne retombe dessus que si artefacts de curseur qui saute et de saisie reecrite. On ne retombe dessus que si
ConPTY est indisponible (Windows trop ancien). ConPTY est indisponible (Windows trop ancien).
""" """
extra = SHELL_ENV.get(self.shell) or {}
env = {**os.environ, **extra} if extra else None
try: try:
return winpty.PtyProcess.spawn( return winpty.PtyProcess.spawn(
command, command,
dimensions=(self.rows, self.cols), dimensions=(self.rows, self.cols),
cwd=self.cwd, cwd=self.cwd,
env=env,
backend=winpty.Backend.ConPTY, backend=winpty.Backend.ConPTY,
) )
except Exception: except Exception:
@@ -105,6 +193,7 @@ class LocalTerminalConnection(QThread):
command, command,
dimensions=(self.rows, self.cols), dimensions=(self.rows, self.cols),
cwd=self.cwd, cwd=self.cwd,
env=env,
backend=winpty.Backend.WinPTY, backend=winpty.Backend.WinPTY,
) )

File diff suppressed because it is too large Load Diff