Client SSH/SFTP personnel pour Windows

Reunit en un seul outil les fonctions utiles de MobaXterm, Termius et Bitvise,
sans compte ni service tiers, avec toutes les donnees en local.

- Terminaux multi-onglets : sessions SSH et terminaux Windows locaux
  (PowerShell / cmd) via la pseudo-console native ConPTY
- Gestionnaire de fichiers SFTP facon WinSCP : double panneau, colonnes
  triables, glisser-deposer, renommage, permissions, file de transferts.
  Ouvrable sur une connexion en cours sans creer de favori
- Gestion des cles SSH : generation Ed25519/RSA, import, export
- Gestion du fichier known_hosts, avec remplacement guide d'une cle d'hote
  modifiee (cas frequent des serveurs en DHCP)
- Trois themes (clair, bleu nuit, noir), commutables a chaud
- Authentification automatique reprenant le comportement de la commande ssh
  (agent SSH et cles ~/.ssh), mot de passe demande seulement en secours

Aucun mot de passe n'est stocke sur le disque.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
snoopy
2026-09-04 06:50:10 +02:00
commit b977842186
28 changed files with 3946 additions and 0 deletions

266
app/terminal_widget.py Normal file
View File

@@ -0,0 +1,266 @@
"""Widget terminal : emulation ANSI/VT100 (pyte) rendue avec QPainter.
Optimisation cle : on ne redessine que les lignes reellement modifiees depuis le
dernier rendu (via `screen.dirty`, fourni par pyte), au lieu de repeindre tout
l'ecran a chaque octet recu. Sans ca, chaque frappe clavier (et son echo distant)
declenche un repaint de la totalite de la grille, ce qui devient tres sensible
des que le terminal contient beaucoup de lignes ou que la fenetre est grande.
"""
from __future__ import annotations
import pyte
from PySide6.QtCore import QRect, Qt, Signal
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QKeyEvent, QWheelEvent
from PySide6.QtWidgets import QWidget
from app import theme as app_theme
# Sequences envoyees pour les touches speciales
KEY_SEQUENCES = {
Qt.Key_Up: b"\x1b[A", Qt.Key_Down: b"\x1b[B", Qt.Key_Right: b"\x1b[C", Qt.Key_Left: b"\x1b[D",
Qt.Key_Home: b"\x1b[H", Qt.Key_End: b"\x1b[F", Qt.Key_Insert: b"\x1b[2~", Qt.Key_Delete: b"\x1b[3~",
Qt.Key_PageUp: b"\x1b[5~", Qt.Key_PageDown: b"\x1b[6~",
Qt.Key_F1: b"\x1bOP", Qt.Key_F2: b"\x1bOQ", Qt.Key_F3: b"\x1bOR", Qt.Key_F4: b"\x1bOS",
Qt.Key_F5: b"\x1b[15~", Qt.Key_F6: b"\x1b[17~", Qt.Key_F7: b"\x1b[18~", Qt.Key_F8: b"\x1b[19~",
Qt.Key_F9: b"\x1b[20~", Qt.Key_F10: b"\x1b[21~", Qt.Key_F11: b"\x1b[23~", Qt.Key_F12: b"\x1b[24~",
Qt.Key_Backspace: b"\x7f", Qt.Key_Tab: b"\t", Qt.Key_Return: b"\r", Qt.Key_Enter: b"\r",
Qt.Key_Escape: b"\x1b",
}
class ReplyingScreen(pyte.HistoryScreen):
"""Ecran pyte capable de repondre aux requetes du terminal.
Certaines pseudo-consoles (ConPTY notamment) interrogent le terminal au demarrage
("Device Attributes" \x1b[c, "Cursor Position Report" \x1b[6n) et **attendent la
reponse** avant de poursuivre. pyte sait construire ces reponses, mais les envoie
via write_process_input(), qui ne fait rien par defaut : sans ce branchement, la
console reste bloquee en attente.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.reply_callback = None
def write_process_input(self, data: str) -> None:
if self.reply_callback:
self.reply_callback(data.encode("utf-8", errors="ignore"))
class TerminalWidget(QWidget):
data_to_send = Signal(bytes)
resized = Signal(int, int) # cols, rows
def __init__(self, parent=None):
super().__init__(parent)
self.setFocusPolicy(Qt.StrongFocus)
self.setAttribute(Qt.WA_OpaquePaintEvent, True)
self.font_ = QFont("Cascadia Mono", 10)
if not self.font_.exactMatch():
self.font_ = QFont("Consolas", 10)
self.font_.setStyleHint(QFont.Monospace)
self.setFont(self.font_)
metrics = QFontMetrics(self.font_)
self.char_w = metrics.horizontalAdvance("M")
self.char_h = metrics.height()
self.cols = 80
self.rows = 24
self.screen = ReplyingScreen(self.cols, self.rows, history=5000)
# les reponses aux requetes du terminal repartent par le meme canal que le clavier
self.screen.reply_callback = self.data_to_send.emit
self.stream = pyte.ByteStream(self.screen)
self.scroll_offset = 0
self._prev_cursor_y = 0
self._load_palette()
self.setMinimumSize(200, 100)
def _load_palette(self) -> None:
"""Construit les couleurs du terminal a partir du theme courant.
Le cache evite de re-parser les memes couleurs hexa a chaque caractere
redessine (c'etait un point chaud du rendu)."""
palette = app_theme.terminal_palette()
self.default_fg = QColor(palette["fg"])
self.default_bg = QColor(palette["bg"])
self.cursor_color = QColor(*palette["cursor"])
self._color_cache: dict[str, QColor] = {
name: QColor(value) for name, value in palette["ansi"].items()
}
self._bold_fg = QColor(palette["ansi"]["brightwhite"])
def refresh_theme(self) -> None:
"""Recharge les couleurs apres un changement de theme, et redessine."""
self._load_palette()
self.update()
def buffer_text(self) -> str:
"""Texte visible + historique proche, pour retrouver ce qui a ete tape
(utilise pour deduire l'hote d'une commande ssh saisie a la main).
Les lignes remplies jusqu'a la derniere colonne sont recollees avec la
suivante : une commande longue (prompt Windows + ssh user@ip) deborde sur
deux lignes, et la couper en plein milieu casserait la detection.
"""
lines = list(self.screen.display)
try:
for entry in self.screen.history.top:
lines.insert(0, "".join(char.data for char in entry.values()))
except Exception:
pass
joined: list[str] = []
current = ""
for line in lines:
stripped = line.rstrip()
current += stripped
if len(stripped) >= self.cols: # ligne pleine : la suite continue en dessous
continue
joined.append(current)
current = ""
if current:
joined.append(current)
return "\n".join(joined)
# ---------- alimentation depuis la connexion SSH ----------
def feed(self, data: bytes) -> None:
was_scrolled = self.scroll_offset != 0
self.stream.feed(data)
self.scroll_offset = 0
if was_scrolled:
# on revient du scrollback a la vue live : tout le contenu affiche change,
# un repaint partiel base sur les seules lignes "dirty" serait incomplet
self.screen.dirty.clear()
self.update()
return
dirty_rows = self.screen.dirty
cursor_y = self.screen.cursor.y
if cursor_y != self._prev_cursor_y:
dirty_rows = dirty_rows | {self._prev_cursor_y, cursor_y}
else:
dirty_rows = dirty_rows | {cursor_y}
self._prev_cursor_y = cursor_y
if dirty_rows:
min_y = max(0, min(dirty_rows))
max_y = min(self.rows - 1, max(dirty_rows))
self.update(QRect(0, min_y * self.char_h, self.cols * self.char_w, (max_y - min_y + 1) * self.char_h))
self.screen.dirty.clear()
# ---------- rendu ----------
def paintEvent(self, event) -> None:
painter = QPainter(self)
rect = event.rect()
painter.fillRect(rect, self.default_bg)
painter.setFont(self.font_)
y0 = max(0, rect.top() // self.char_h)
y1 = min(self.rows - 1, rect.bottom() // self.char_h)
cursor = self.screen.cursor
for y in range(y0, y1 + 1):
line = self.screen.buffer[y]
x = 0
while x < self.cols:
char = line[x]
run_text = char.data
start_x = x
fg, bg = self._resolve_colors(char)
# regrouper les caracteres consecutifs de meme style pour limiter les appels de dessin
x += 1
while x < self.cols:
nxt = line[x]
nfg, nbg = self._resolve_colors(nxt)
if nfg == fg and nbg == bg:
run_text += nxt.data
x += 1
else:
break
px = start_x * self.char_w
py = y * self.char_h
run_w = (x - start_x) * self.char_w
if bg != self.default_bg:
painter.fillRect(px, py, run_w, self.char_h, bg)
painter.setPen(fg)
painter.drawText(px, py + self.char_h - 4, run_text)
# curseur (bloc plein), non affiche pendant un scroll dans l'historique
if self.scroll_offset == 0 and not self.screen.cursor.hidden and y0 <= cursor.y <= y1:
cx, cy = cursor.x, cursor.y
painter.fillRect(cx * self.char_w, cy * self.char_h, self.char_w, self.char_h, self.cursor_color)
painter.end()
def _resolve_colors(self, char) -> tuple[QColor, QColor]:
fg_color = self._color_for(char.fg, self.default_fg)
bg_color = self._color_for(char.bg, self.default_bg)
if char.reverse:
fg_color, bg_color = bg_color, fg_color
if char.bold and fg_color is self.default_fg:
fg_color = self._bold_fg
return fg_color, bg_color
def _color_for(self, value: str, default: QColor) -> QColor:
cached = self._color_cache.get(value)
if cached is not None:
return cached
if isinstance(value, str) and len(value) == 6:
candidate = QColor(f"#{value}")
if candidate.isValid():
self._color_cache[value] = candidate
return candidate
return default
# ---------- redimensionnement ----------
def resizeEvent(self, event) -> None:
new_cols = max(10, self.width() // self.char_w)
new_rows = max(3, self.height() // self.char_h)
if new_cols != self.cols or new_rows != self.rows:
self.cols, self.rows = new_cols, new_rows
self.screen.resize(self.rows, self.cols)
self.resized.emit(self.cols, self.rows)
super().resizeEvent(event)
# ---------- entree clavier ----------
def keyPressEvent(self, event: QKeyEvent) -> None:
key = event.key()
modifiers = event.modifiers()
# Ctrl+lettre = raccourci (Ctrl+C, Ctrl+D...), mais PAS quand Alt est aussi enfonce :
# sur les claviers europeens (AZERTY compris), AltGr - indispensable pour taper
# @ # { } | ~ ... - est rapporte par Windows comme Ctrl+Alt enfonces ensemble.
# Sans cette exclusion, taper "@" (et les lettres voisines pendant le chevauchement
# naturel de la frappe) etait intercepte comme un raccourci Ctrl+lettre parasite,
# envoyant un octet de controle au lieu du vrai caractere.
if (modifiers & Qt.ControlModifier) and not (modifiers & Qt.AltModifier) and Qt.Key_A <= key <= Qt.Key_Z:
self.data_to_send.emit(bytes([key - Qt.Key_A + 1]))
return
if key in KEY_SEQUENCES:
self.data_to_send.emit(KEY_SEQUENCES[key])
return
text = event.text()
if text:
self.data_to_send.emit(text.encode("utf-8", errors="ignore"))
return
super().keyPressEvent(event)
def wheelEvent(self, event: QWheelEvent) -> None:
steps = event.angleDelta().y() // 120
if steps > 0:
self.screen.prev_page()
self.scroll_offset += steps
elif steps < 0:
self.screen.next_page()
self.scroll_offset = max(0, self.scroll_offset + steps)
self.update()
def mousePressEvent(self, event) -> None:
self.setFocus()
super().mousePressEvent(event)