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:
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# generes localement (chemins absolus / dependants du poste)
|
||||||
|
*.lnk
|
||||||
|
resources/app.ico
|
||||||
|
data/settings.json
|
||||||
|
|
||||||
|
# artefacts de construction PyInstaller
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
data/sessions.json
|
||||||
|
data/keys.json
|
||||||
|
data/known_hosts
|
||||||
|
data/keys/*
|
||||||
|
!data/keys/.gitkeep
|
||||||
65
PySSHClient.spec
Normal file
65
PySSHClient.spec
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
"""Recette PyInstaller : construit un dossier portable dans dist/PySSH Client/.
|
||||||
|
|
||||||
|
Choix "onedir" (dossier) plutot que "onefile" : avec PySide6, un executable unique
|
||||||
|
doit decompresser ~200 Mo a chaque lancement, ce qui ajoute plusieurs secondes
|
||||||
|
d'attente a chaque demarrage. Le dossier demarre instantanement et se copie tout
|
||||||
|
aussi bien sur une cle USB.
|
||||||
|
"""
|
||||||
|
|
||||||
|
block_cipher = None
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
["main.py"],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[],
|
||||||
|
# la feuille de style est lue au runtime : elle doit voyager avec l'application
|
||||||
|
datas=[("resources/theme.qss", "resources")],
|
||||||
|
hiddenimports=["winpty"],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
# modules inutiles ici : les exclure allege sensiblement le resultat
|
||||||
|
excludes=[
|
||||||
|
"tkinter", "PySide6.QtQml", "PySide6.QtQuick", "PySide6.QtQuick3D",
|
||||||
|
"PySide6.QtWebEngineCore", "PySide6.QtWebEngineWidgets", "PySide6.Qt3DCore",
|
||||||
|
"PySide6.QtMultimedia", "PySide6.QtCharts", "PySide6.QtDataVisualization",
|
||||||
|
"PySide6.QtBluetooth", "PySide6.QtPositioning", "PySide6.QtSensors",
|
||||||
|
],
|
||||||
|
win_no_prefer_redirects=False,
|
||||||
|
win_private_assemblies=False,
|
||||||
|
cipher=block_cipher,
|
||||||
|
noarchive=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
[],
|
||||||
|
exclude_binaries=True,
|
||||||
|
name="PySSH Client",
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=False,
|
||||||
|
console=False, # application graphique : pas de fenetre de console
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
icon="resources/app.ico",
|
||||||
|
)
|
||||||
|
|
||||||
|
coll = COLLECT(
|
||||||
|
exe,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
strip=False,
|
||||||
|
upx=False,
|
||||||
|
upx_exclude=[],
|
||||||
|
name="PySSH Client",
|
||||||
|
)
|
||||||
123
README.md
Normal file
123
README.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# PySSH Client
|
||||||
|
|
||||||
|
Client SSH/SFTP personnel (type MobaXterm/Termius/Bitvise), 100% local, sans compte ni service tiers.
|
||||||
|
|
||||||
|
## Fonctionnalites
|
||||||
|
|
||||||
|
- Sessions SSH multiples dans des onglets, avec terminal ANSI colore (theme "bleu nuit")
|
||||||
|
- 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
|
||||||
|
- 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)
|
||||||
|
- Client SFTP integre facon WinSCP : double panneau local/distant avec colonnes triables (nom, taille, date, droits),
|
||||||
|
glisser-deposer entre les panneaux, renommage (F2), suppression (Suppr), creation de dossier, permissions distantes
|
||||||
|
(chmod), et file de transferts avec progression. **Aucun favori n'est necessaire pour l'utiliser** — voir ci-dessous
|
||||||
|
- **SFTP sur la connexion en cours** : depuis n'importe quel terminal ou vous avez tape `ssh user@hote` a la main, le
|
||||||
|
bouton "📁 SFTP sur cette connexion" lit l'hote, l'utilisateur et le port dans la commande saisie et ouvre le
|
||||||
|
gestionnaire de fichiers dessus. Pense pour les serveurs de test en DHCP, qu'on n'a aucune envie d'enregistrer
|
||||||
|
- **Authentification automatique** (mode par defaut) : comme la commande `ssh`, l'application utilise l'agent SSH et les
|
||||||
|
cles de `~/.ssh` sans que vous ayez a designer quoi que ce soit, et ne demande un mot de passe qu'en dernier recours
|
||||||
|
- Verification des empreintes de cles d'hote (TOFU) avant premiere connexion, comme un client SSH classique
|
||||||
|
- Gestionnaire des hotes connus (menu "Hotes connus" ou bouton "⚙" du panneau lateral) : si une adresse en DHCP change de machine, la cle d'hote presentee ne correspond plus a celle enregistree — l'application le detecte et propose de remplacer l'ancienne entree directement au moment de la connexion, sans avoir a vider le fichier `known_hosts` a la main. Le gestionnaire permet aussi un nettoyage manuel (par hote ou en une fois).
|
||||||
|
|
||||||
|
Aucun mot de passe n'est stocke sur le disque : l'authentification par mot de passe est demandee a chaque connexion, et les passphrases de cles sont demandees uniquement en memoire au moment de la connexion.
|
||||||
|
|
||||||
|
## Installation (Windows)
|
||||||
|
|
||||||
|
Necessite Python 3.10+ installe sur le poste (verifiable avec `py -3 --version`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
setup.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce script cree un environnement virtuel local (`venv/`) et y installe les dependances (PySide6, paramiko, pyte, cryptography, pywinpty) — rien n'est installe globalement sur le poste.
|
||||||
|
|
||||||
|
## Lancement
|
||||||
|
|
||||||
|
Trois facons, au choix :
|
||||||
|
|
||||||
|
- **Raccourci Windows** (recommande) : double-cliquez sur `PySSH Client.lnk` a la racine du projet.
|
||||||
|
Il lance l'application via `pythonw.exe`, donc **sans fenetre de console noire**. Vous pouvez le
|
||||||
|
copier sur le Bureau, ou l'epingler a la barre des taches.
|
||||||
|
Pour le (re)creer — apres un deplacement du projet, ou sur un autre poste :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
powershell -ExecutionPolicy Bypass -File tools\create_shortcut.ps1 -Desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
(`-Desktop` et `-StartMenu` sont optionnels et ajoutent une copie a ces emplacements.)
|
||||||
|
|
||||||
|
- **En ligne de commande**, sans console residuelle :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
venv\Scripts\pythonw.exe main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`run.bat`** : garde une fenetre de console ouverte, ce qui est pratique pour voir les erreurs
|
||||||
|
si l'application refuse de demarrer.
|
||||||
|
|
||||||
|
## Version portable (sans Python installe)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
build_exe.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
Produit un dossier autonome `dist\PySSH Client\` contenant l'executable et tout ce dont il a besoin,
|
||||||
|
interpreteur Python compris. Copiez ce dossier ou vous voulez (y compris sur une cle USB) : il
|
||||||
|
fonctionne sur n'importe quel poste Windows, **meme sans Python installe**.
|
||||||
|
|
||||||
|
Le format retenu est un dossier plutot qu'un fichier `.exe` unique : avec PySide6, un executable
|
||||||
|
unique doit decompresser environ 200 Mo a chaque lancement, ce qui ajoute plusieurs secondes
|
||||||
|
d'attente a chaque demarrage. Le dossier demarre immediatement.
|
||||||
|
|
||||||
|
**Ou sont stockees les donnees ?** A cote de l'executable, dans son propre sous-dossier `data\`
|
||||||
|
(mode portable : vos sessions et cles suivent la cle USB). Si cet emplacement n'est pas accessible
|
||||||
|
en ecriture — installation dans `Program Files`, support en lecture seule — l'application bascule
|
||||||
|
automatiquement sur `%APPDATA%\PySSHClient\`.
|
||||||
|
|
||||||
|
## Premiers pas
|
||||||
|
|
||||||
|
1. **Terminal local immediat** : bouton "💻 Terminal" dans le panneau lateral (ou `Ctrl+Shift+T`), ou double-clic sur "PowerShell"/"Invite de commandes" dans la section "Terminaux locaux" de l'arborescence — aucune session a creer.
|
||||||
|
2. **Nouvelle session SSH** (bouton "+ Session" ou `Ctrl+N`) : renseignez l'hote, le port, l'utilisateur et le mode d'authentification.
|
||||||
|
3. **Cles SSH** (menu "Cles SSH > Gestionnaire de cles") : generez une nouvelle paire de cles ou importez-en une existante, puis selectionnez-la dans le formulaire de session.
|
||||||
|
4. Double-cliquez sur une session dans l'arborescence pour ouvrir un onglet terminal SSH.
|
||||||
|
5. **SFTP — trois facons d'y arriver**, selon la situation :
|
||||||
|
- *Vous avez tape `ssh user@hote` dans un terminal* : bouton **"📁 SFTP sur cette connexion"** dans la barre de
|
||||||
|
l'onglet. L'hote et l'utilisateur sont deduits de la commande, le formulaire est pre-rempli. Rien a enregistrer.
|
||||||
|
- *Connexion ponctuelle* : bouton **"📁 SFTP"** du panneau lateral, ou `Ctrl+Shift+F`. La case "Enregistrer dans mes
|
||||||
|
sessions" est decochee par defaut.
|
||||||
|
- *Session enregistree* : clic droit > **"Ouvrir en SFTP"** (onglet dedie). Dans une session SSH ouverte par
|
||||||
|
l'application, le panneau de fichiers est deja affiche a cote du terminal, comme dans MobaXterm.
|
||||||
|
|
||||||
|
Dans tous les cas : glisser-deposer entre les deux panneaux, F2 renomme, Suppr supprime, F5 actualise, et le clic
|
||||||
|
droit sur le panneau distant donne acces aux permissions (chmod).
|
||||||
|
|
||||||
|
## Structure du projet
|
||||||
|
|
||||||
|
```
|
||||||
|
main.py point d'entree
|
||||||
|
app/
|
||||||
|
theme.py feuille de style "bleu nuit" + icone d'application
|
||||||
|
session_manager.py base de sessions (JSON)
|
||||||
|
key_manager.py generation/import/export de cles SSH
|
||||||
|
ssh_connection.py connexion SSH en arriere-plan (paramiko + QThread)
|
||||||
|
known_hosts_manager.py gestion du fichier known_hosts (lister/retirer/remplacer une cle d'hote)
|
||||||
|
local_terminal.py terminal local Windows (PowerShell/cmd via pywinpty)
|
||||||
|
terminal_widget.py emulateur de terminal ANSI (pyte + QPainter)
|
||||||
|
sftp_panel.py client SFTP a deux panneaux
|
||||||
|
main_window.py fenetre principale (arbre de sessions + onglets)
|
||||||
|
dialogs/ boites de dialogue (session, cles, connexion)
|
||||||
|
resources/
|
||||||
|
theme.qss feuille de style Qt "bleu nuit"
|
||||||
|
data/
|
||||||
|
sessions.json vos serveurs enregistres (non versionne)
|
||||||
|
keys.json registre des cles connues (non versionne)
|
||||||
|
keys/ cles generees par l'application (non versionne)
|
||||||
|
known_hosts empreintes d'hotes approuvees (non versionne)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limites connues / pistes d'amelioration
|
||||||
|
|
||||||
|
- L'emulation de terminal couvre les usages courants (bash, vim, htop...) mais n'est pas un xterm parfaitement complet.
|
||||||
|
- Le terminal local repose sur `pywinpty` (pseudo-console Windows). Si un onglet local reste bloque sur un ecran vide, tapez une touche : le rafraichissement de la console Windows peut etre paresseux dans certains contextes (notamment les sessions Windows non interactives) et se declenche a la premiere frappe.
|
||||||
|
- Le SFTP ne propose pas encore l'edition de fichier distant en place (telecharger / modifier / renvoyer a la main).
|
||||||
|
- Pas encore d'export/import global du carnet de sessions (le fichier `data/sessions.json` peut etre copie manuellement).
|
||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/dialogs/__init__.py
Normal file
0
app/dialogs/__init__.py
Normal file
42
app/dialogs/connect_prompt.py
Normal file
42
app/dialogs/connect_prompt.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Dialogues utilises au moment de la connexion : saisie de secret et confirmation de cle d'hote."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QInputDialog, QLineEdit, QMessageBox, QWidget
|
||||||
|
|
||||||
|
|
||||||
|
def ask_secret(parent: QWidget, title: str, label: str) -> tuple[str, bool]:
|
||||||
|
text, ok = QInputDialog.getText(parent, title, label, QLineEdit.Password)
|
||||||
|
return text, ok
|
||||||
|
|
||||||
|
|
||||||
|
def confirm_host_key(parent: QWidget, hostname: str, key_type: str, fingerprint: str) -> bool:
|
||||||
|
msg = (
|
||||||
|
f"L'empreinte de la cle du serveur '{hostname}' n'est pas connue.\n\n"
|
||||||
|
f"Type : {key_type}\n"
|
||||||
|
f"Empreinte : {fingerprint}\n\n"
|
||||||
|
"Verifiez cette empreinte aupres de l'administrateur du serveur si vous n'etes pas certain.\n"
|
||||||
|
"Faire confiance a cette cle et se connecter ?"
|
||||||
|
)
|
||||||
|
answer = QMessageBox.question(
|
||||||
|
parent, "Nouvelle cle d'hote detectee", msg,
|
||||||
|
QMessageBox.Yes | QMessageBox.No, QMessageBox.No,
|
||||||
|
)
|
||||||
|
return answer == QMessageBox.Yes
|
||||||
|
|
||||||
|
|
||||||
|
def confirm_host_key_change(parent: QWidget, hostname: str, key_type: str, old_fingerprint: str, new_fingerprint: str) -> bool:
|
||||||
|
msg = (
|
||||||
|
f"La cle presentee par le serveur '{hostname}' ne correspond plus a celle enregistree.\n\n"
|
||||||
|
f"Ancienne empreinte : {old_fingerprint}\n"
|
||||||
|
f"Nouvelle empreinte : {new_fingerprint}\n\n"
|
||||||
|
"C'est generalement normal si ce serveur a une adresse IP attribuee par DHCP et "
|
||||||
|
"qu'elle a ete reutilisee par une autre machine, ou si le serveur a ete reinstalle. "
|
||||||
|
"Mais cela peut aussi indiquer une interception de la connexion (attaque de type "
|
||||||
|
"'man-in-the-middle') : verifiez la nouvelle empreinte si vous avez un doute.\n\n"
|
||||||
|
"Remplacer l'ancienne cle enregistree et se connecter ?"
|
||||||
|
)
|
||||||
|
answer = QMessageBox.warning(
|
||||||
|
parent, "Cle d'hote modifiee", msg,
|
||||||
|
QMessageBox.Yes | QMessageBox.No, QMessageBox.No,
|
||||||
|
)
|
||||||
|
return answer == QMessageBox.Yes
|
||||||
198
app/dialogs/key_dialog.py
Normal file
198
app/dialogs/key_dialog.py
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
"""Gestionnaire de cles SSH : generation, import, export."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, QPushButton,
|
||||||
|
QMessageBox, QFileDialog, QFormLayout, QLineEdit, QComboBox, QSpinBox,
|
||||||
|
QDialogButtonBox, QApplication, QLabel,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.key_manager import KeyManager, KEY_TYPE_ED25519, KEY_TYPE_RSA
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateKeyDialog(QDialog):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Generer une nouvelle cle SSH")
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
form = QFormLayout()
|
||||||
|
|
||||||
|
self.name_edit = QLineEdit()
|
||||||
|
self.type_combo = QComboBox()
|
||||||
|
self.type_combo.addItem("Ed25519 (recommande)", KEY_TYPE_ED25519)
|
||||||
|
self.type_combo.addItem("RSA", KEY_TYPE_RSA)
|
||||||
|
self.bits_spin = QSpinBox()
|
||||||
|
self.bits_spin.setRange(2048, 8192)
|
||||||
|
self.bits_spin.setSingleStep(1024)
|
||||||
|
self.bits_spin.setValue(4096)
|
||||||
|
self.passphrase_edit = QLineEdit()
|
||||||
|
self.passphrase_edit.setEchoMode(QLineEdit.Password)
|
||||||
|
self.comment_edit = QLineEdit()
|
||||||
|
|
||||||
|
form.addRow("Nom :", self.name_edit)
|
||||||
|
form.addRow("Type :", self.type_combo)
|
||||||
|
form.addRow("Taille (RSA) :", self.bits_spin)
|
||||||
|
form.addRow("Passphrase (optionnel) :", self.passphrase_edit)
|
||||||
|
form.addRow("Commentaire :", self.comment_edit)
|
||||||
|
layout.addLayout(form)
|
||||||
|
|
||||||
|
self.type_combo.currentIndexChanged.connect(
|
||||||
|
lambda: self.bits_spin.setEnabled(self.type_combo.currentData() == KEY_TYPE_RSA)
|
||||||
|
)
|
||||||
|
self.bits_spin.setEnabled(False)
|
||||||
|
|
||||||
|
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||||
|
buttons.accepted.connect(self.accept)
|
||||||
|
buttons.rejected.connect(self.reject)
|
||||||
|
layout.addWidget(buttons)
|
||||||
|
|
||||||
|
def values(self) -> dict:
|
||||||
|
return dict(
|
||||||
|
name=self.name_edit.text().strip() or "cle_ssh",
|
||||||
|
key_type=self.type_combo.currentData(),
|
||||||
|
rsa_bits=self.bits_spin.value(),
|
||||||
|
passphrase=self.passphrase_edit.text(),
|
||||||
|
comment=self.comment_edit.text().strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class KeyManagerDialog(QDialog):
|
||||||
|
def __init__(self, key_manager: KeyManager, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.key_manager = key_manager
|
||||||
|
self.setWindowTitle("Gestionnaire de cles SSH")
|
||||||
|
self.setMinimumSize(560, 360)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
self.list = QListWidget()
|
||||||
|
layout.addWidget(self.list)
|
||||||
|
|
||||||
|
self.info_label = QLabel("")
|
||||||
|
self.info_label.setWordWrap(True)
|
||||||
|
layout.addWidget(self.info_label)
|
||||||
|
|
||||||
|
btn_row = QHBoxLayout()
|
||||||
|
self.generate_btn = QPushButton("Generer...")
|
||||||
|
self.import_btn = QPushButton("Importer...")
|
||||||
|
self.export_pub_btn = QPushButton("Exporter cle publique...")
|
||||||
|
self.export_priv_btn = QPushButton("Exporter cle privee...")
|
||||||
|
self.copy_pub_btn = QPushButton("Copier cle publique")
|
||||||
|
self.remove_btn = QPushButton("Supprimer")
|
||||||
|
for b in (self.generate_btn, self.import_btn, self.export_pub_btn,
|
||||||
|
self.export_priv_btn, self.copy_pub_btn, self.remove_btn):
|
||||||
|
btn_row.addWidget(b)
|
||||||
|
layout.addLayout(btn_row)
|
||||||
|
|
||||||
|
close_row = QHBoxLayout()
|
||||||
|
close_row.addStretch()
|
||||||
|
close_btn = QPushButton("Fermer")
|
||||||
|
close_btn.clicked.connect(self.accept)
|
||||||
|
close_row.addWidget(close_btn)
|
||||||
|
layout.addLayout(close_row)
|
||||||
|
|
||||||
|
self.generate_btn.clicked.connect(self.generate_key)
|
||||||
|
self.import_btn.clicked.connect(self.import_key)
|
||||||
|
self.export_pub_btn.clicked.connect(self.export_public)
|
||||||
|
self.export_priv_btn.clicked.connect(self.export_private)
|
||||||
|
self.copy_pub_btn.clicked.connect(self.copy_public)
|
||||||
|
self.remove_btn.clicked.connect(self.remove_key)
|
||||||
|
self.list.currentItemChanged.connect(self._update_info)
|
||||||
|
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
self.list.clear()
|
||||||
|
for entry in self.key_manager.keys:
|
||||||
|
item = QListWidgetItem(f"{entry.name} [{entry.key_type}]")
|
||||||
|
item.setData(Qt.UserRole, entry.id)
|
||||||
|
self.list.addItem(item)
|
||||||
|
self.info_label.setText("")
|
||||||
|
|
||||||
|
def _current_entry(self):
|
||||||
|
item = self.list.currentItem()
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
key_id = item.data(Qt.UserRole)
|
||||||
|
return next((k for k in self.key_manager.keys if k.id == key_id), None)
|
||||||
|
|
||||||
|
def _update_info(self) -> None:
|
||||||
|
entry = self._current_entry()
|
||||||
|
if not entry:
|
||||||
|
self.info_label.setText("")
|
||||||
|
return
|
||||||
|
self.info_label.setText(
|
||||||
|
f"Chemin : {entry.private_path}\nEmpreinte : {entry.fingerprint}\n\n{entry.public_key_text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate_key(self) -> None:
|
||||||
|
dlg = GenerateKeyDialog(self)
|
||||||
|
if dlg.exec() != QDialog.Accepted:
|
||||||
|
return
|
||||||
|
vals = dlg.values()
|
||||||
|
try:
|
||||||
|
self.key_manager.generate_key(**vals)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
QMessageBox.critical(self, "Erreur", str(exc))
|
||||||
|
return
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def import_key(self) -> None:
|
||||||
|
priv_path, _ = QFileDialog.getOpenFileName(self, "Selectionner la cle privee a importer")
|
||||||
|
if not priv_path:
|
||||||
|
return
|
||||||
|
name = Path(priv_path).stem
|
||||||
|
try:
|
||||||
|
self.key_manager.import_key(name=name, private_key_path=priv_path)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
QMessageBox.critical(self, "Erreur", str(exc))
|
||||||
|
return
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def export_public(self) -> None:
|
||||||
|
entry = self._current_entry()
|
||||||
|
if not entry:
|
||||||
|
return
|
||||||
|
dest, _ = QFileDialog.getSaveFileName(self, "Exporter la cle publique", f"{entry.name}.pub")
|
||||||
|
if dest:
|
||||||
|
self.key_manager.export_public_key(entry, dest)
|
||||||
|
QMessageBox.information(self, "Export", "Cle publique exportee.")
|
||||||
|
|
||||||
|
def export_private(self) -> None:
|
||||||
|
entry = self._current_entry()
|
||||||
|
if not entry:
|
||||||
|
return
|
||||||
|
if QMessageBox.warning(
|
||||||
|
self, "Attention",
|
||||||
|
"La cle privee est sensible : quiconque y a acces peut se connecter a vos serveurs. Continuer ?",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
) != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
dest, _ = QFileDialog.getSaveFileName(self, "Exporter la cle privee", entry.name)
|
||||||
|
if dest:
|
||||||
|
self.key_manager.export_private_key(entry, dest)
|
||||||
|
QMessageBox.information(self, "Export", "Cle privee exportee.")
|
||||||
|
|
||||||
|
def copy_public(self) -> None:
|
||||||
|
entry = self._current_entry()
|
||||||
|
if not entry:
|
||||||
|
return
|
||||||
|
QApplication.clipboard().setText(entry.public_key_text)
|
||||||
|
QMessageBox.information(self, "Copie", "Cle publique copiee dans le presse-papiers.")
|
||||||
|
|
||||||
|
def remove_key(self) -> None:
|
||||||
|
entry = self._current_entry()
|
||||||
|
if not entry:
|
||||||
|
return
|
||||||
|
delete_files = QMessageBox.question(
|
||||||
|
self, "Supprimer",
|
||||||
|
"Retirer aussi les fichiers de cle du disque (irreversible) ?\n"
|
||||||
|
"Non = seulement retirer de la liste.",
|
||||||
|
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
|
||||||
|
)
|
||||||
|
if delete_files == QMessageBox.Cancel:
|
||||||
|
return
|
||||||
|
self.key_manager.remove(entry.id, delete_files=(delete_files == QMessageBox.Yes))
|
||||||
|
self.refresh()
|
||||||
95
app/dialogs/known_hosts_dialog.py
Normal file
95
app/dialogs/known_hosts_dialog.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""Gestionnaire des hotes SSH connus (fichier known_hosts local)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog, QVBoxLayout, QHBoxLayout, QTreeWidget, QTreeWidgetItem, QPushButton,
|
||||||
|
QMessageBox, QLabel, QAbstractItemView,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.known_hosts_manager import KnownHostsStore
|
||||||
|
|
||||||
|
|
||||||
|
class KnownHostsDialog(QDialog):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.store = KnownHostsStore()
|
||||||
|
self.setWindowTitle("Gestionnaire des hotes connus")
|
||||||
|
self.setMinimumSize(560, 380)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
info = QLabel(
|
||||||
|
"Cles des serveurs approuvees lors d'une premiere connexion. Si un serveur change "
|
||||||
|
"d'adresse (ex. reattribution DHCP), l'application vous proposera automatiquement de "
|
||||||
|
"remplacer son entree au moment de la connexion. Vous pouvez aussi nettoyer manuellement ici."
|
||||||
|
)
|
||||||
|
info.setWordWrap(True)
|
||||||
|
layout.addWidget(info)
|
||||||
|
|
||||||
|
self.tree = QTreeWidget()
|
||||||
|
self.tree.setHeaderLabels(["Hote", "Type de cle", "Empreinte"])
|
||||||
|
self.tree.setRootIsDecorated(False)
|
||||||
|
self.tree.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||||
|
self.tree.setColumnWidth(0, 200)
|
||||||
|
self.tree.setColumnWidth(1, 110)
|
||||||
|
layout.addWidget(self.tree)
|
||||||
|
|
||||||
|
self.count_label = QLabel("")
|
||||||
|
self.count_label.setStyleSheet("color: #93a4c2;")
|
||||||
|
layout.addWidget(self.count_label)
|
||||||
|
|
||||||
|
btn_row = QHBoxLayout()
|
||||||
|
self.refresh_btn = QPushButton("Actualiser")
|
||||||
|
self.remove_btn = QPushButton("Supprimer la selection")
|
||||||
|
self.clear_btn = QPushButton("Tout effacer")
|
||||||
|
btn_row.addWidget(self.refresh_btn)
|
||||||
|
btn_row.addWidget(self.remove_btn)
|
||||||
|
btn_row.addWidget(self.clear_btn)
|
||||||
|
btn_row.addStretch()
|
||||||
|
close_btn = QPushButton("Fermer")
|
||||||
|
close_btn.clicked.connect(self.accept)
|
||||||
|
btn_row.addWidget(close_btn)
|
||||||
|
layout.addLayout(btn_row)
|
||||||
|
|
||||||
|
self.refresh_btn.clicked.connect(self.refresh)
|
||||||
|
self.remove_btn.clicked.connect(self.remove_selected)
|
||||||
|
self.clear_btn.clicked.connect(self.clear_all)
|
||||||
|
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
self.tree.clear()
|
||||||
|
entries = self.store.list_entries()
|
||||||
|
for entry in entries:
|
||||||
|
item = QTreeWidgetItem([entry.hostname, entry.key_type, entry.fingerprint])
|
||||||
|
item.setData(0, Qt.UserRole, entry.hostname)
|
||||||
|
self.tree.addTopLevelItem(item)
|
||||||
|
self.count_label.setText(f"{len(entries)} hote(s) enregistre(s).")
|
||||||
|
self.remove_btn.setEnabled(bool(entries))
|
||||||
|
self.clear_btn.setEnabled(bool(entries))
|
||||||
|
|
||||||
|
def remove_selected(self) -> None:
|
||||||
|
items = self.tree.selectedItems()
|
||||||
|
if not items:
|
||||||
|
return
|
||||||
|
hostnames = {item.data(0, Qt.UserRole) for item in items}
|
||||||
|
if QMessageBox.question(
|
||||||
|
self, "Supprimer",
|
||||||
|
f"Retirer {len(hostnames)} hote(s) du fichier known_hosts ?\n"
|
||||||
|
"Une confirmation d'empreinte sera redemandee a la prochaine connexion.",
|
||||||
|
) != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
for hostname in hostnames:
|
||||||
|
self.store.remove_host(hostname)
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def clear_all(self) -> None:
|
||||||
|
if QMessageBox.question(
|
||||||
|
self, "Tout effacer",
|
||||||
|
"Vider entierement le fichier known_hosts ?\n"
|
||||||
|
"Toutes les connexions redemanderont une confirmation d'empreinte.",
|
||||||
|
) != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
self.store.clear_all()
|
||||||
|
self.refresh()
|
||||||
151
app/dialogs/session_dialog.py
Normal file
151
app/dialogs/session_dialog.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""Boite de dialogue de creation/edition d'une session SSH."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog, QFormLayout, QLineEdit, QSpinBox, QComboBox, QDialogButtonBox,
|
||||||
|
QHBoxLayout, QPushButton, QTextEdit, QFileDialog, QWidget, QVBoxLayout,
|
||||||
|
QCheckBox, QMessageBox,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.session_manager import Session, AUTH_AUTO, AUTH_KEY, AUTH_PASSWORD, AUTH_AGENT
|
||||||
|
from app.key_manager import KeyManager
|
||||||
|
|
||||||
|
|
||||||
|
class SessionDialog(QDialog):
|
||||||
|
"""Formulaire de session. En mode `connect_label`, il sert de connexion rapide :
|
||||||
|
on saisit les infos et on se connecte directement, l'enregistrement etant optionnel."""
|
||||||
|
|
||||||
|
def __init__(self, key_manager: KeyManager, session: Session | None = None,
|
||||||
|
parent=None, connect_label: str = "", title: str = ""):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.key_manager = key_manager
|
||||||
|
self.session = session or Session()
|
||||||
|
self.quick_mode = bool(connect_label)
|
||||||
|
self.setWindowTitle(title or ("Nouvelle session" if session is None else "Modifier la session"))
|
||||||
|
self.setMinimumWidth(420)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
form = QFormLayout()
|
||||||
|
|
||||||
|
self.name_edit = QLineEdit(self.session.name)
|
||||||
|
self.group_edit = QLineEdit(self.session.group)
|
||||||
|
self.group_edit.setPlaceholderText("ex: Production/Web")
|
||||||
|
self.host_edit = QLineEdit(self.session.host)
|
||||||
|
self.port_spin = QSpinBox()
|
||||||
|
self.port_spin.setRange(1, 65535)
|
||||||
|
self.port_spin.setValue(self.session.port or 22)
|
||||||
|
self.user_edit = QLineEdit(self.session.username)
|
||||||
|
|
||||||
|
self.auth_combo = QComboBox()
|
||||||
|
self.auth_combo.addItem("Automatique (comme la commande ssh)", AUTH_AUTO)
|
||||||
|
self.auth_combo.addItem("Cle SSH choisie", AUTH_KEY)
|
||||||
|
self.auth_combo.addItem("Mot de passe (saisi a la connexion)", AUTH_PASSWORD)
|
||||||
|
self.auth_combo.addItem("Agent SSH", AUTH_AGENT)
|
||||||
|
self.auth_combo.setToolTip(
|
||||||
|
"Automatique : utilise l'agent SSH et vos cles ~/.ssh comme le fait la commande ssh,\n"
|
||||||
|
"et demande un mot de passe seulement si aucune cle ne convient."
|
||||||
|
)
|
||||||
|
idx = self.auth_combo.findData(self.session.auth_type)
|
||||||
|
self.auth_combo.setCurrentIndex(idx if idx >= 0 else 0)
|
||||||
|
|
||||||
|
self.key_combo = QComboBox()
|
||||||
|
self._reload_keys()
|
||||||
|
self.browse_key_btn = QPushButton("Parcourir...")
|
||||||
|
|
||||||
|
key_row = QWidget()
|
||||||
|
key_layout = QHBoxLayout(key_row)
|
||||||
|
key_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
key_layout.addWidget(self.key_combo, 1)
|
||||||
|
key_layout.addWidget(self.browse_key_btn)
|
||||||
|
|
||||||
|
self.notes_edit = QTextEdit(self.session.notes)
|
||||||
|
self.notes_edit.setFixedHeight(60)
|
||||||
|
|
||||||
|
form.addRow("Nom :", self.name_edit)
|
||||||
|
form.addRow("Dossier :", self.group_edit)
|
||||||
|
form.addRow("Hote :", self.host_edit)
|
||||||
|
form.addRow("Port :", self.port_spin)
|
||||||
|
form.addRow("Utilisateur :", self.user_edit)
|
||||||
|
form.addRow("Authentification :", self.auth_combo)
|
||||||
|
form.addRow("Cle privee :", key_row)
|
||||||
|
form.addRow("Notes :", self.notes_edit)
|
||||||
|
|
||||||
|
layout.addLayout(form)
|
||||||
|
|
||||||
|
self.save_check = QCheckBox("Enregistrer dans mes sessions (optionnel)")
|
||||||
|
if self.quick_mode:
|
||||||
|
# decoche par defaut : les serveurs de test changent d'IP (DHCP), les
|
||||||
|
# enregistrer systematiquement encombre la liste pour rien
|
||||||
|
self.save_check.setChecked(False)
|
||||||
|
layout.addWidget(self.save_check)
|
||||||
|
|
||||||
|
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||||
|
if self.quick_mode:
|
||||||
|
buttons.button(QDialogButtonBox.Ok).setText(connect_label)
|
||||||
|
buttons.accepted.connect(self._on_accept)
|
||||||
|
buttons.rejected.connect(self.reject)
|
||||||
|
layout.addWidget(buttons)
|
||||||
|
|
||||||
|
self.auth_combo.currentIndexChanged.connect(self._update_key_row)
|
||||||
|
self.browse_key_btn.clicked.connect(self._browse_key)
|
||||||
|
self._update_key_row()
|
||||||
|
|
||||||
|
if self.session.key_path:
|
||||||
|
existing = self.key_combo.findData(self.session.key_path)
|
||||||
|
if existing >= 0:
|
||||||
|
self.key_combo.setCurrentIndex(existing)
|
||||||
|
else:
|
||||||
|
self.key_combo.addItem(self.session.key_path, self.session.key_path)
|
||||||
|
self.key_combo.setCurrentIndex(self.key_combo.count() - 1)
|
||||||
|
|
||||||
|
def _reload_keys(self) -> None:
|
||||||
|
self.key_combo.clear()
|
||||||
|
for entry in self.key_manager.keys:
|
||||||
|
self.key_combo.addItem(f"{entry.name} ({entry.key_type})", entry.private_path)
|
||||||
|
|
||||||
|
def _update_key_row(self) -> None:
|
||||||
|
is_key_auth = self.auth_combo.currentData() == AUTH_KEY
|
||||||
|
self.key_combo.setEnabled(is_key_auth)
|
||||||
|
self.browse_key_btn.setEnabled(is_key_auth)
|
||||||
|
|
||||||
|
def _browse_key(self) -> None:
|
||||||
|
path, _ = QFileDialog.getOpenFileName(self, "Choisir une cle privee")
|
||||||
|
if path:
|
||||||
|
self.key_combo.addItem(path, path)
|
||||||
|
self.key_combo.setCurrentIndex(self.key_combo.count() - 1)
|
||||||
|
|
||||||
|
def should_save(self) -> bool:
|
||||||
|
"""En connexion rapide, indique si la session doit aussi etre enregistree."""
|
||||||
|
return self.save_check.isChecked() if self.quick_mode else True
|
||||||
|
|
||||||
|
def _on_accept(self) -> None:
|
||||||
|
if not self.host_edit.text().strip():
|
||||||
|
QMessageBox.warning(self, "Hote manquant", "Indiquez l'adresse du serveur (nom d'hote ou IP).")
|
||||||
|
self.host_edit.setFocus()
|
||||||
|
return
|
||||||
|
if not self.user_edit.text().strip():
|
||||||
|
QMessageBox.warning(self, "Utilisateur manquant", "Indiquez le nom d'utilisateur.")
|
||||||
|
self.user_edit.setFocus()
|
||||||
|
return
|
||||||
|
if self.auth_combo.currentData() == AUTH_KEY and not self.key_combo.currentData():
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "Cle manquante",
|
||||||
|
"Aucune cle privee n'est selectionnee.\n\n"
|
||||||
|
"Choisissez une cle (bouton « Parcourir... »), ou passez l'authentification "
|
||||||
|
"sur « Automatique » — qui utilisera l'agent SSH et vos cles ~/.ssh comme le "
|
||||||
|
"fait la commande ssh.",
|
||||||
|
)
|
||||||
|
self.auth_combo.setFocus()
|
||||||
|
return
|
||||||
|
self.session.name = self.name_edit.text().strip() or self.host_edit.text().strip()
|
||||||
|
self.session.group = self.group_edit.text().strip()
|
||||||
|
self.session.host = self.host_edit.text().strip()
|
||||||
|
self.session.port = self.port_spin.value()
|
||||||
|
self.session.username = self.user_edit.text().strip()
|
||||||
|
self.session.auth_type = self.auth_combo.currentData()
|
||||||
|
self.session.key_path = self.key_combo.currentData() or ""
|
||||||
|
self.session.notes = self.notes_edit.toPlainText()
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
def result_session(self) -> Session:
|
||||||
|
return self.session
|
||||||
201
app/key_manager.py
Normal file
201
app/key_manager.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"""Gestion des cles SSH : generation, import, export, registre local."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa, ed25519
|
||||||
|
|
||||||
|
from app.paths import DATA_DIR # noqa: E402 (chemins valables aussi en .exe)
|
||||||
|
KEYS_FILE = DATA_DIR / "keys.json"
|
||||||
|
DEFAULT_KEY_DIR = DATA_DIR / "keys"
|
||||||
|
|
||||||
|
KEY_TYPE_ED25519 = "ed25519"
|
||||||
|
KEY_TYPE_RSA = "rsa"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class KeyEntry:
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
name: str = ""
|
||||||
|
key_type: str = KEY_TYPE_ED25519
|
||||||
|
private_path: str = ""
|
||||||
|
public_path: str = ""
|
||||||
|
fingerprint: str = ""
|
||||||
|
public_key_text: str = ""
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(data: dict) -> "KeyEntry":
|
||||||
|
e = KeyEntry()
|
||||||
|
for k in KeyEntry.__dataclass_fields__:
|
||||||
|
if k in data:
|
||||||
|
setattr(e, k, data[k])
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
class KeyManager:
|
||||||
|
def __init__(self, registry_path: Path = KEYS_FILE):
|
||||||
|
self.registry_path = registry_path
|
||||||
|
self.keys: list[KeyEntry] = []
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
# ---------- registre ----------
|
||||||
|
def load(self) -> None:
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
DEFAULT_KEY_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not self.registry_path.exists():
|
||||||
|
self.keys = []
|
||||||
|
self.save()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
raw = json.loads(self.registry_path.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
raw = {"keys": []}
|
||||||
|
self.keys = [KeyEntry.from_dict(k) for k in raw.get("keys", [])]
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = {"keys": [k.to_dict() for k in self.keys]}
|
||||||
|
self.registry_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
|
||||||
|
def remove(self, key_id: str, delete_files: bool = False) -> None:
|
||||||
|
entry = next((k for k in self.keys if k.id == key_id), None)
|
||||||
|
if entry and delete_files:
|
||||||
|
for p in (entry.private_path, entry.public_path):
|
||||||
|
try:
|
||||||
|
if p and Path(p).exists():
|
||||||
|
Path(p).unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self.keys = [k for k in self.keys if k.id != key_id]
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
# ---------- generation ----------
|
||||||
|
def generate_key(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
key_type: str = KEY_TYPE_ED25519,
|
||||||
|
rsa_bits: int = 4096,
|
||||||
|
passphrase: str | None = None,
|
||||||
|
save_dir: Path | None = None,
|
||||||
|
comment: str = "",
|
||||||
|
) -> KeyEntry:
|
||||||
|
save_dir = save_dir or DEFAULT_KEY_DIR
|
||||||
|
save_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
safe_name = "".join(c for c in name if c.isalnum() or c in ("-", "_")) or "id_key"
|
||||||
|
filename = f"id_{key_type}_{safe_name}"
|
||||||
|
priv_path = save_dir / filename
|
||||||
|
pub_path = save_dir / f"{filename}.pub"
|
||||||
|
|
||||||
|
if key_type == KEY_TYPE_ED25519:
|
||||||
|
private_key = ed25519.Ed25519PrivateKey.generate()
|
||||||
|
else:
|
||||||
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=rsa_bits)
|
||||||
|
|
||||||
|
encryption = (
|
||||||
|
serialization.BestAvailableEncryption(passphrase.encode("utf-8"))
|
||||||
|
if passphrase
|
||||||
|
else serialization.NoEncryption()
|
||||||
|
)
|
||||||
|
priv_bytes = private_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.OpenSSH,
|
||||||
|
encryption_algorithm=encryption,
|
||||||
|
)
|
||||||
|
pub_bytes = private_key.public_key().public_bytes(
|
||||||
|
encoding=serialization.Encoding.OpenSSH,
|
||||||
|
format=serialization.PublicFormat.OpenSSH,
|
||||||
|
)
|
||||||
|
comment_suffix = f" {comment}".rstrip() if comment else ""
|
||||||
|
pub_text = pub_bytes.decode("utf-8") + comment_suffix + "\n"
|
||||||
|
|
||||||
|
priv_path.write_bytes(priv_bytes)
|
||||||
|
pub_path.write_text(pub_text, encoding="utf-8")
|
||||||
|
try:
|
||||||
|
priv_path.chmod(0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
fingerprint = self._fingerprint(pub_bytes)
|
||||||
|
entry = KeyEntry(
|
||||||
|
name=name,
|
||||||
|
key_type=key_type,
|
||||||
|
private_path=str(priv_path),
|
||||||
|
public_path=str(pub_path),
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
public_key_text=pub_text.strip(),
|
||||||
|
)
|
||||||
|
self.keys.append(entry)
|
||||||
|
self.save()
|
||||||
|
return entry
|
||||||
|
|
||||||
|
# ---------- import ----------
|
||||||
|
def import_key(self, name: str, private_key_path: str, public_key_path: str | None = None) -> KeyEntry:
|
||||||
|
priv_path = Path(private_key_path)
|
||||||
|
key_type, pub_text = self._inspect_private_key(priv_path)
|
||||||
|
|
||||||
|
pub_path_str = ""
|
||||||
|
if public_key_path and Path(public_key_path).exists():
|
||||||
|
pub_path_str = public_key_path
|
||||||
|
pub_text = Path(public_key_path).read_text(encoding="utf-8").strip()
|
||||||
|
else:
|
||||||
|
guess = priv_path.with_suffix(priv_path.suffix + ".pub")
|
||||||
|
if guess.exists():
|
||||||
|
pub_path_str = str(guess)
|
||||||
|
pub_text = guess.read_text(encoding="utf-8").strip()
|
||||||
|
|
||||||
|
fingerprint = pub_text.split()[1] if pub_text and len(pub_text.split()) > 1 else ""
|
||||||
|
entry = KeyEntry(
|
||||||
|
name=name,
|
||||||
|
key_type=key_type,
|
||||||
|
private_path=str(priv_path),
|
||||||
|
public_path=pub_path_str,
|
||||||
|
fingerprint=fingerprint[:16],
|
||||||
|
public_key_text=pub_text,
|
||||||
|
)
|
||||||
|
self.keys.append(entry)
|
||||||
|
self.save()
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def _inspect_private_key(self, priv_path: Path) -> tuple[str, str]:
|
||||||
|
"""Tente de determiner le type de cle sans mot de passe (best effort)."""
|
||||||
|
for loader, ktype in (
|
||||||
|
(paramiko.Ed25519Key, KEY_TYPE_ED25519),
|
||||||
|
(paramiko.RSAKey, KEY_TYPE_RSA),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
pkey = loader.from_private_key_file(str(priv_path))
|
||||||
|
pub_text = f"{pkey.get_name()} {pkey.get_base64()}"
|
||||||
|
return ktype, pub_text
|
||||||
|
except paramiko.PasswordRequiredException:
|
||||||
|
return ktype, ""
|
||||||
|
except (paramiko.SSHException, OSError):
|
||||||
|
continue
|
||||||
|
return "unknown", ""
|
||||||
|
|
||||||
|
# ---------- export ----------
|
||||||
|
def export_public_key(self, entry: KeyEntry, destination: str) -> None:
|
||||||
|
Path(destination).write_text(entry.public_key_text + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
def export_private_key(self, entry: KeyEntry, destination: str) -> None:
|
||||||
|
shutil.copyfile(entry.private_path, destination)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fingerprint(pub_bytes: bytes) -> str:
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
parts = pub_bytes.split()
|
||||||
|
if len(parts) < 2:
|
||||||
|
return ""
|
||||||
|
raw = base64.b64decode(parts[1])
|
||||||
|
digest = hashlib.sha256(raw).digest()
|
||||||
|
return "SHA256:" + base64.b64encode(digest).decode("utf-8").rstrip("=")
|
||||||
72
app/known_hosts_manager.py
Normal file
72
app/known_hosts_manager.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""Gestion du fichier known_hosts local (cles d'hotes approuvees via TOFU).
|
||||||
|
|
||||||
|
Avec des serveurs en IP dynamique (DHCP), une IP reattribuee a une autre machine
|
||||||
|
fait apparaitre une "cle d'hote modifiee" lors de la connexion : ce module permet
|
||||||
|
de retirer facilement l'ancienne entree, en masse ou au cas par cas, sans avoir
|
||||||
|
a editer le fichier a la main.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
from app.paths import DATA_DIR # noqa: E402 (chemins valables aussi en .exe)
|
||||||
|
KNOWN_HOSTS_FILE = DATA_DIR / "known_hosts"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class KnownHostEntry:
|
||||||
|
hostname: str
|
||||||
|
key_type: str
|
||||||
|
fingerprint: str
|
||||||
|
|
||||||
|
|
||||||
|
class KnownHostsStore:
|
||||||
|
def __init__(self, path: Path = KNOWN_HOSTS_FILE):
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
def _load(self) -> paramiko.HostKeys:
|
||||||
|
hk = paramiko.HostKeys()
|
||||||
|
if self.path.exists():
|
||||||
|
try:
|
||||||
|
hk.load(str(self.path))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return hk
|
||||||
|
|
||||||
|
def list_entries(self) -> list[KnownHostEntry]:
|
||||||
|
hk = self._load()
|
||||||
|
entries: list[KnownHostEntry] = []
|
||||||
|
for hostname in hk.keys():
|
||||||
|
for key_type, key in hk[hostname].items():
|
||||||
|
entries.append(KnownHostEntry(
|
||||||
|
hostname=hostname,
|
||||||
|
key_type=key_type,
|
||||||
|
fingerprint=key.get_fingerprint().hex(":"),
|
||||||
|
))
|
||||||
|
entries.sort(key=lambda e: e.hostname)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def remove_host(self, hostname: str) -> None:
|
||||||
|
hk = self._load()
|
||||||
|
if hostname in hk:
|
||||||
|
del hk[hostname]
|
||||||
|
self._save(hk)
|
||||||
|
|
||||||
|
def replace_host_key(self, hostname: str, key: paramiko.PKey) -> None:
|
||||||
|
"""Retire l'ancienne cle d'un hote et enregistre la nouvelle (cas d'une IP DHCP reattribuee)."""
|
||||||
|
hk = self._load()
|
||||||
|
if hostname in hk:
|
||||||
|
del hk[hostname]
|
||||||
|
hk.add(hostname, key.get_name(), key)
|
||||||
|
self._save(hk)
|
||||||
|
|
||||||
|
def clear_all(self) -> None:
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.path.write_text("", encoding="utf-8")
|
||||||
|
|
||||||
|
def _save(self, hk: paramiko.HostKeys) -> None:
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
hk.save(str(self.path))
|
||||||
151
app/local_terminal.py
Normal file
151
app/local_terminal.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""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 queue
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import winpty
|
||||||
|
from PySide6.QtCore import QThread, Signal
|
||||||
|
|
||||||
|
SHELL_POWERSHELL = "powershell"
|
||||||
|
SHELL_CMD = "cmd"
|
||||||
|
|
||||||
|
SHELL_LABELS = {
|
||||||
|
SHELL_POWERSHELL: "PowerShell",
|
||||||
|
SHELL_CMD: "Invite de commandes (cmd)",
|
||||||
|
}
|
||||||
|
SHELL_COMMANDS = {
|
||||||
|
SHELL_POWERSHELL: "powershell.exe -NoLogo",
|
||||||
|
SHELL_CMD: "cmd.exe",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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: 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).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return winpty.PtyProcess.spawn(
|
||||||
|
command,
|
||||||
|
dimensions=(self.rows, self.cols),
|
||||||
|
cwd=self.cwd,
|
||||||
|
backend=winpty.Backend.ConPTY,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return winpty.PtyProcess.spawn(
|
||||||
|
command,
|
||||||
|
dimensions=(self.rows, self.cols),
|
||||||
|
cwd=self.cwd,
|
||||||
|
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
|
||||||
720
app/main_window.py
Normal file
720
app/main_window.py
Normal file
@@ -0,0 +1,720 @@
|
|||||||
|
"""Fenetre principale : arborescence des sessions, onglets terminal/SFTP."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtGui import QAction, QActionGroup, QKeySequence
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QMainWindow, QDockWidget, QTreeWidget, QTreeWidgetItem, QTabWidget, QWidget,
|
||||||
|
QVBoxLayout, QHBoxLayout, QToolButton, QLabel, QMenu, QMessageBox, QSplitter,
|
||||||
|
QStatusBar, QFrame, QApplication,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.session_manager import SessionStore, Session, AUTH_KEY, AUTH_PASSWORD
|
||||||
|
from app.key_manager import KeyManager
|
||||||
|
from app.ssh_connection import SSHConnection
|
||||||
|
from app.local_terminal import LocalTerminalConnection, SHELL_POWERSHELL, SHELL_CMD, SHELL_LABELS
|
||||||
|
from app.terminal_widget import TerminalWidget
|
||||||
|
from app.sftp_panel import SFTPPanel
|
||||||
|
from app.dialogs.session_dialog import SessionDialog
|
||||||
|
from app.dialogs.key_dialog import KeyManagerDialog
|
||||||
|
from app.dialogs.known_hosts_dialog import KnownHostsDialog
|
||||||
|
from app.dialogs.connect_prompt import ask_secret, confirm_host_key, confirm_host_key_change
|
||||||
|
from app.theme import THEME_LABELS, apply_theme, current_theme, save_theme
|
||||||
|
|
||||||
|
SESSION_ROLE = Qt.UserRole + 10
|
||||||
|
LOCAL_SHELL_ROLE = Qt.UserRole + 11
|
||||||
|
|
||||||
|
# Repere une commande ssh tapee a la main dans un terminal, pour proposer le SFTP
|
||||||
|
# sur cet hote sans avoir a creer de favori.
|
||||||
|
SSH_COMMAND_RE = re.compile(r"\bssh\s+(?P<args>[^\r\n|;&]+)")
|
||||||
|
# options ssh qui consomment la valeur suivante (a ne pas confondre avec l'hote)
|
||||||
|
SSH_VALUE_OPTIONS = {
|
||||||
|
"-p", "-i", "-l", "-o", "-F", "-J", "-b", "-c", "-D", "-L", "-R", "-W", "-e", "-m", "-Q",
|
||||||
|
}
|
||||||
|
HOST_RE = re.compile(r"^[A-Za-z0-9][\w.\-]*$")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ssh_args(args: str) -> dict | None:
|
||||||
|
tokens = args.split()
|
||||||
|
user, host, port = "", "", 22
|
||||||
|
index = 0
|
||||||
|
while index < len(tokens):
|
||||||
|
token = tokens[index]
|
||||||
|
if token.startswith("-"):
|
||||||
|
if token in SSH_VALUE_OPTIONS:
|
||||||
|
value = tokens[index + 1] if index + 1 < len(tokens) else ""
|
||||||
|
if token == "-p" and value.isdigit():
|
||||||
|
port = int(value)
|
||||||
|
if token == "-l" and value:
|
||||||
|
user = value
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
if token.startswith("-p") and token[2:].isdigit(): # forme collee : -p2222
|
||||||
|
port = int(token[2:])
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
if not host:
|
||||||
|
candidate = token
|
||||||
|
if "@" in candidate:
|
||||||
|
user, _, candidate = candidate.partition("@")
|
||||||
|
if HOST_RE.match(candidate):
|
||||||
|
host = candidate
|
||||||
|
index += 1
|
||||||
|
return {"user": user, "host": host, "port": port} if host else None
|
||||||
|
|
||||||
|
|
||||||
|
def detect_ssh_target(text: str) -> dict | None:
|
||||||
|
"""Renvoie la derniere cible ssh trouvee dans le texte d'un terminal."""
|
||||||
|
result = None
|
||||||
|
for match in SSH_COMMAND_RE.finditer(text):
|
||||||
|
parsed = _parse_ssh_args(match.group("args"))
|
||||||
|
if parsed:
|
||||||
|
result = parsed
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class BaseTerminalTab(QWidget):
|
||||||
|
"""Base commune a un onglet terminal : barre de statut + terminal, autour d'une
|
||||||
|
connexion (SSH ou locale) qui expose la meme interface de signaux
|
||||||
|
(connected / data_received / disconnected / error / send / resize)."""
|
||||||
|
|
||||||
|
def __init__(self, initial_status: str, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(0)
|
||||||
|
|
||||||
|
self.bar = QHBoxLayout()
|
||||||
|
self.bar.setContentsMargins(10, 6, 10, 6)
|
||||||
|
self.status_label = QLabel(initial_status)
|
||||||
|
self.status_label.setObjectName("statusLabel")
|
||||||
|
self.bar.addWidget(self.status_label, 1)
|
||||||
|
bar_frame = QFrame()
|
||||||
|
bar_frame.setObjectName("tabBar")
|
||||||
|
bar_frame.setLayout(self.bar)
|
||||||
|
layout.addWidget(bar_frame)
|
||||||
|
|
||||||
|
self.splitter = QSplitter(Qt.Horizontal)
|
||||||
|
self.terminal = TerminalWidget()
|
||||||
|
self.splitter.addWidget(self.terminal)
|
||||||
|
layout.addWidget(self.splitter, 1)
|
||||||
|
|
||||||
|
self.connection = None # assigne par les sous-classes
|
||||||
|
|
||||||
|
def _wire_connection(self) -> None:
|
||||||
|
self.connection.connected.connect(self._on_connected)
|
||||||
|
self.connection.data_received.connect(self.terminal.feed)
|
||||||
|
self.connection.disconnected.connect(self._on_disconnected)
|
||||||
|
self.connection.error.connect(self._on_error)
|
||||||
|
self.terminal.data_to_send.connect(self.connection.send)
|
||||||
|
self.terminal.resized.connect(self.connection.resize)
|
||||||
|
|
||||||
|
def _on_connected(self) -> None:
|
||||||
|
self.terminal.setFocus()
|
||||||
|
|
||||||
|
def _on_disconnected(self, reason: str) -> None:
|
||||||
|
self.status_label.setText(f"● Deconnecte ({reason})")
|
||||||
|
|
||||||
|
def _on_error(self, message: str) -> None:
|
||||||
|
self.status_label.setText(f"● Erreur : {message}")
|
||||||
|
QMessageBox.critical(self, "Erreur", message)
|
||||||
|
|
||||||
|
def close_connection(self) -> None:
|
||||||
|
if self.connection:
|
||||||
|
self.connection.close_connection()
|
||||||
|
self.connection.wait(2000)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionTabWidget(BaseTerminalTab):
|
||||||
|
"""Un onglet = une connexion SSH avec son terminal et un panneau SFTP optionnel."""
|
||||||
|
|
||||||
|
def __init__(self, session: Session, secret: str, parent=None):
|
||||||
|
super().__init__(f"○ Connexion a {session.username}@{session.host}...", parent)
|
||||||
|
self.session = session
|
||||||
|
self.sftp_panel: SFTPPanel | None = None
|
||||||
|
|
||||||
|
self.sftp_toggle = QToolButton()
|
||||||
|
self.sftp_toggle.setText("📁 Fichiers (SFTP)")
|
||||||
|
self.sftp_toggle.setToolTip("Afficher / masquer le navigateur de fichiers SFTP")
|
||||||
|
self.sftp_toggle.setCheckable(True)
|
||||||
|
self.sftp_toggle.setChecked(True)
|
||||||
|
self.sftp_toggle.setEnabled(False)
|
||||||
|
self.bar.addWidget(self.sftp_toggle)
|
||||||
|
|
||||||
|
self.connection = SSHConnection(
|
||||||
|
host=session.host,
|
||||||
|
port=session.port,
|
||||||
|
username=session.username,
|
||||||
|
auth_type=session.auth_type,
|
||||||
|
key_path=session.key_path,
|
||||||
|
password=secret if session.auth_type == AUTH_PASSWORD else "",
|
||||||
|
passphrase=secret if session.auth_type == AUTH_KEY else "",
|
||||||
|
# comme MobaXterm : le navigateur de fichiers est la des l'ouverture,
|
||||||
|
# pas cache derriere une action supplementaire
|
||||||
|
open_sftp_on_connect=True,
|
||||||
|
)
|
||||||
|
self._wire_connection()
|
||||||
|
self.connection.sftp_ready.connect(self._on_sftp_ready)
|
||||||
|
self.connection.password_prompt.connect(self._on_password_prompt)
|
||||||
|
self.connection.host_key_prompt.connect(self._on_host_key_prompt)
|
||||||
|
self.connection.host_key_changed_prompt.connect(self._on_host_key_changed_prompt)
|
||||||
|
self.connection.passphrase_prompt.connect(self._on_passphrase_prompt)
|
||||||
|
self.sftp_toggle.toggled.connect(self._toggle_sftp)
|
||||||
|
|
||||||
|
self.connection.start()
|
||||||
|
|
||||||
|
def _on_connected(self) -> None:
|
||||||
|
self.status_label.setText(f"● Connecte : {self.session.username}@{self.session.host}:{self.session.port}")
|
||||||
|
# le bouton n'est active qu'une fois le canal SFTP reellement ouvert
|
||||||
|
# (_on_sftp_ready) : certains serveurs n'exposent pas le sous-systeme SFTP
|
||||||
|
super()._on_connected()
|
||||||
|
|
||||||
|
def _on_disconnected(self, reason: str) -> None:
|
||||||
|
super()._on_disconnected(reason)
|
||||||
|
self.sftp_toggle.setEnabled(False)
|
||||||
|
|
||||||
|
def _on_host_key_prompt(self, hostname: str, key_type: str, fingerprint: str) -> None:
|
||||||
|
accepted = confirm_host_key(self, hostname, key_type, fingerprint)
|
||||||
|
self.connection.confirm_host_key(accepted)
|
||||||
|
|
||||||
|
def _on_host_key_changed_prompt(self, hostname: str, key_type: str, old_fp: str, new_fp: str) -> None:
|
||||||
|
accepted = confirm_host_key_change(self, hostname, key_type, old_fp, new_fp)
|
||||||
|
self.connection.confirm_host_key_change(accepted)
|
||||||
|
|
||||||
|
def _on_passphrase_prompt(self, key_path: str) -> None:
|
||||||
|
text, ok = ask_secret(self, "Passphrase requise", f"Passphrase pour la cle :\n{key_path}")
|
||||||
|
self.connection.provide_passphrase(text if ok else None)
|
||||||
|
|
||||||
|
def _on_password_prompt(self, target: str) -> None:
|
||||||
|
text, ok = ask_secret(self, "Mot de passe", f"Mot de passe pour {target} :")
|
||||||
|
self.connection.provide_password(text if ok else None)
|
||||||
|
|
||||||
|
def _on_sftp_ready(self, sftp) -> None:
|
||||||
|
"""Le canal SFTP a ete ouvert en arriere-plan : on affiche le navigateur de
|
||||||
|
fichiers a cote du terminal, sans action de l'utilisateur."""
|
||||||
|
if self.sftp_panel is not None:
|
||||||
|
return
|
||||||
|
self.sftp_panel = SFTPPanel(sftp)
|
||||||
|
self.splitter.insertWidget(0, self.sftp_panel)
|
||||||
|
self.splitter.setSizes([460, 540])
|
||||||
|
self.sftp_toggle.setEnabled(True)
|
||||||
|
self.sftp_toggle.setChecked(True)
|
||||||
|
self.terminal.setFocus()
|
||||||
|
|
||||||
|
def _toggle_sftp(self, checked: bool) -> None:
|
||||||
|
if self.sftp_panel is not None:
|
||||||
|
self.sftp_panel.setVisible(checked)
|
||||||
|
|
||||||
|
|
||||||
|
class SFTPTabWidget(QWidget):
|
||||||
|
"""Onglet SFTP autonome (facon WinSCP) : double panneau de fichiers, sans terminal."""
|
||||||
|
|
||||||
|
def __init__(self, session: Session, secret: str, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.session = session
|
||||||
|
self.sftp_panel: SFTPPanel | None = None
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(0)
|
||||||
|
|
||||||
|
bar = QHBoxLayout()
|
||||||
|
bar.setContentsMargins(10, 6, 10, 6)
|
||||||
|
self.status_label = QLabel(f"○ Connexion SFTP a {session.username}@{session.host}...")
|
||||||
|
self.status_label.setObjectName("statusLabel")
|
||||||
|
bar.addWidget(self.status_label, 1)
|
||||||
|
bar_frame = QFrame()
|
||||||
|
bar_frame.setObjectName("tabBar")
|
||||||
|
bar_frame.setLayout(bar)
|
||||||
|
layout.addWidget(bar_frame)
|
||||||
|
|
||||||
|
self.container = QVBoxLayout()
|
||||||
|
holder = QWidget()
|
||||||
|
holder.setLayout(self.container)
|
||||||
|
layout.addWidget(holder, 1)
|
||||||
|
|
||||||
|
self.connection = SSHConnection(
|
||||||
|
host=session.host,
|
||||||
|
port=session.port,
|
||||||
|
username=session.username,
|
||||||
|
auth_type=session.auth_type,
|
||||||
|
key_path=session.key_path,
|
||||||
|
password=secret if session.auth_type == AUTH_PASSWORD else "",
|
||||||
|
passphrase=secret if session.auth_type == AUTH_KEY else "",
|
||||||
|
open_shell=False,
|
||||||
|
open_sftp_on_connect=True,
|
||||||
|
)
|
||||||
|
self.connection.sftp_ready.connect(self._on_sftp_ready)
|
||||||
|
self.connection.password_prompt.connect(self._on_password_prompt)
|
||||||
|
self.connection.error.connect(self._on_error)
|
||||||
|
self.connection.disconnected.connect(self._on_disconnected)
|
||||||
|
self.connection.host_key_prompt.connect(self._on_host_key_prompt)
|
||||||
|
self.connection.host_key_changed_prompt.connect(self._on_host_key_changed_prompt)
|
||||||
|
self.connection.passphrase_prompt.connect(self._on_passphrase_prompt)
|
||||||
|
self.connection.start()
|
||||||
|
|
||||||
|
def _on_sftp_ready(self, sftp) -> None:
|
||||||
|
self.sftp_panel = SFTPPanel(sftp)
|
||||||
|
self.container.addWidget(self.sftp_panel)
|
||||||
|
self.status_label.setText(
|
||||||
|
f"● SFTP connecte : {self.session.username}@{self.session.host}:{self.session.port}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_disconnected(self, reason: str) -> None:
|
||||||
|
self.status_label.setText(f"● Deconnecte ({reason})")
|
||||||
|
|
||||||
|
def _on_error(self, message: str) -> None:
|
||||||
|
self.status_label.setText(f"● Erreur : {message}")
|
||||||
|
QMessageBox.critical(self, "Erreur SFTP", message)
|
||||||
|
|
||||||
|
def _on_host_key_prompt(self, hostname: str, key_type: str, fingerprint: str) -> None:
|
||||||
|
self.connection.confirm_host_key(confirm_host_key(self, hostname, key_type, fingerprint))
|
||||||
|
|
||||||
|
def _on_host_key_changed_prompt(self, hostname: str, key_type: str, old_fp: str, new_fp: str) -> None:
|
||||||
|
self.connection.confirm_host_key_change(
|
||||||
|
confirm_host_key_change(self, hostname, key_type, old_fp, new_fp)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_passphrase_prompt(self, key_path: str) -> None:
|
||||||
|
text, ok = ask_secret(self, "Passphrase requise", f"Passphrase pour la cle :\n{key_path}")
|
||||||
|
self.connection.provide_passphrase(text if ok else None)
|
||||||
|
|
||||||
|
def _on_password_prompt(self, target: str) -> None:
|
||||||
|
text, ok = ask_secret(self, "Mot de passe", f"Mot de passe pour {target} :")
|
||||||
|
self.connection.provide_password(text if ok else None)
|
||||||
|
|
||||||
|
def close_connection(self) -> None:
|
||||||
|
self.connection.close_connection()
|
||||||
|
self.connection.wait(2000)
|
||||||
|
|
||||||
|
|
||||||
|
class LocalTerminalTabWidget(BaseTerminalTab):
|
||||||
|
"""Un onglet = un terminal Windows local (PowerShell / cmd), sans session enregistree."""
|
||||||
|
|
||||||
|
sftp_requested = Signal(object) # cible ssh detectee (ou None)
|
||||||
|
|
||||||
|
def __init__(self, shell: str = SHELL_POWERSHELL, parent=None):
|
||||||
|
super().__init__(f"○ Ouverture de {SHELL_LABELS.get(shell, shell)}...", parent)
|
||||||
|
self.shell = shell
|
||||||
|
|
||||||
|
# Meme dans un terminal local, on tape souvent "ssh user@ip" a la main :
|
||||||
|
# ce bouton ouvre le SFTP sur cet hote, sans passer par un favori.
|
||||||
|
self.sftp_btn = QToolButton()
|
||||||
|
self.sftp_btn.setText("📁 SFTP sur cette connexion")
|
||||||
|
self.sftp_btn.setToolTip(
|
||||||
|
"Ouvre un gestionnaire de fichiers SFTP sur l'hote de la commande ssh tapee ici"
|
||||||
|
)
|
||||||
|
self.sftp_btn.clicked.connect(
|
||||||
|
lambda: self.sftp_requested.emit(detect_ssh_target(self.terminal.buffer_text()))
|
||||||
|
)
|
||||||
|
self.bar.addWidget(self.sftp_btn)
|
||||||
|
|
||||||
|
self.connection = LocalTerminalConnection(shell=shell)
|
||||||
|
self._wire_connection()
|
||||||
|
self.connection.start()
|
||||||
|
|
||||||
|
def _on_connected(self) -> None:
|
||||||
|
self.status_label.setText(f"● Terminal local — {SHELL_LABELS.get(self.shell, self.shell)}")
|
||||||
|
super()._on_connected()
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle("PySSH Client")
|
||||||
|
self.resize(1280, 820)
|
||||||
|
|
||||||
|
self.session_store = SessionStore()
|
||||||
|
self.key_manager = KeyManager()
|
||||||
|
|
||||||
|
self._build_menu()
|
||||||
|
self._build_sidebar()
|
||||||
|
self._build_tabs()
|
||||||
|
self.setStatusBar(QStatusBar())
|
||||||
|
|
||||||
|
self.reload_sessions()
|
||||||
|
|
||||||
|
# demarre directement dans un terminal utilisable, sans avoir a en choisir un
|
||||||
|
self.open_local_terminal(SHELL_POWERSHELL)
|
||||||
|
|
||||||
|
# ---------- construction UI ----------
|
||||||
|
def _build_menu(self) -> None:
|
||||||
|
file_menu = self.menuBar().addMenu("&Fichier")
|
||||||
|
new_session_action = QAction("Nouvelle session...", self)
|
||||||
|
new_session_action.setShortcut(QKeySequence("Ctrl+N"))
|
||||||
|
new_session_action.triggered.connect(self.new_session)
|
||||||
|
file_menu.addAction(new_session_action)
|
||||||
|
|
||||||
|
file_menu.addSeparator()
|
||||||
|
|
||||||
|
quick_ssh_action = QAction("Connexion SSH rapide...", self)
|
||||||
|
quick_ssh_action.setShortcut(QKeySequence("Ctrl+Shift+S"))
|
||||||
|
quick_ssh_action.triggered.connect(self.quick_ssh)
|
||||||
|
file_menu.addAction(quick_ssh_action)
|
||||||
|
|
||||||
|
quick_sftp_action = QAction("Connexion SFTP (gestionnaire de fichiers)...", self)
|
||||||
|
quick_sftp_action.setShortcut(QKeySequence("Ctrl+Shift+F"))
|
||||||
|
quick_sftp_action.triggered.connect(self.quick_sftp)
|
||||||
|
file_menu.addAction(quick_sftp_action)
|
||||||
|
|
||||||
|
file_menu.addSeparator()
|
||||||
|
|
||||||
|
new_ps_action = QAction("Nouveau terminal local (PowerShell)", self)
|
||||||
|
new_ps_action.setShortcut(QKeySequence("Ctrl+Shift+T"))
|
||||||
|
new_ps_action.triggered.connect(lambda: self.open_local_terminal(SHELL_POWERSHELL))
|
||||||
|
file_menu.addAction(new_ps_action)
|
||||||
|
|
||||||
|
new_cmd_action = QAction("Nouveau terminal local (Invite de commandes)", self)
|
||||||
|
new_cmd_action.triggered.connect(lambda: self.open_local_terminal(SHELL_CMD))
|
||||||
|
file_menu.addAction(new_cmd_action)
|
||||||
|
|
||||||
|
file_menu.addSeparator()
|
||||||
|
quit_action = QAction("Quitter", self)
|
||||||
|
quit_action.triggered.connect(self.close)
|
||||||
|
file_menu.addAction(quit_action)
|
||||||
|
|
||||||
|
keys_menu = self.menuBar().addMenu("&Cles SSH")
|
||||||
|
manage_keys_action = QAction("Gestionnaire de cles...", self)
|
||||||
|
manage_keys_action.triggered.connect(self.open_key_manager)
|
||||||
|
keys_menu.addAction(manage_keys_action)
|
||||||
|
|
||||||
|
view_menu = self.menuBar().addMenu("&Affichage")
|
||||||
|
theme_menu = view_menu.addMenu("Theme")
|
||||||
|
self.theme_group = QActionGroup(self)
|
||||||
|
self.theme_group.setExclusive(True)
|
||||||
|
for theme_id, label in THEME_LABELS.items():
|
||||||
|
action = QAction(label, self, checkable=True)
|
||||||
|
action.setChecked(theme_id == current_theme())
|
||||||
|
action.triggered.connect(lambda _checked=False, t=theme_id: self.change_theme(t))
|
||||||
|
self.theme_group.addAction(action)
|
||||||
|
theme_menu.addAction(action)
|
||||||
|
|
||||||
|
hosts_menu = self.menuBar().addMenu("&Hotes connus")
|
||||||
|
manage_hosts_action = QAction("Gestionnaire des hotes connus...", self)
|
||||||
|
manage_hosts_action.triggered.connect(self.open_known_hosts_manager)
|
||||||
|
hosts_menu.addAction(manage_hosts_action)
|
||||||
|
|
||||||
|
help_menu = self.menuBar().addMenu("&Aide")
|
||||||
|
about_action = QAction("A propos", self)
|
||||||
|
about_action.triggered.connect(self.show_about)
|
||||||
|
help_menu.addAction(about_action)
|
||||||
|
|
||||||
|
def _build_sidebar(self) -> None:
|
||||||
|
dock = QDockWidget("Sessions", self)
|
||||||
|
dock.setFeatures(QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable)
|
||||||
|
|
||||||
|
container = QWidget()
|
||||||
|
layout = QVBoxLayout(container)
|
||||||
|
layout.setContentsMargins(6, 6, 6, 6)
|
||||||
|
layout.setSpacing(6)
|
||||||
|
|
||||||
|
toolbar = QHBoxLayout()
|
||||||
|
new_btn = QToolButton()
|
||||||
|
new_btn.setObjectName("primary")
|
||||||
|
new_btn.setText("+ Session")
|
||||||
|
new_btn.clicked.connect(self.new_session)
|
||||||
|
toolbar.addWidget(new_btn)
|
||||||
|
|
||||||
|
term_btn = QToolButton()
|
||||||
|
term_btn.setText("💻 Terminal")
|
||||||
|
term_btn.setPopupMode(QToolButton.InstantPopup)
|
||||||
|
term_menu = QMenu(term_btn)
|
||||||
|
term_menu.addAction("PowerShell", lambda: self.open_local_terminal(SHELL_POWERSHELL))
|
||||||
|
term_menu.addAction("Invite de commandes (cmd)", lambda: self.open_local_terminal(SHELL_CMD))
|
||||||
|
term_btn.setMenu(term_menu)
|
||||||
|
toolbar.addWidget(term_btn)
|
||||||
|
|
||||||
|
sftp_btn = QToolButton()
|
||||||
|
sftp_btn.setText("📁 SFTP")
|
||||||
|
sftp_btn.setToolTip("Ouvrir un gestionnaire de fichiers SFTP (sans session enregistree)")
|
||||||
|
sftp_btn.clicked.connect(self.quick_sftp)
|
||||||
|
toolbar.addWidget(sftp_btn)
|
||||||
|
|
||||||
|
toolbar.addStretch()
|
||||||
|
|
||||||
|
manage_btn = QToolButton()
|
||||||
|
manage_btn.setText("⚙")
|
||||||
|
manage_btn.setToolTip("Cles SSH et hotes connus")
|
||||||
|
manage_btn.setPopupMode(QToolButton.InstantPopup)
|
||||||
|
manage_menu = QMenu(manage_btn)
|
||||||
|
manage_menu.addAction("Gestionnaire de cles SSH...", self.open_key_manager)
|
||||||
|
manage_menu.addAction("Gestionnaire des hotes connus...", self.open_known_hosts_manager)
|
||||||
|
manage_btn.setMenu(manage_menu)
|
||||||
|
toolbar.addWidget(manage_btn)
|
||||||
|
|
||||||
|
layout.addLayout(toolbar)
|
||||||
|
|
||||||
|
self.tree = QTreeWidget()
|
||||||
|
self.tree.setHeaderHidden(True)
|
||||||
|
self.tree.itemDoubleClicked.connect(self._on_tree_double_click)
|
||||||
|
self.tree.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||||
|
self.tree.customContextMenuRequested.connect(self._on_tree_context_menu)
|
||||||
|
layout.addWidget(self.tree)
|
||||||
|
|
||||||
|
dock.setWidget(container)
|
||||||
|
self.addDockWidget(Qt.LeftDockWidgetArea, dock)
|
||||||
|
|
||||||
|
def _build_tabs(self) -> None:
|
||||||
|
self.tabs = QTabWidget()
|
||||||
|
self.tabs.setTabsClosable(True)
|
||||||
|
self.tabs.setMovable(True)
|
||||||
|
self.tabs.tabCloseRequested.connect(self._on_tab_close)
|
||||||
|
self.setCentralWidget(self.tabs)
|
||||||
|
|
||||||
|
# ---------- sessions / arbre ----------
|
||||||
|
def reload_sessions(self) -> None:
|
||||||
|
self.tree.clear()
|
||||||
|
|
||||||
|
locals_header = QTreeWidgetItem(["💻 Terminaux locaux"])
|
||||||
|
locals_header.setFlags(locals_header.flags() & ~Qt.ItemIsSelectable)
|
||||||
|
font = locals_header.font(0)
|
||||||
|
font.setBold(True)
|
||||||
|
locals_header.setFont(0, font)
|
||||||
|
self.tree.addTopLevelItem(locals_header)
|
||||||
|
for shell, label in SHELL_LABELS.items():
|
||||||
|
child = QTreeWidgetItem([f" {label}"])
|
||||||
|
child.setData(0, LOCAL_SHELL_ROLE, shell)
|
||||||
|
locals_header.addChild(child)
|
||||||
|
|
||||||
|
sessions_header = QTreeWidgetItem(["🔐 Sessions SSH"])
|
||||||
|
sessions_header.setFlags(sessions_header.flags() & ~Qt.ItemIsSelectable)
|
||||||
|
sessions_header.setFont(0, font)
|
||||||
|
self.tree.addTopLevelItem(sessions_header)
|
||||||
|
|
||||||
|
folder_items: dict[str, QTreeWidgetItem] = {}
|
||||||
|
|
||||||
|
def get_folder(path: str) -> QTreeWidgetItem:
|
||||||
|
if not path:
|
||||||
|
return sessions_header
|
||||||
|
if path in folder_items:
|
||||||
|
return folder_items[path]
|
||||||
|
parent_path, _, name = path.rpartition("/")
|
||||||
|
parent_item = get_folder(parent_path)
|
||||||
|
item = QTreeWidgetItem([f"📁 {name}"])
|
||||||
|
item.setFlags(item.flags() & ~Qt.ItemIsSelectable)
|
||||||
|
parent_item.addChild(item)
|
||||||
|
folder_items[path] = item
|
||||||
|
return item
|
||||||
|
|
||||||
|
for session in sorted(self.session_store.sessions, key=lambda s: s.name.lower()):
|
||||||
|
parent = get_folder(session.group)
|
||||||
|
item = QTreeWidgetItem([f"🖥 {session.name} ({session.host})"])
|
||||||
|
item.setData(0, SESSION_ROLE, session.id)
|
||||||
|
parent.addChild(item)
|
||||||
|
|
||||||
|
if not self.session_store.sessions:
|
||||||
|
hint = QTreeWidgetItem([" (aucune session — bouton « + Session » ou « 📁 SFTP »)"])
|
||||||
|
hint.setFlags(hint.flags() & ~Qt.ItemIsSelectable)
|
||||||
|
hint.setForeground(0, Qt.gray)
|
||||||
|
sessions_header.addChild(hint)
|
||||||
|
|
||||||
|
self.tree.expandAll()
|
||||||
|
|
||||||
|
def _session_from_item(self, item: QTreeWidgetItem) -> Session | None:
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
session_id = item.data(0, SESSION_ROLE)
|
||||||
|
if not session_id:
|
||||||
|
return None
|
||||||
|
return next((s for s in self.session_store.sessions if s.id == session_id), None)
|
||||||
|
|
||||||
|
def _shell_from_item(self, item: QTreeWidgetItem) -> str | None:
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
return item.data(0, LOCAL_SHELL_ROLE)
|
||||||
|
|
||||||
|
def _on_tree_double_click(self, item: QTreeWidgetItem, _col: int) -> None:
|
||||||
|
session = self._session_from_item(item)
|
||||||
|
if session:
|
||||||
|
self.open_session(session)
|
||||||
|
return
|
||||||
|
shell = self._shell_from_item(item)
|
||||||
|
if shell:
|
||||||
|
self.open_local_terminal(shell)
|
||||||
|
|
||||||
|
def _on_tree_context_menu(self, pos) -> None:
|
||||||
|
item = self.tree.itemAt(pos)
|
||||||
|
menu = QMenu(self)
|
||||||
|
session = self._session_from_item(item) if item else None
|
||||||
|
shell = self._shell_from_item(item) if item else None
|
||||||
|
|
||||||
|
if session:
|
||||||
|
connect_action = menu.addAction("🖥 Ouvrir un terminal SSH")
|
||||||
|
sftp_action = menu.addAction("📁 Ouvrir en SFTP (gestionnaire de fichiers)")
|
||||||
|
menu.addSeparator()
|
||||||
|
edit_action = menu.addAction("Modifier...")
|
||||||
|
duplicate_action = menu.addAction("Dupliquer")
|
||||||
|
delete_action = menu.addAction("Supprimer")
|
||||||
|
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
|
||||||
|
if action == connect_action:
|
||||||
|
self.open_session(session)
|
||||||
|
elif action == sftp_action:
|
||||||
|
self.open_sftp_session(session)
|
||||||
|
elif action == edit_action:
|
||||||
|
self.edit_session(session)
|
||||||
|
elif action == duplicate_action:
|
||||||
|
self.duplicate_session(session)
|
||||||
|
elif action == delete_action:
|
||||||
|
self.delete_session(session)
|
||||||
|
elif shell:
|
||||||
|
open_action = menu.addAction("Ouvrir")
|
||||||
|
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
|
||||||
|
if action == open_action:
|
||||||
|
self.open_local_terminal(shell)
|
||||||
|
else:
|
||||||
|
new_action = menu.addAction("Nouvelle session...")
|
||||||
|
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
|
||||||
|
if action == new_action:
|
||||||
|
self.new_session()
|
||||||
|
|
||||||
|
def new_session(self) -> None:
|
||||||
|
dialog = SessionDialog(self.key_manager, parent=self)
|
||||||
|
if dialog.exec():
|
||||||
|
self.session_store.add(dialog.result_session())
|
||||||
|
self.reload_sessions()
|
||||||
|
|
||||||
|
def edit_session(self, session: Session) -> None:
|
||||||
|
dialog = SessionDialog(self.key_manager, session=session, parent=self)
|
||||||
|
if dialog.exec():
|
||||||
|
self.session_store.update(dialog.result_session())
|
||||||
|
self.reload_sessions()
|
||||||
|
|
||||||
|
def duplicate_session(self, session: Session) -> None:
|
||||||
|
new_session = copy.deepcopy(session)
|
||||||
|
new_session.id = str(uuid.uuid4())
|
||||||
|
new_session.name = f"{session.name} (copie)"
|
||||||
|
self.session_store.add(new_session)
|
||||||
|
self.reload_sessions()
|
||||||
|
|
||||||
|
def delete_session(self, session: Session) -> None:
|
||||||
|
if QMessageBox.question(self, "Supprimer", f"Supprimer la session '{session.name}' ?") == QMessageBox.Yes:
|
||||||
|
self.session_store.delete(session.id)
|
||||||
|
self.reload_sessions()
|
||||||
|
|
||||||
|
# ---------- connexion / onglets ----------
|
||||||
|
def open_session(self, session: Session) -> None:
|
||||||
|
secret = ""
|
||||||
|
if session.auth_type == AUTH_PASSWORD:
|
||||||
|
text, ok = ask_secret(self, "Mot de passe", f"Mot de passe pour {session.username}@{session.host} :")
|
||||||
|
if not ok:
|
||||||
|
return
|
||||||
|
secret = text
|
||||||
|
|
||||||
|
tab = SessionTabWidget(session, secret)
|
||||||
|
index = self.tabs.addTab(tab, f"🖥 {session.name}")
|
||||||
|
self.tabs.setCurrentIndex(index)
|
||||||
|
|
||||||
|
def quick_sftp(self, target: dict | None = None) -> None:
|
||||||
|
"""Ouvre un SFTP en saisissant directement les infos de connexion, sans favori.
|
||||||
|
|
||||||
|
Si l'onglet courant est un terminal ou une commande ssh a ete tapee, l'hote,
|
||||||
|
l'utilisateur et le port sont pre-remplis automatiquement."""
|
||||||
|
if target is None:
|
||||||
|
target = self._current_tab_ssh_target()
|
||||||
|
self._quick_connect(sftp=True, target=target)
|
||||||
|
|
||||||
|
def quick_ssh(self) -> None:
|
||||||
|
self._quick_connect(sftp=False, target=self._current_tab_ssh_target())
|
||||||
|
|
||||||
|
def _current_tab_ssh_target(self) -> dict | None:
|
||||||
|
widget = self.tabs.currentWidget()
|
||||||
|
if isinstance(widget, SessionTabWidget):
|
||||||
|
return {
|
||||||
|
"user": widget.session.username,
|
||||||
|
"host": widget.session.host,
|
||||||
|
"port": widget.session.port,
|
||||||
|
}
|
||||||
|
if isinstance(widget, BaseTerminalTab):
|
||||||
|
return detect_ssh_target(widget.terminal.buffer_text())
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _quick_connect(self, sftp: bool, target: dict | None = None) -> None:
|
||||||
|
prefill = None
|
||||||
|
if target:
|
||||||
|
prefill = Session(
|
||||||
|
name=target.get("host", ""),
|
||||||
|
host=target.get("host", ""),
|
||||||
|
username=target.get("user", ""),
|
||||||
|
port=target.get("port", 22),
|
||||||
|
)
|
||||||
|
dialog = SessionDialog(
|
||||||
|
self.key_manager,
|
||||||
|
session=prefill,
|
||||||
|
parent=self,
|
||||||
|
connect_label="Se connecter",
|
||||||
|
title="Connexion SFTP" if sftp else "Connexion SSH",
|
||||||
|
)
|
||||||
|
if not dialog.exec():
|
||||||
|
return
|
||||||
|
session = dialog.result_session()
|
||||||
|
if dialog.should_save():
|
||||||
|
self.session_store.add(session)
|
||||||
|
self.reload_sessions()
|
||||||
|
if sftp:
|
||||||
|
self.open_sftp_session(session)
|
||||||
|
else:
|
||||||
|
self.open_session(session)
|
||||||
|
|
||||||
|
def open_sftp_session(self, session: Session) -> None:
|
||||||
|
"""Ouvre un onglet SFTP autonome (double panneau) pour cette session."""
|
||||||
|
secret = ""
|
||||||
|
if session.auth_type == AUTH_PASSWORD:
|
||||||
|
text, ok = ask_secret(self, "Mot de passe", f"Mot de passe pour {session.username}@{session.host} :")
|
||||||
|
if not ok:
|
||||||
|
return
|
||||||
|
secret = text
|
||||||
|
|
||||||
|
tab = SFTPTabWidget(session, secret)
|
||||||
|
index = self.tabs.addTab(tab, f"📁 {session.name}")
|
||||||
|
self.tabs.setCurrentIndex(index)
|
||||||
|
|
||||||
|
def open_local_terminal(self, shell: str = SHELL_POWERSHELL) -> None:
|
||||||
|
tab = LocalTerminalTabWidget(shell)
|
||||||
|
tab.sftp_requested.connect(self.quick_sftp)
|
||||||
|
label = "PowerShell" if shell == SHELL_POWERSHELL else "cmd"
|
||||||
|
index = self.tabs.addTab(tab, f"💻 {label}")
|
||||||
|
self.tabs.setCurrentIndex(index)
|
||||||
|
|
||||||
|
def _on_tab_close(self, index: int) -> None:
|
||||||
|
widget = self.tabs.widget(index)
|
||||||
|
if isinstance(widget, (BaseTerminalTab, SFTPTabWidget)):
|
||||||
|
widget.close_connection()
|
||||||
|
self.tabs.removeTab(index)
|
||||||
|
|
||||||
|
# ---------- cles / a propos ----------
|
||||||
|
def change_theme(self, theme: str) -> None:
|
||||||
|
"""Applique un theme a chaud : feuille de style de l'interface + couleurs des
|
||||||
|
terminaux deja ouverts (qui ont leur propre palette, hors QSS)."""
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is None:
|
||||||
|
return
|
||||||
|
apply_theme(app, theme)
|
||||||
|
save_theme(theme)
|
||||||
|
for widget in self.findChildren(TerminalWidget):
|
||||||
|
widget.refresh_theme()
|
||||||
|
|
||||||
|
def open_key_manager(self) -> None:
|
||||||
|
dialog = KeyManagerDialog(self.key_manager, parent=self)
|
||||||
|
dialog.exec()
|
||||||
|
|
||||||
|
def open_known_hosts_manager(self) -> None:
|
||||||
|
dialog = KnownHostsDialog(parent=self)
|
||||||
|
dialog.exec()
|
||||||
|
|
||||||
|
def show_about(self) -> None:
|
||||||
|
QMessageBox.information(
|
||||||
|
self, "A propos",
|
||||||
|
"PySSH Client\n\nClient SSH/SFTP local avec gestion de sessions, de cles, "
|
||||||
|
"et terminaux locaux integres.\nBase sur PySide6, paramiko, pywinpty et pyte.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def closeEvent(self, event) -> None:
|
||||||
|
for i in range(self.tabs.count()):
|
||||||
|
widget = self.tabs.widget(i)
|
||||||
|
if isinstance(widget, (BaseTerminalTab, SFTPTabWidget)):
|
||||||
|
widget.close_connection()
|
||||||
|
super().closeEvent(event)
|
||||||
49
app/paths.py
Normal file
49
app/paths.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""Emplacements des donnees et des ressources, valables en mode script comme en .exe.
|
||||||
|
|
||||||
|
Point d'attention : une fois l'application figee par PyInstaller, `__file__` pointe vers
|
||||||
|
un dossier temporaire (`sys._MEIPASS`) que Windows supprime a la fermeture. Y ecrire les
|
||||||
|
sessions, les cles ou known_hosts reviendrait a les perdre a chaque sortie. Les donnees
|
||||||
|
utilisateur vont donc a cote de l'executable (mode portable), avec repli sur %APPDATA%
|
||||||
|
si cet emplacement n'est pas accessible en ecriture (installation dans Program Files,
|
||||||
|
lancement depuis un support en lecture seule...).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FROZEN = getattr(sys, "frozen", False)
|
||||||
|
|
||||||
|
|
||||||
|
def _app_dir() -> Path:
|
||||||
|
"""Dossier de l'executable (mode figé) ou racine du projet (mode script)."""
|
||||||
|
if FROZEN:
|
||||||
|
return Path(sys.executable).resolve().parent
|
||||||
|
return Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_resources() -> Path:
|
||||||
|
if FROZEN:
|
||||||
|
bundled = getattr(sys, "_MEIPASS", None)
|
||||||
|
if bundled: # onefile : ressources extraites dans un dossier temporaire
|
||||||
|
return Path(bundled) / "resources"
|
||||||
|
return _app_dir() / "resources"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_data() -> Path:
|
||||||
|
candidate = _app_dir() / "data"
|
||||||
|
try:
|
||||||
|
candidate.mkdir(parents=True, exist_ok=True)
|
||||||
|
probe = candidate / ".write_test"
|
||||||
|
probe.write_text("", encoding="utf-8")
|
||||||
|
probe.unlink()
|
||||||
|
return candidate
|
||||||
|
except OSError:
|
||||||
|
fallback = Path(os.environ.get("APPDATA") or Path.home()) / "PySSHClient"
|
||||||
|
fallback.mkdir(parents=True, exist_ok=True)
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
RESOURCES_DIR = _resolve_resources()
|
||||||
|
DATA_DIR = _resolve_data()
|
||||||
83
app/session_manager.py
Normal file
83
app/session_manager.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""Stockage des sessions SSH (serveurs enregistres) dans un fichier JSON local."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.paths import DATA_DIR # noqa: E402 (chemins valables aussi en .exe)
|
||||||
|
SESSIONS_FILE = DATA_DIR / "sessions.json"
|
||||||
|
|
||||||
|
AUTH_AUTO = "auto" # comme la commande ssh : agent + cles ~/.ssh, mot de passe en secours
|
||||||
|
AUTH_KEY = "key" # cle privee explicitement choisie
|
||||||
|
AUTH_PASSWORD = "password"
|
||||||
|
AUTH_AGENT = "agent"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Session:
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
name: str = ""
|
||||||
|
group: str = "" # ex: "Prod/Web" -- sert a construire l'arborescence de dossiers
|
||||||
|
host: str = ""
|
||||||
|
port: int = 22
|
||||||
|
username: str = ""
|
||||||
|
auth_type: str = AUTH_AUTO # auto | key | password | agent
|
||||||
|
key_path: str = ""
|
||||||
|
notes: str = ""
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(data: dict) -> "Session":
|
||||||
|
known = {f: data.get(f) for f in Session.__dataclass_fields__ if f in data}
|
||||||
|
s = Session()
|
||||||
|
for k, v in known.items():
|
||||||
|
setattr(s, k, v)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
class SessionStore:
|
||||||
|
"""Charge/sauvegarde la liste des sessions depuis data/sessions.json."""
|
||||||
|
|
||||||
|
def __init__(self, path: Path = SESSIONS_FILE):
|
||||||
|
self.path = path
|
||||||
|
self.sessions: list[Session] = []
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
def load(self) -> None:
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not self.path.exists():
|
||||||
|
self.sessions = []
|
||||||
|
self.save()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
raw = {"sessions": []}
|
||||||
|
self.sessions = [Session.from_dict(s) for s in raw.get("sessions", [])]
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = {"sessions": [s.to_dict() for s in self.sessions]}
|
||||||
|
self.path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
|
||||||
|
def add(self, session: Session) -> None:
|
||||||
|
self.sessions.append(session)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def update(self, session: Session) -> None:
|
||||||
|
for i, s in enumerate(self.sessions):
|
||||||
|
if s.id == session.id:
|
||||||
|
self.sessions[i] = session
|
||||||
|
break
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def delete(self, session_id: str) -> None:
|
||||||
|
self.sessions = [s for s in self.sessions if s.id != session_id]
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def groups(self) -> list[str]:
|
||||||
|
return sorted({s.group for s in self.sessions if s.group})
|
||||||
673
app/sftp_panel.py
Normal file
673
app/sftp_panel.py
Normal file
@@ -0,0 +1,673 @@
|
|||||||
|
"""Client SFTP integre facon WinSCP : double panneau, colonnes triables, glisser-deposer,
|
||||||
|
renommage, permissions, et file de transferts avec progression globale."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import posixpath
|
||||||
|
import stat
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
from PySide6.QtCore import Qt, QThread, Signal, QMimeData, QPoint
|
||||||
|
from PySide6.QtGui import QDrag, QKeySequence, QShortcut
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QWidget, QVBoxLayout, QHBoxLayout, QLineEdit, QToolButton, QTreeWidget,
|
||||||
|
QTreeWidgetItem, QSplitter, QLabel, QMessageBox, QInputDialog, QProgressDialog,
|
||||||
|
QAbstractItemView, QMenu, QHeaderView,
|
||||||
|
)
|
||||||
|
|
||||||
|
MIME_TYPE = "application/x-pyssh-files"
|
||||||
|
|
||||||
|
NAME_ROLE = Qt.UserRole
|
||||||
|
ISDIR_ROLE = Qt.UserRole + 1
|
||||||
|
SIZE_ROLE = Qt.UserRole + 2
|
||||||
|
MTIME_ROLE = Qt.UserRole + 3
|
||||||
|
PARENT_ROLE = Qt.UserRole + 4 # marque l'entree ".."
|
||||||
|
|
||||||
|
|
||||||
|
def human_size(n: int) -> str:
|
||||||
|
value = float(n)
|
||||||
|
for unit in ("o", "Ko", "Mo", "Go", "To"):
|
||||||
|
if value < 1024:
|
||||||
|
return f"{value:.0f} {unit}" if unit == "o" else f"{value:.1f} {unit}"
|
||||||
|
value /= 1024
|
||||||
|
return f"{value:.1f} Po"
|
||||||
|
|
||||||
|
|
||||||
|
def format_time(ts: float) -> str:
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(ts).strftime("%d/%m/%Y %H:%M")
|
||||||
|
except (OSError, ValueError, OverflowError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Transferts
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
class TransferJob:
|
||||||
|
def __init__(self, direction: str, local_path: str, remote_path: str, is_dir: bool):
|
||||||
|
self.direction = direction # "upload" | "download"
|
||||||
|
self.local_path = local_path
|
||||||
|
self.remote_path = remote_path
|
||||||
|
self.is_dir = is_dir
|
||||||
|
|
||||||
|
|
||||||
|
class TransferWorker(QThread):
|
||||||
|
"""Execute une file de transferts sequentiellement, dans son propre thread."""
|
||||||
|
|
||||||
|
file_started = Signal(str, int, int) # nom, index, total
|
||||||
|
file_progress = Signal(int, int) # octets transferes, taille totale
|
||||||
|
finished_all = Signal(int, list) # nb reussis, liste d'erreurs
|
||||||
|
|
||||||
|
def __init__(self, sftp: paramiko.SFTPClient, jobs: list[TransferJob]):
|
||||||
|
super().__init__()
|
||||||
|
self.sftp = sftp
|
||||||
|
self.jobs = jobs
|
||||||
|
self._cancel = False
|
||||||
|
self._done = 0
|
||||||
|
self._errors: list[str] = []
|
||||||
|
|
||||||
|
def cancel(self) -> None:
|
||||||
|
self._cancel = True
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
total = len(self.jobs)
|
||||||
|
for index, job in enumerate(self.jobs, start=1):
|
||||||
|
if self._cancel:
|
||||||
|
break
|
||||||
|
label = os.path.basename(job.local_path) or job.local_path
|
||||||
|
self.file_started.emit(label, index, total)
|
||||||
|
try:
|
||||||
|
if job.direction == "upload":
|
||||||
|
if job.is_dir:
|
||||||
|
self._upload_dir(job.local_path, job.remote_path)
|
||||||
|
else:
|
||||||
|
self.sftp.put(job.local_path, job.remote_path, callback=self._cb)
|
||||||
|
else:
|
||||||
|
if job.is_dir:
|
||||||
|
self._download_dir(job.remote_path, job.local_path)
|
||||||
|
else:
|
||||||
|
self.sftp.get(job.remote_path, job.local_path, callback=self._cb)
|
||||||
|
self._done += 1
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._errors.append(f"{label} : {exc}")
|
||||||
|
self.finished_all.emit(self._done, self._errors)
|
||||||
|
|
||||||
|
def _cb(self, transferred: int, total: int) -> None:
|
||||||
|
self.file_progress.emit(transferred, total)
|
||||||
|
|
||||||
|
def _upload_dir(self, local_dir: str, remote_dir: str) -> None:
|
||||||
|
try:
|
||||||
|
self.sftp.mkdir(remote_dir)
|
||||||
|
except IOError:
|
||||||
|
pass
|
||||||
|
for entry in os.listdir(local_dir):
|
||||||
|
if self._cancel:
|
||||||
|
return
|
||||||
|
lp = os.path.join(local_dir, entry)
|
||||||
|
rp = posixpath.join(remote_dir, entry)
|
||||||
|
if os.path.isdir(lp):
|
||||||
|
self._upload_dir(lp, rp)
|
||||||
|
else:
|
||||||
|
self.sftp.put(lp, rp, callback=self._cb)
|
||||||
|
|
||||||
|
def _download_dir(self, remote_dir: str, local_dir: str) -> None:
|
||||||
|
Path(local_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
for attr in self.sftp.listdir_attr(remote_dir):
|
||||||
|
if self._cancel:
|
||||||
|
return
|
||||||
|
rp = posixpath.join(remote_dir, attr.filename)
|
||||||
|
lp = os.path.join(local_dir, attr.filename)
|
||||||
|
if stat.S_ISDIR(attr.st_mode):
|
||||||
|
self._download_dir(rp, lp)
|
||||||
|
else:
|
||||||
|
self.sftp.get(rp, lp, callback=self._cb)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Panneaux de fichiers
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
class FileItem(QTreeWidgetItem):
|
||||||
|
"""Ligne de fichier avec le tri attendu d'un gestionnaire de fichiers :
|
||||||
|
".." toujours en tete, puis les dossiers, puis les fichiers ; taille et date
|
||||||
|
comparees numeriquement (et non comme du texte)."""
|
||||||
|
|
||||||
|
SORT_VALUE_ROLE = Qt.UserRole + 10
|
||||||
|
|
||||||
|
def __lt__(self, other: QTreeWidgetItem) -> bool:
|
||||||
|
tree = self.treeWidget()
|
||||||
|
column = tree.sortColumn() if tree else 0
|
||||||
|
descending = bool(
|
||||||
|
tree and tree.header().sortIndicatorOrder() == Qt.DescendingOrder
|
||||||
|
)
|
||||||
|
|
||||||
|
def pinned(result: bool) -> bool:
|
||||||
|
# Qt inverse le resultat en tri descendant : on le compense pour que
|
||||||
|
# ".." et les dossiers gardent leur place quel que soit le sens du tri.
|
||||||
|
return not result if descending else result
|
||||||
|
|
||||||
|
self_parent = bool(self.data(0, PARENT_ROLE))
|
||||||
|
other_parent = bool(other.data(0, PARENT_ROLE))
|
||||||
|
if self_parent != other_parent:
|
||||||
|
return pinned(self_parent)
|
||||||
|
|
||||||
|
self_dir = bool(self.data(0, ISDIR_ROLE))
|
||||||
|
other_dir = bool(other.data(0, ISDIR_ROLE))
|
||||||
|
if self_dir != other_dir:
|
||||||
|
return pinned(self_dir)
|
||||||
|
|
||||||
|
self_value = self.data(column, self.SORT_VALUE_ROLE)
|
||||||
|
other_value = other.data(column, self.SORT_VALUE_ROLE)
|
||||||
|
if self_value is not None and other_value is not None:
|
||||||
|
return self_value < other_value
|
||||||
|
return self.text(column).lower() < other.text(column).lower()
|
||||||
|
|
||||||
|
|
||||||
|
class FileTree(QTreeWidget):
|
||||||
|
"""Arbre de fichiers avec glisser-deposer entre panneaux."""
|
||||||
|
|
||||||
|
files_dropped = Signal(dict)
|
||||||
|
|
||||||
|
def __init__(self, side: str, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.side = side
|
||||||
|
self.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||||
|
self.setRootIsDecorated(False)
|
||||||
|
self.setSortingEnabled(True)
|
||||||
|
self.setDragEnabled(True)
|
||||||
|
self.setAcceptDrops(True)
|
||||||
|
self.setDropIndicatorShown(True)
|
||||||
|
self.setDragDropMode(QAbstractItemView.DragDrop)
|
||||||
|
self.setHeaderLabels(["Nom", "Taille", "Modifie", "Droits"])
|
||||||
|
# sans tri initial explicite, les lignes resteraient dans l'ordre d'insertion
|
||||||
|
self.sortByColumn(0, Qt.AscendingOrder)
|
||||||
|
self.header().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||||
|
self.setColumnWidth(1, 90)
|
||||||
|
self.setColumnWidth(2, 130)
|
||||||
|
self.setColumnWidth(3, 100)
|
||||||
|
|
||||||
|
def startDrag(self, actions) -> None:
|
||||||
|
entries = []
|
||||||
|
for item in self.selectedItems():
|
||||||
|
if item.data(0, PARENT_ROLE):
|
||||||
|
continue
|
||||||
|
entries.append({"name": item.data(0, NAME_ROLE), "is_dir": bool(item.data(0, ISDIR_ROLE))})
|
||||||
|
if not entries:
|
||||||
|
return
|
||||||
|
payload = {"side": self.side, "entries": entries}
|
||||||
|
mime = QMimeData()
|
||||||
|
mime.setData(MIME_TYPE, json.dumps(payload).encode("utf-8"))
|
||||||
|
drag = QDrag(self)
|
||||||
|
drag.setMimeData(mime)
|
||||||
|
drag.exec(Qt.CopyAction)
|
||||||
|
|
||||||
|
def dragEnterEvent(self, event) -> None:
|
||||||
|
if event.mimeData().hasFormat(MIME_TYPE):
|
||||||
|
event.acceptProposedAction()
|
||||||
|
|
||||||
|
def dragMoveEvent(self, event) -> None:
|
||||||
|
if event.mimeData().hasFormat(MIME_TYPE):
|
||||||
|
event.acceptProposedAction()
|
||||||
|
|
||||||
|
def dropEvent(self, event) -> None:
|
||||||
|
if not event.mimeData().hasFormat(MIME_TYPE):
|
||||||
|
return
|
||||||
|
payload = json.loads(bytes(event.mimeData().data(MIME_TYPE)).decode("utf-8"))
|
||||||
|
if payload.get("side") == self.side:
|
||||||
|
return # depose sur lui-meme : rien a faire
|
||||||
|
event.acceptProposedAction()
|
||||||
|
self.files_dropped.emit(payload)
|
||||||
|
|
||||||
|
|
||||||
|
class FilePanel(QWidget):
|
||||||
|
"""Panneau generique (decline en local et distant)."""
|
||||||
|
|
||||||
|
path_changed = Signal(str)
|
||||||
|
transfer_here = Signal(dict)
|
||||||
|
|
||||||
|
def __init__(self, title: str, side: str, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.side = side
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(4, 4, 4, 4)
|
||||||
|
layout.setSpacing(4)
|
||||||
|
|
||||||
|
header = QHBoxLayout()
|
||||||
|
self.title_label = QLabel(f"<b>{title}</b>")
|
||||||
|
header.addWidget(self.title_label)
|
||||||
|
header.addStretch()
|
||||||
|
layout.addLayout(header)
|
||||||
|
|
||||||
|
nav = QHBoxLayout()
|
||||||
|
self.up_btn = QToolButton(); self.up_btn.setText("⬆"); self.up_btn.setToolTip("Dossier parent")
|
||||||
|
self.refresh_btn = QToolButton(); self.refresh_btn.setText("⟳"); self.refresh_btn.setToolTip("Actualiser (F5)")
|
||||||
|
self.mkdir_btn = QToolButton(); self.mkdir_btn.setText("📁+"); self.mkdir_btn.setToolTip("Nouveau dossier")
|
||||||
|
self.path_edit = QLineEdit()
|
||||||
|
nav.addWidget(self.up_btn)
|
||||||
|
nav.addWidget(self.refresh_btn)
|
||||||
|
nav.addWidget(self.mkdir_btn)
|
||||||
|
nav.addWidget(self.path_edit, 1)
|
||||||
|
layout.addLayout(nav)
|
||||||
|
|
||||||
|
self.tree = FileTree(side)
|
||||||
|
layout.addWidget(self.tree)
|
||||||
|
|
||||||
|
self.status = QLabel("")
|
||||||
|
self.status.setObjectName("statusLabel")
|
||||||
|
layout.addWidget(self.status)
|
||||||
|
|
||||||
|
self.up_btn.clicked.connect(self.go_up)
|
||||||
|
self.refresh_btn.clicked.connect(self.refresh)
|
||||||
|
self.mkdir_btn.clicked.connect(self.make_dir)
|
||||||
|
self.path_edit.returnPressed.connect(lambda: self.navigate(self.path_edit.text()))
|
||||||
|
self.tree.itemDoubleClicked.connect(self._on_double_click)
|
||||||
|
self.tree.files_dropped.connect(self.transfer_here.emit)
|
||||||
|
self.tree.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||||
|
self.tree.customContextMenuRequested.connect(self._context_menu)
|
||||||
|
|
||||||
|
QShortcut(QKeySequence("F2"), self.tree, activated=self.rename_selected)
|
||||||
|
QShortcut(QKeySequence("F5"), self.tree, activated=self.refresh)
|
||||||
|
QShortcut(QKeySequence(Qt.Key_Delete), self.tree, activated=self.delete_selected)
|
||||||
|
|
||||||
|
self.current_path = ""
|
||||||
|
|
||||||
|
# ---------- helpers d'affichage ----------
|
||||||
|
def _add_row(self, name: str, is_dir: bool, size: int, mtime: float, perms: str) -> None:
|
||||||
|
item = FileItem([
|
||||||
|
("📁 " if is_dir else "📄 ") + name,
|
||||||
|
"" if is_dir else human_size(size),
|
||||||
|
format_time(mtime) if mtime else "",
|
||||||
|
perms,
|
||||||
|
])
|
||||||
|
item.setData(0, NAME_ROLE, name)
|
||||||
|
item.setData(0, ISDIR_ROLE, is_dir)
|
||||||
|
# tri numerique correct sur la taille et la date
|
||||||
|
item.setData(1, FileItem.SORT_VALUE_ROLE, size)
|
||||||
|
item.setData(2, FileItem.SORT_VALUE_ROLE, mtime)
|
||||||
|
self.tree.addTopLevelItem(item)
|
||||||
|
|
||||||
|
def _add_parent_row(self) -> None:
|
||||||
|
item = FileItem(["📁 ..", "", "", ""])
|
||||||
|
item.setData(0, PARENT_ROLE, True)
|
||||||
|
item.setData(0, ISDIR_ROLE, True)
|
||||||
|
self.tree.addTopLevelItem(item)
|
||||||
|
|
||||||
|
def _on_double_click(self, item: QTreeWidgetItem) -> None:
|
||||||
|
if item.data(0, PARENT_ROLE):
|
||||||
|
self.go_up()
|
||||||
|
return
|
||||||
|
if item.data(0, ISDIR_ROLE):
|
||||||
|
self.navigate(self.join(self.current_path, item.data(0, NAME_ROLE)))
|
||||||
|
|
||||||
|
def go_up(self) -> None:
|
||||||
|
parent = self.parent_of(self.current_path)
|
||||||
|
if parent and parent != self.current_path:
|
||||||
|
self.navigate(parent)
|
||||||
|
|
||||||
|
def selected_entries(self) -> list[tuple[str, bool]]:
|
||||||
|
out = []
|
||||||
|
for item in self.tree.selectedItems():
|
||||||
|
if item.data(0, PARENT_ROLE):
|
||||||
|
continue
|
||||||
|
out.append((item.data(0, NAME_ROLE), bool(item.data(0, ISDIR_ROLE))))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _context_menu(self, pos: QPoint) -> None:
|
||||||
|
menu = QMenu(self)
|
||||||
|
has_selection = bool(self.selected_entries())
|
||||||
|
act_transfer = menu.addAction("⬆ Envoyer" if self.side == "local" else "⬇ Recevoir")
|
||||||
|
act_transfer.setEnabled(has_selection)
|
||||||
|
menu.addSeparator()
|
||||||
|
act_rename = menu.addAction("Renommer (F2)")
|
||||||
|
act_rename.setEnabled(has_selection)
|
||||||
|
act_delete = menu.addAction("Supprimer (Suppr)")
|
||||||
|
act_delete.setEnabled(has_selection)
|
||||||
|
act_perms = None
|
||||||
|
if self.side == "remote":
|
||||||
|
menu.addSeparator()
|
||||||
|
act_perms = menu.addAction("Permissions...")
|
||||||
|
act_perms.setEnabled(has_selection)
|
||||||
|
menu.addSeparator()
|
||||||
|
act_mkdir = menu.addAction("Nouveau dossier")
|
||||||
|
act_refresh = menu.addAction("Actualiser (F5)")
|
||||||
|
|
||||||
|
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
|
||||||
|
if action == act_transfer:
|
||||||
|
self.transfer_requested()
|
||||||
|
elif action == act_rename:
|
||||||
|
self.rename_selected()
|
||||||
|
elif action == act_delete:
|
||||||
|
self.delete_selected()
|
||||||
|
elif act_perms is not None and action == act_perms:
|
||||||
|
self.change_permissions()
|
||||||
|
elif action == act_mkdir:
|
||||||
|
self.make_dir()
|
||||||
|
elif action == act_refresh:
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def transfer_requested(self) -> None:
|
||||||
|
entries = [{"name": n, "is_dir": d} for n, d in self.selected_entries()]
|
||||||
|
if entries:
|
||||||
|
self.transfer_here.emit({"side": self.side, "entries": entries, "_from_button": True})
|
||||||
|
|
||||||
|
# ---------- a surcharger ----------
|
||||||
|
def navigate(self, path: str) -> None: ...
|
||||||
|
def refresh(self) -> None: ...
|
||||||
|
def join(self, a: str, b: str) -> str: ...
|
||||||
|
def parent_of(self, path: str) -> str: ...
|
||||||
|
def make_dir(self) -> None: ...
|
||||||
|
def rename_selected(self) -> None: ...
|
||||||
|
def delete_selected(self) -> None: ...
|
||||||
|
def change_permissions(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFilePanel(FilePanel):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__("💻 Local", "local", parent)
|
||||||
|
self.navigate(str(Path.home()))
|
||||||
|
|
||||||
|
def join(self, a: str, b: str) -> str:
|
||||||
|
return str(Path(a) / b)
|
||||||
|
|
||||||
|
def parent_of(self, path: str) -> str:
|
||||||
|
return str(Path(path).parent)
|
||||||
|
|
||||||
|
def navigate(self, path: str) -> None:
|
||||||
|
p = Path(path)
|
||||||
|
if not p.exists() or not p.is_dir():
|
||||||
|
QMessageBox.warning(self, "Chemin invalide", f"Dossier introuvable : {path}")
|
||||||
|
return
|
||||||
|
self.current_path = str(p)
|
||||||
|
self.path_edit.setText(self.current_path)
|
||||||
|
self.refresh()
|
||||||
|
self.path_changed.emit(self.current_path)
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
self.tree.setSortingEnabled(False)
|
||||||
|
self.tree.clear()
|
||||||
|
self._add_parent_row()
|
||||||
|
count = 0
|
||||||
|
try:
|
||||||
|
for entry in Path(self.current_path).iterdir():
|
||||||
|
try:
|
||||||
|
st = entry.stat()
|
||||||
|
is_dir = entry.is_dir()
|
||||||
|
self._add_row(entry.name, is_dir, st.st_size, st.st_mtime, stat.filemode(st.st_mode))
|
||||||
|
count += 1
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
except OSError as exc:
|
||||||
|
QMessageBox.warning(self, "Erreur", str(exc))
|
||||||
|
self.tree.setSortingEnabled(True)
|
||||||
|
self.status.setText(f"{count} element(s)")
|
||||||
|
|
||||||
|
def make_dir(self) -> None:
|
||||||
|
name, ok = QInputDialog.getText(self, "Nouveau dossier", "Nom du dossier :")
|
||||||
|
if not ok or not name:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
Path(self.current_path, name).mkdir()
|
||||||
|
self.refresh()
|
||||||
|
except OSError as exc:
|
||||||
|
QMessageBox.critical(self, "Erreur", str(exc))
|
||||||
|
|
||||||
|
def rename_selected(self) -> None:
|
||||||
|
entries = self.selected_entries()
|
||||||
|
if not entries:
|
||||||
|
return
|
||||||
|
old_name = entries[0][0]
|
||||||
|
new_name, ok = QInputDialog.getText(self, "Renommer", "Nouveau nom :", text=old_name)
|
||||||
|
if not ok or not new_name or new_name == old_name:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
Path(self.current_path, old_name).rename(Path(self.current_path, new_name))
|
||||||
|
self.refresh()
|
||||||
|
except OSError as exc:
|
||||||
|
QMessageBox.critical(self, "Erreur", str(exc))
|
||||||
|
|
||||||
|
def delete_selected(self) -> None:
|
||||||
|
entries = self.selected_entries()
|
||||||
|
if not entries:
|
||||||
|
return
|
||||||
|
if QMessageBox.question(
|
||||||
|
self, "Supprimer",
|
||||||
|
f"Supprimer definitivement {len(entries)} element(s) en local ?",
|
||||||
|
) != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
import shutil
|
||||||
|
for name, is_dir in entries:
|
||||||
|
target = Path(self.current_path, name)
|
||||||
|
try:
|
||||||
|
if is_dir:
|
||||||
|
shutil.rmtree(target)
|
||||||
|
else:
|
||||||
|
target.unlink()
|
||||||
|
except OSError as exc:
|
||||||
|
QMessageBox.critical(self, "Erreur", f"{name} : {exc}")
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteFilePanel(FilePanel):
|
||||||
|
def __init__(self, sftp: paramiko.SFTPClient, parent=None):
|
||||||
|
super().__init__("🌐 Distant", "remote", parent)
|
||||||
|
self.sftp = sftp
|
||||||
|
try:
|
||||||
|
home = self.sftp.normalize(".")
|
||||||
|
except Exception:
|
||||||
|
home = "/"
|
||||||
|
self.navigate(home)
|
||||||
|
|
||||||
|
def join(self, a: str, b: str) -> str:
|
||||||
|
return posixpath.join(a, b)
|
||||||
|
|
||||||
|
def parent_of(self, path: str) -> str:
|
||||||
|
return posixpath.dirname(path.rstrip("/")) or "/"
|
||||||
|
|
||||||
|
def navigate(self, path: str) -> None:
|
||||||
|
try:
|
||||||
|
attrs = self.sftp.listdir_attr(path)
|
||||||
|
except IOError as exc:
|
||||||
|
QMessageBox.warning(self, "Erreur SFTP", f"{path} : {exc}")
|
||||||
|
return
|
||||||
|
self.current_path = path
|
||||||
|
self.path_edit.setText(path)
|
||||||
|
self._populate(attrs)
|
||||||
|
self.path_changed.emit(path)
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
if self.current_path:
|
||||||
|
self.navigate(self.current_path)
|
||||||
|
|
||||||
|
def _populate(self, attrs) -> None:
|
||||||
|
self.tree.setSortingEnabled(False)
|
||||||
|
self.tree.clear()
|
||||||
|
self._add_parent_row()
|
||||||
|
for attr in attrs:
|
||||||
|
is_dir = stat.S_ISDIR(attr.st_mode)
|
||||||
|
self._add_row(
|
||||||
|
attr.filename, is_dir, attr.st_size or 0, attr.st_mtime or 0,
|
||||||
|
stat.filemode(attr.st_mode),
|
||||||
|
)
|
||||||
|
self.tree.setSortingEnabled(True)
|
||||||
|
self.status.setText(f"{len(attrs)} element(s)")
|
||||||
|
|
||||||
|
def make_dir(self) -> None:
|
||||||
|
name, ok = QInputDialog.getText(self, "Nouveau dossier", "Nom du dossier :")
|
||||||
|
if not ok or not name:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.sftp.mkdir(self.join(self.current_path, name))
|
||||||
|
self.refresh()
|
||||||
|
except IOError as exc:
|
||||||
|
QMessageBox.critical(self, "Erreur", str(exc))
|
||||||
|
|
||||||
|
def rename_selected(self) -> None:
|
||||||
|
entries = self.selected_entries()
|
||||||
|
if not entries:
|
||||||
|
return
|
||||||
|
old_name = entries[0][0]
|
||||||
|
new_name, ok = QInputDialog.getText(self, "Renommer", "Nouveau nom :", text=old_name)
|
||||||
|
if not ok or not new_name or new_name == old_name:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.sftp.rename(self.join(self.current_path, old_name), self.join(self.current_path, new_name))
|
||||||
|
self.refresh()
|
||||||
|
except IOError as exc:
|
||||||
|
QMessageBox.critical(self, "Erreur", str(exc))
|
||||||
|
|
||||||
|
def delete_selected(self) -> None:
|
||||||
|
entries = self.selected_entries()
|
||||||
|
if not entries:
|
||||||
|
return
|
||||||
|
if QMessageBox.question(
|
||||||
|
self, "Supprimer",
|
||||||
|
f"Supprimer definitivement {len(entries)} element(s) sur le serveur ?",
|
||||||
|
) != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
for name, is_dir in entries:
|
||||||
|
path = self.join(self.current_path, name)
|
||||||
|
try:
|
||||||
|
if is_dir:
|
||||||
|
self._rmdir_recursive(path)
|
||||||
|
else:
|
||||||
|
self.sftp.remove(path)
|
||||||
|
except IOError as exc:
|
||||||
|
QMessageBox.critical(self, "Erreur", f"{name} : {exc}")
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def _rmdir_recursive(self, path: str) -> None:
|
||||||
|
for attr in self.sftp.listdir_attr(path):
|
||||||
|
sub = posixpath.join(path, attr.filename)
|
||||||
|
if stat.S_ISDIR(attr.st_mode):
|
||||||
|
self._rmdir_recursive(sub)
|
||||||
|
else:
|
||||||
|
self.sftp.remove(sub)
|
||||||
|
self.sftp.rmdir(path)
|
||||||
|
|
||||||
|
def change_permissions(self) -> None:
|
||||||
|
entries = self.selected_entries()
|
||||||
|
if not entries:
|
||||||
|
return
|
||||||
|
name = entries[0][0]
|
||||||
|
path = self.join(self.current_path, name)
|
||||||
|
try:
|
||||||
|
current = stat.S_IMODE(self.sftp.stat(path).st_mode)
|
||||||
|
except IOError as exc:
|
||||||
|
QMessageBox.critical(self, "Erreur", str(exc))
|
||||||
|
return
|
||||||
|
text, ok = QInputDialog.getText(
|
||||||
|
self, "Permissions", f"Droits octaux pour {name} :", text=f"{current:03o}",
|
||||||
|
)
|
||||||
|
if not ok or not text:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
mode = int(text.strip(), 8)
|
||||||
|
except ValueError:
|
||||||
|
QMessageBox.warning(self, "Valeur invalide", "Entrez une valeur octale, par exemple 644 ou 755.")
|
||||||
|
return
|
||||||
|
errors = []
|
||||||
|
for entry_name, _ in entries:
|
||||||
|
try:
|
||||||
|
self.sftp.chmod(self.join(self.current_path, entry_name), mode)
|
||||||
|
except IOError as exc:
|
||||||
|
errors.append(f"{entry_name} : {exc}")
|
||||||
|
if errors:
|
||||||
|
QMessageBox.critical(self, "Erreur", "\n".join(errors))
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Conteneur double panneau
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
class SFTPPanel(QWidget):
|
||||||
|
def __init__(self, sftp: paramiko.SFTPClient, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.sftp = sftp
|
||||||
|
self._worker: TransferWorker | None = None
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(4, 4, 4, 4)
|
||||||
|
|
||||||
|
toolbar = QHBoxLayout()
|
||||||
|
self.upload_btn = QToolButton(); self.upload_btn.setText("⬆ Envoyer")
|
||||||
|
self.download_btn = QToolButton(); self.download_btn.setText("⬇ Recevoir")
|
||||||
|
hint = QLabel(" Glissez-deposez entre les panneaux, ou utilisez F2 (renommer), Suppr, F5.")
|
||||||
|
hint.setObjectName("statusLabel")
|
||||||
|
toolbar.addWidget(self.upload_btn)
|
||||||
|
toolbar.addWidget(self.download_btn)
|
||||||
|
toolbar.addWidget(hint)
|
||||||
|
toolbar.addStretch()
|
||||||
|
layout.addLayout(toolbar)
|
||||||
|
|
||||||
|
splitter = QSplitter(Qt.Horizontal)
|
||||||
|
self.local = LocalFilePanel()
|
||||||
|
self.remote = RemoteFilePanel(sftp)
|
||||||
|
splitter.addWidget(self.local)
|
||||||
|
splitter.addWidget(self.remote)
|
||||||
|
splitter.setSizes([500, 500])
|
||||||
|
layout.addWidget(splitter, 1)
|
||||||
|
|
||||||
|
self.upload_btn.clicked.connect(self.local.transfer_requested)
|
||||||
|
self.download_btn.clicked.connect(self.remote.transfer_requested)
|
||||||
|
# un depot venant du panneau local signifie "envoyer", et inversement
|
||||||
|
self.local.transfer_here.connect(self._on_transfer_payload)
|
||||||
|
self.remote.transfer_here.connect(self._on_transfer_payload)
|
||||||
|
|
||||||
|
def _on_transfer_payload(self, payload: dict) -> None:
|
||||||
|
source_side = payload.get("side")
|
||||||
|
entries = payload.get("entries", [])
|
||||||
|
if not entries:
|
||||||
|
return
|
||||||
|
jobs = []
|
||||||
|
for entry in entries:
|
||||||
|
name, is_dir = entry["name"], entry["is_dir"]
|
||||||
|
if source_side == "local":
|
||||||
|
jobs.append(TransferJob(
|
||||||
|
"upload", self.local.join(self.local.current_path, name),
|
||||||
|
self.remote.join(self.remote.current_path, name), is_dir,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
jobs.append(TransferJob(
|
||||||
|
"download", self.local.join(self.local.current_path, name),
|
||||||
|
self.remote.join(self.remote.current_path, name), is_dir,
|
||||||
|
))
|
||||||
|
self._run_jobs(jobs)
|
||||||
|
|
||||||
|
def _run_jobs(self, jobs: list[TransferJob]) -> None:
|
||||||
|
if not jobs:
|
||||||
|
return
|
||||||
|
if self._worker and self._worker.isRunning():
|
||||||
|
QMessageBox.information(self, "Transfert en cours", "Attendez la fin du transfert en cours.")
|
||||||
|
return
|
||||||
|
|
||||||
|
dialog = QProgressDialog("Preparation...", "Annuler", 0, 100, self)
|
||||||
|
dialog.setWindowTitle("Transfert SFTP")
|
||||||
|
dialog.setWindowModality(Qt.WindowModal)
|
||||||
|
dialog.setMinimumDuration(0)
|
||||||
|
dialog.setAutoClose(False)
|
||||||
|
dialog.setAutoReset(False)
|
||||||
|
|
||||||
|
worker = TransferWorker(self.sftp, jobs)
|
||||||
|
worker.file_started.connect(
|
||||||
|
lambda name, i, total: dialog.setLabelText(f"({i}/{total}) {name}")
|
||||||
|
)
|
||||||
|
worker.file_progress.connect(
|
||||||
|
lambda done, total: dialog.setValue(int(done * 100 / total) if total else 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_finished(done: int, errors: list) -> None:
|
||||||
|
dialog.close()
|
||||||
|
self.local.refresh()
|
||||||
|
self.remote.refresh()
|
||||||
|
if errors:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "Transfert termine avec des erreurs",
|
||||||
|
f"{done} element(s) transfere(s).\n\nErreurs :\n" + "\n".join(errors[:10]),
|
||||||
|
)
|
||||||
|
|
||||||
|
worker.finished_all.connect(on_finished)
|
||||||
|
dialog.canceled.connect(worker.cancel)
|
||||||
|
self._worker = worker
|
||||||
|
worker.start()
|
||||||
336
app/ssh_connection.py
Normal file
336
app/ssh_connection.py
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
"""Connexion SSH en arriere-plan (thread Qt) : shell interactif + verification des cles d'hote."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
from PySide6.QtCore import QThread, Signal
|
||||||
|
|
||||||
|
from app.known_hosts_manager import KnownHostsStore
|
||||||
|
|
||||||
|
from app.paths import DATA_DIR # noqa: E402 (chemins valables aussi en .exe)
|
||||||
|
KNOWN_HOSTS_FILE = DATA_DIR / "known_hosts"
|
||||||
|
|
||||||
|
|
||||||
|
class TrustPrompt(paramiko.MissingHostKeyPolicy):
|
||||||
|
"""Politique qui demande confirmation (via signal Qt) avant de faire confiance a une nouvelle cle d'hote."""
|
||||||
|
|
||||||
|
def __init__(self, connection: "SSHConnection"):
|
||||||
|
self.connection = connection
|
||||||
|
|
||||||
|
def missing_host_key(self, client, hostname, key):
|
||||||
|
accepted = self.connection._ask_host_key_trust(hostname, key)
|
||||||
|
if not accepted:
|
||||||
|
raise paramiko.SSHException(f"Cle d'hote refusee pour {hostname}")
|
||||||
|
client.get_host_keys().add(hostname, key.get_name(), key)
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
client.save_host_keys(str(KNOWN_HOSTS_FILE))
|
||||||
|
|
||||||
|
|
||||||
|
class SSHConnection(QThread):
|
||||||
|
"""Gere une connexion SSH + un canal shell interactif dans un thread dedie."""
|
||||||
|
|
||||||
|
connected = Signal()
|
||||||
|
data_received = Signal(bytes)
|
||||||
|
disconnected = Signal(str)
|
||||||
|
error = Signal(str)
|
||||||
|
host_key_prompt = Signal(str, str, str) # hostname, key_type, fingerprint -> attend confirm_host_key()
|
||||||
|
host_key_changed_prompt = Signal(str, str, str, str) # hostname, key_type, old_fp, new_fp -> attend confirm_host_key_change()
|
||||||
|
passphrase_prompt = Signal(str) # key_path -> attend provide_passphrase()
|
||||||
|
password_prompt = Signal(str) # "user@host" -> attend provide_password()
|
||||||
|
sftp_ready = Signal(object) # SFTPClient ouvert dans le thread, pret a l'emploi
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
username: str,
|
||||||
|
auth_type: str,
|
||||||
|
key_path: str = "",
|
||||||
|
password: str = "",
|
||||||
|
passphrase: str = "",
|
||||||
|
cols: int = 80,
|
||||||
|
rows: int = 24,
|
||||||
|
open_shell: bool = True,
|
||||||
|
open_sftp_on_connect: bool = False,
|
||||||
|
parent=None,
|
||||||
|
):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.open_shell = open_shell
|
||||||
|
self.open_sftp_on_connect = open_sftp_on_connect
|
||||||
|
self.sftp: paramiko.SFTPClient | None = None
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.username = username
|
||||||
|
self.auth_type = auth_type
|
||||||
|
self.key_path = key_path
|
||||||
|
self.password = password
|
||||||
|
self.passphrase = passphrase
|
||||||
|
self.cols = cols
|
||||||
|
self.rows = rows
|
||||||
|
|
||||||
|
self.client: paramiko.SSHClient | None = None
|
||||||
|
self.channel: paramiko.Channel | None = None
|
||||||
|
self._stop = threading.Event()
|
||||||
|
|
||||||
|
self._trust_event = threading.Event()
|
||||||
|
self._trust_result = False
|
||||||
|
|
||||||
|
self._passphrase_event = threading.Event()
|
||||||
|
self._passphrase_result: str | None = None
|
||||||
|
|
||||||
|
self._host_key_change_event = threading.Event()
|
||||||
|
self._host_key_change_result = False
|
||||||
|
|
||||||
|
self._password_event = threading.Event()
|
||||||
|
self._password_result: str | None = None
|
||||||
|
self.known_hosts = KnownHostsStore(KNOWN_HOSTS_FILE)
|
||||||
|
|
||||||
|
# File d'envoi + thread dedie : send() est appele directement depuis le thread GUI
|
||||||
|
# (une frappe clavier). channel.send() peut bloquer (reseau congestionne, fenetre TCP
|
||||||
|
# pleine) ; sans decouplage, ce blocage gele toute l'interface le temps de l'ecriture.
|
||||||
|
self._out_queue: queue.Queue[bytes] = queue.Queue()
|
||||||
|
self._writer_thread: threading.Thread | None = None
|
||||||
|
|
||||||
|
# ---------- confiance cle d'hote (appele depuis le thread SSH) ----------
|
||||||
|
def _ask_host_key_trust(self, hostname: str, key: paramiko.PKey) -> bool:
|
||||||
|
self._trust_event.clear()
|
||||||
|
fingerprint = key.get_fingerprint().hex(":")
|
||||||
|
self.host_key_prompt.emit(hostname, key.get_name(), fingerprint)
|
||||||
|
self._trust_event.wait() # debloque via confirm_host_key() depuis le thread GUI
|
||||||
|
return self._trust_result
|
||||||
|
|
||||||
|
def confirm_host_key(self, accepted: bool) -> None:
|
||||||
|
self._trust_result = accepted
|
||||||
|
self._trust_event.set()
|
||||||
|
|
||||||
|
# ---------- cle d'hote modifiee, ex. IP DHCP reattribuee (appele depuis le thread SSH) ----------
|
||||||
|
def _ask_host_key_change(self, hostname: str, got_key: paramiko.PKey, expected_key: paramiko.PKey) -> bool:
|
||||||
|
self._host_key_change_event.clear()
|
||||||
|
old_fp = expected_key.get_fingerprint().hex(":")
|
||||||
|
new_fp = got_key.get_fingerprint().hex(":")
|
||||||
|
self.host_key_changed_prompt.emit(hostname, got_key.get_name(), old_fp, new_fp)
|
||||||
|
self._host_key_change_event.wait() # debloque via confirm_host_key_change() depuis le thread GUI
|
||||||
|
return self._host_key_change_result
|
||||||
|
|
||||||
|
def confirm_host_key_change(self, accepted: bool) -> None:
|
||||||
|
self._host_key_change_result = accepted
|
||||||
|
self._host_key_change_event.set()
|
||||||
|
|
||||||
|
# ---------- passphrase de cle privee (appele depuis le thread SSH) ----------
|
||||||
|
def _ask_passphrase(self, key_path: str) -> str | None:
|
||||||
|
self._passphrase_event.clear()
|
||||||
|
self._passphrase_result = None
|
||||||
|
self.passphrase_prompt.emit(key_path)
|
||||||
|
self._passphrase_event.wait() # debloque via provide_passphrase() depuis le thread GUI
|
||||||
|
return self._passphrase_result
|
||||||
|
|
||||||
|
def provide_passphrase(self, text: str | None) -> None:
|
||||||
|
self._passphrase_result = text or None
|
||||||
|
self._passphrase_event.set()
|
||||||
|
|
||||||
|
# ---------- mot de passe demande en secours (appele depuis le thread SSH) ----------
|
||||||
|
def _ask_password(self) -> str | None:
|
||||||
|
self._password_event.clear()
|
||||||
|
self._password_result = None
|
||||||
|
self.password_prompt.emit(f"{self.username}@{self.host}")
|
||||||
|
self._password_event.wait() # debloque via provide_password() depuis le thread GUI
|
||||||
|
return self._password_result
|
||||||
|
|
||||||
|
def provide_password(self, text: str | None) -> None:
|
||||||
|
self._password_result = text or None
|
||||||
|
self._password_event.set()
|
||||||
|
|
||||||
|
# ---------- cycle de vie du thread ----------
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
self.client = paramiko.SSHClient()
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
if KNOWN_HOSTS_FILE.exists():
|
||||||
|
self.client.load_host_keys(str(KNOWN_HOSTS_FILE))
|
||||||
|
self.client.set_missing_host_key_policy(TrustPrompt(self))
|
||||||
|
|
||||||
|
auto = self.auth_type == "auto"
|
||||||
|
connect_kwargs = dict(
|
||||||
|
hostname=self.host,
|
||||||
|
port=self.port,
|
||||||
|
username=self.username,
|
||||||
|
timeout=15,
|
||||||
|
# en mode auto on se comporte comme la commande ssh : agent SSH
|
||||||
|
# et cles par defaut de ~/.ssh, sans rien avoir a designer
|
||||||
|
allow_agent=(auto or self.auth_type == "agent"),
|
||||||
|
look_for_keys=auto,
|
||||||
|
)
|
||||||
|
if self.auth_type == "key":
|
||||||
|
if not self.key_path:
|
||||||
|
raise paramiko.SSHException(
|
||||||
|
"Aucune cle privee selectionnee. Choisissez une cle, ou l'authentification "
|
||||||
|
"automatique / par mot de passe."
|
||||||
|
)
|
||||||
|
connect_kwargs["pkey"] = self._load_private_key(self.key_path, self.passphrase)
|
||||||
|
elif self.auth_type == "password":
|
||||||
|
connect_kwargs["password"] = self.password
|
||||||
|
elif auto and self.password:
|
||||||
|
connect_kwargs["password"] = self.password
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
self.client.connect(**connect_kwargs)
|
||||||
|
except paramiko.AuthenticationException:
|
||||||
|
# Mode auto : ni l'agent ni les cles ~/.ssh n'ont convenu,
|
||||||
|
# on demande alors un mot de passe plutot que d'echouer.
|
||||||
|
if not auto or connect_kwargs.get("password"):
|
||||||
|
raise
|
||||||
|
password = self._ask_password()
|
||||||
|
if not password:
|
||||||
|
raise
|
||||||
|
connect_kwargs["password"] = password
|
||||||
|
connect_kwargs["look_for_keys"] = False
|
||||||
|
connect_kwargs["allow_agent"] = False
|
||||||
|
self.client.connect(**connect_kwargs)
|
||||||
|
except paramiko.BadHostKeyException as exc:
|
||||||
|
# La cle presentee par le serveur ne correspond plus a celle enregistree
|
||||||
|
# (frequent avec des IP DHCP reattribuees a une autre machine).
|
||||||
|
accepted = self._ask_host_key_change(exc.hostname, exc.got_key, exc.expected_key)
|
||||||
|
if not accepted:
|
||||||
|
raise paramiko.SSHException(
|
||||||
|
f"Cle d'hote modifiee refusee pour {exc.hostname} (connexion annulee)."
|
||||||
|
) from exc
|
||||||
|
self.known_hosts.replace_host_key(exc.hostname, exc.got_key)
|
||||||
|
# Corrige aussi la copie en memoire utilisee par ce client pour la reconnexion
|
||||||
|
# (load_host_keys() fusionnerait avec l'ancienne entree encore en memoire).
|
||||||
|
live_keys = self.client.get_host_keys()
|
||||||
|
if exc.hostname in live_keys:
|
||||||
|
del live_keys[exc.hostname]
|
||||||
|
live_keys.add(exc.hostname, exc.got_key.get_name(), exc.got_key)
|
||||||
|
self.client.connect(**connect_kwargs)
|
||||||
|
|
||||||
|
# Ouvert ici (dans le thread) et non depuis l'interface : open_sftp() fait un
|
||||||
|
# aller-retour reseau qui figerait la fenetre s'il etait appele au clic.
|
||||||
|
if self.open_sftp_on_connect:
|
||||||
|
try:
|
||||||
|
self.sftp = self.client.open_sftp()
|
||||||
|
self.sftp_ready.emit(self.sftp)
|
||||||
|
except Exception:
|
||||||
|
# Sans shell, le SFTP est la raison d'etre de la connexion : c'est fatal.
|
||||||
|
# Avec un shell, le terminal doit rester utilisable meme si le serveur
|
||||||
|
# n'expose pas le sous-systeme SFTP.
|
||||||
|
if not self.open_shell:
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not self.open_shell:
|
||||||
|
# Connexion dediee au SFTP : on garde la session vivante sans shell.
|
||||||
|
self.connected.emit()
|
||||||
|
while not self._stop.is_set():
|
||||||
|
self._stop.wait(0.2)
|
||||||
|
self.disconnected.emit("Connexion fermee")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.channel = self.client.invoke_shell(term="xterm-256color", width=self.cols, height=self.rows)
|
||||||
|
# La taille reelle du widget arrive souvent apres la creation de la connexion :
|
||||||
|
# on reapplique la derniere taille demandee, sinon le shell distant reste en
|
||||||
|
# 80x24 alors que l'affichage est plus grand (retours a la ligne incoherents).
|
||||||
|
try:
|
||||||
|
self.channel.resize_pty(width=self.cols, height=self.rows)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.channel.settimeout(0.5)
|
||||||
|
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.channel.recv(4096)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
self.data_received.emit(data)
|
||||||
|
except TimeoutError:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
|
self.disconnected.emit("Connexion fermee")
|
||||||
|
except paramiko.AuthenticationException:
|
||||||
|
self.error.emit("Authentification refusee (identifiants ou cle invalides).")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self.error.emit(str(exc))
|
||||||
|
finally:
|
||||||
|
self._cleanup()
|
||||||
|
|
||||||
|
def _load_private_key(self, key_path: str, passphrase: str) -> paramiko.PKey:
|
||||||
|
last_exc = None
|
||||||
|
for loader in (paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.RSAKey):
|
||||||
|
try:
|
||||||
|
return loader.from_private_key_file(key_path, password=passphrase or None)
|
||||||
|
except paramiko.PasswordRequiredException:
|
||||||
|
pwd = passphrase or self._ask_passphrase(key_path)
|
||||||
|
if not pwd:
|
||||||
|
raise paramiko.SSHException("Passphrase requise pour deverrouiller la cle")
|
||||||
|
try:
|
||||||
|
return loader.from_private_key_file(key_path, password=pwd)
|
||||||
|
except paramiko.SSHException as exc:
|
||||||
|
last_exc = exc
|
||||||
|
continue
|
||||||
|
except paramiko.SSHException as exc:
|
||||||
|
last_exc = exc
|
||||||
|
continue
|
||||||
|
raise last_exc or paramiko.SSHException("Format de cle non reconnu")
|
||||||
|
|
||||||
|
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 reseau 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 canal
|
||||||
|
restant lui-meme ouvert). On n'arrete la boucle que si le canal est ferme."""
|
||||||
|
while not self._stop.is_set():
|
||||||
|
try:
|
||||||
|
data = self._out_queue.get(timeout=0.2)
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
if not (self.channel and not self.channel.closed):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
self.channel.send(data)
|
||||||
|
except OSError:
|
||||||
|
if not self.channel or self.channel.closed:
|
||||||
|
break
|
||||||
|
|
||||||
|
def resize(self, cols: int, rows: int) -> None:
|
||||||
|
self.cols, self.rows = cols, rows
|
||||||
|
if self.channel and not self.channel.closed:
|
||||||
|
try:
|
||||||
|
self.channel.resize_pty(width=cols, height=rows)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def open_sftp(self) -> paramiko.SFTPClient | None:
|
||||||
|
if self.client:
|
||||||
|
return self.client.open_sftp()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def close_connection(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
self._trust_event.set() # au cas ou on serait bloque sur une confirmation
|
||||||
|
self._passphrase_event.set()
|
||||||
|
self._host_key_change_event.set()
|
||||||
|
self._password_event.set()
|
||||||
|
self._cleanup()
|
||||||
|
|
||||||
|
def _cleanup(self) -> None:
|
||||||
|
try:
|
||||||
|
if self.channel:
|
||||||
|
self.channel.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if self.client:
|
||||||
|
self.client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
266
app/terminal_widget.py
Normal file
266
app/terminal_widget.py
Normal 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)
|
||||||
181
app/theme.py
Normal file
181
app/theme.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"""Themes de l'application : palette d'interface (QSS) + palette du terminal.
|
||||||
|
|
||||||
|
Trois themes sont fournis (clair par defaut, bleu nuit, noir). Ils partagent le meme
|
||||||
|
gabarit `resources/theme.qss`, dont les $variables sont remplacees par la palette
|
||||||
|
choisie : une seule feuille de style a maintenir au lieu de trois quasi identiques.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from string import Template
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtGui import QIcon, QPixmap, QPainter, QColor, QFont, QLinearGradient
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from app.paths import RESOURCES_DIR, DATA_DIR # noqa: E402 (chemins valables aussi en .exe)
|
||||||
|
STYLESHEET_PATH = RESOURCES_DIR / "theme.qss"
|
||||||
|
SETTINGS_FILE = DATA_DIR / "settings.json"
|
||||||
|
|
||||||
|
THEME_LIGHT = "light"
|
||||||
|
THEME_MIDNIGHT = "midnight"
|
||||||
|
THEME_BLACK = "black"
|
||||||
|
DEFAULT_THEME = THEME_LIGHT
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Palettes d'interface
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
UI_PALETTES: dict[str, dict[str, str]] = {
|
||||||
|
THEME_LIGHT: {
|
||||||
|
"window": "#f4f6f9", "base": "#ffffff", "panel": "#eef1f6", "elevated": "#e4e9f0",
|
||||||
|
"input": "#ffffff", "hover": "#dfe6f2", "pressed": "#d2dae8",
|
||||||
|
"border": "#d3dae4", "border_strong": "#b9c3d1",
|
||||||
|
"text": "#1c2430", "text_dim": "#5c6875", "text_disabled": "#a3adba",
|
||||||
|
"accent": "#2f6fed", "accent_hover": "#1f5ed6", "on_accent": "#ffffff",
|
||||||
|
"scroll": "#c3ccd9", "scroll_hover": "#a7b3c4",
|
||||||
|
},
|
||||||
|
THEME_MIDNIGHT: {
|
||||||
|
"window": "#0a1220", "base": "#0c1526", "panel": "#0e1a30", "elevated": "#1c2c47",
|
||||||
|
"input": "#101c30", "hover": "#172542", "pressed": "#16233b",
|
||||||
|
"border": "#1c2c47", "border_strong": "#2a3c5c",
|
||||||
|
"text": "#e6ecf6", "text_dim": "#93a4c2", "text_disabled": "#55627c",
|
||||||
|
"accent": "#2f6fed", "accent_hover": "#4f8cff", "on_accent": "#ffffff",
|
||||||
|
"scroll": "#2a3c5c", "scroll_hover": "#3a527d",
|
||||||
|
},
|
||||||
|
THEME_BLACK: {
|
||||||
|
"window": "#000000", "base": "#0a0a0a", "panel": "#141414", "elevated": "#1f1f1f",
|
||||||
|
"input": "#141414", "hover": "#262626", "pressed": "#0f0f0f",
|
||||||
|
"border": "#2b2b2b", "border_strong": "#3d3d3d",
|
||||||
|
"text": "#e8e8e8", "text_dim": "#9a9a9a", "text_disabled": "#5a5a5a",
|
||||||
|
"accent": "#4a9eff", "accent_hover": "#6fb3ff", "on_accent": "#000000",
|
||||||
|
"scroll": "#3a3a3a", "scroll_hover": "#525252",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Palettes du terminal (16 couleurs ANSI + defauts + curseur)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
TERMINAL_PALETTES: dict[str, dict] = {
|
||||||
|
THEME_LIGHT: {
|
||||||
|
"fg": "#1c2430", "bg": "#ffffff", "cursor": (47, 111, 237, 110),
|
||||||
|
"ansi": {
|
||||||
|
"black": "#1c2430", "red": "#c0392b", "green": "#2e7d32", "brown": "#a06800",
|
||||||
|
"blue": "#1f5ed6", "magenta": "#8e24aa", "cyan": "#00838f", "white": "#5c6875",
|
||||||
|
"brightblack": "#8a94a3", "brightred": "#e04b3a", "brightgreen": "#3ca142",
|
||||||
|
"brightbrown": "#c98a00", "brightblue": "#2f6fed", "brightmagenta": "#a944c7",
|
||||||
|
"brightcyan": "#0aa2b0", "brightwhite": "#1c2430",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
THEME_MIDNIGHT: {
|
||||||
|
"fg": "#e6ecf6", "bg": "#0b1220", "cursor": (79, 140, 255, 140),
|
||||||
|
"ansi": {
|
||||||
|
"black": "#0b1220", "red": "#ff6b81", "green": "#8ce99a", "brown": "#f2c94c",
|
||||||
|
"blue": "#4f8cff", "magenta": "#c792ea", "cyan": "#63d4e0", "white": "#dbe4f5",
|
||||||
|
"brightblack": "#3a4a68", "brightred": "#ff8a9b", "brightgreen": "#a9e6b5",
|
||||||
|
"brightbrown": "#f5d778", "brightblue": "#7aa9ff", "brightmagenta": "#dcb3f0",
|
||||||
|
"brightcyan": "#8fe3ec", "brightwhite": "#ffffff",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
THEME_BLACK: {
|
||||||
|
"fg": "#e8e8e8", "bg": "#000000", "cursor": (74, 158, 255, 140),
|
||||||
|
"ansi": {
|
||||||
|
"black": "#000000", "red": "#e05561", "green": "#8cc265", "brown": "#d18f52",
|
||||||
|
"blue": "#4aa5f0", "magenta": "#c162de", "cyan": "#42b3c2", "white": "#d0d0d0",
|
||||||
|
"brightblack": "#5a5a5a", "brightred": "#ff6b78", "brightgreen": "#a5e075",
|
||||||
|
"brightbrown": "#e5a663", "brightblue": "#6fb3ff", "brightmagenta": "#d178ee",
|
||||||
|
"brightcyan": "#5fc9d8", "brightwhite": "#ffffff",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
THEME_LABELS = {
|
||||||
|
THEME_LIGHT: "Clair",
|
||||||
|
THEME_MIDNIGHT: "Bleu nuit",
|
||||||
|
THEME_BLACK: "Noir",
|
||||||
|
}
|
||||||
|
|
||||||
|
_current_theme = DEFAULT_THEME
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Preference persistante
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def load_saved_theme() -> str:
|
||||||
|
try:
|
||||||
|
data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
|
||||||
|
theme = data.get("theme")
|
||||||
|
if theme in UI_PALETTES:
|
||||||
|
return theme
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
return DEFAULT_THEME
|
||||||
|
|
||||||
|
|
||||||
|
def save_theme(theme: str) -> None:
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
data = {}
|
||||||
|
try:
|
||||||
|
data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
data["theme"] = theme
|
||||||
|
try:
|
||||||
|
SETTINGS_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def current_theme() -> str:
|
||||||
|
return _current_theme
|
||||||
|
|
||||||
|
|
||||||
|
def terminal_palette(theme: str | None = None) -> dict:
|
||||||
|
return TERMINAL_PALETTES.get(theme or _current_theme, TERMINAL_PALETTES[DEFAULT_THEME])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Application du theme
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def apply_theme(app: QApplication, theme: str = DEFAULT_THEME) -> None:
|
||||||
|
"""Bascule sur le style Fusion (necessaire pour que la QSS s'applique
|
||||||
|
uniformement, y compris aux widgets stylises nativement par Windows) puis
|
||||||
|
construit la feuille de style a partir de la palette demandee."""
|
||||||
|
global _current_theme
|
||||||
|
if theme not in UI_PALETTES:
|
||||||
|
theme = DEFAULT_THEME
|
||||||
|
_current_theme = theme
|
||||||
|
|
||||||
|
app.setStyle("Fusion")
|
||||||
|
try:
|
||||||
|
template = Template(STYLESHEET_PATH.read_text(encoding="utf-8"))
|
||||||
|
app.setStyleSheet(template.safe_substitute(UI_PALETTES[theme]))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def build_app_icon() -> QIcon:
|
||||||
|
"""Genere une icone simple (pas de fichier externe requis) : carre + prompt '>_'"""
|
||||||
|
size = 64
|
||||||
|
pixmap = QPixmap(size, size)
|
||||||
|
pixmap.fill(Qt.transparent)
|
||||||
|
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
painter.setRenderHint(QPainter.Antialiasing)
|
||||||
|
|
||||||
|
gradient = QLinearGradient(0, 0, size, size)
|
||||||
|
gradient.setColorAt(0, QColor("#182a4a"))
|
||||||
|
gradient.setColorAt(1, QColor("#0a1220"))
|
||||||
|
painter.setBrush(gradient)
|
||||||
|
painter.setPen(QColor("#2a3c5c"))
|
||||||
|
painter.drawRoundedRect(2, 2, size - 4, size - 4, 14, 14)
|
||||||
|
|
||||||
|
font = QFont("Consolas")
|
||||||
|
font.setBold(True)
|
||||||
|
font.setPointSize(24)
|
||||||
|
painter.setFont(font)
|
||||||
|
painter.setPen(QColor("#4f8cff"))
|
||||||
|
painter.drawText(pixmap.rect(), Qt.AlignCenter, ">_")
|
||||||
|
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pixmap)
|
||||||
32
build_exe.bat
Normal file
32
build_exe.bat
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
if not exist venv (
|
||||||
|
echo L'environnement virtuel n'existe pas. Lancez d'abord setup.bat
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
call venv\Scripts\activate.bat
|
||||||
|
|
||||||
|
echo Installation de PyInstaller si necessaire...
|
||||||
|
python -m pip install pyinstaller --quiet
|
||||||
|
|
||||||
|
echo Generation de l'icone...
|
||||||
|
python tools\make_icon.py
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Construction de l'application portable (plusieurs minutes)...
|
||||||
|
python -m PyInstaller --noconfirm --clean PySSHClient.spec
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo La construction a echoue.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Termine : le dossier "dist\PySSH Client" est autonome.
|
||||||
|
echo Copiez-le ou vous voulez, il fonctionne sans Python installe.
|
||||||
|
pause
|
||||||
0
data/keys/.gitkeep
Normal file
0
data/keys/.gitkeep
Normal file
25
main.py
Normal file
25
main.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""Point d'entree de l'application PySSH Client."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from app.main_window import MainWindow
|
||||||
|
from app.theme import apply_theme, build_app_icon, load_saved_theme
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
app.setApplicationName("PySSH Client")
|
||||||
|
# theme clair par defaut au premier lancement, sinon celui choisi precedemment
|
||||||
|
apply_theme(app, load_saved_theme())
|
||||||
|
icon = build_app_icon()
|
||||||
|
app.setWindowIcon(icon)
|
||||||
|
|
||||||
|
window = MainWindow()
|
||||||
|
window.setWindowIcon(icon)
|
||||||
|
window.show()
|
||||||
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
PySide6>=6.7
|
||||||
|
paramiko>=3.4
|
||||||
|
pyte>=0.8.2
|
||||||
|
cryptography>=42.0
|
||||||
|
pywinpty>=2.0
|
||||||
342
resources/theme.qss
Normal file
342
resources/theme.qss
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
/* ============================================================
|
||||||
|
PySSH Client - gabarit de theme
|
||||||
|
Les jetons prefixes du signe dollar sont remplaces par la palette choisie
|
||||||
|
(voir app/theme.py).
|
||||||
|
Applique sur le style de base Fusion.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
* {
|
||||||
|
outline: none;
|
||||||
|
font-family: "Segoe UI", "Cascadia Mono", sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QWidget {
|
||||||
|
background-color: $base;
|
||||||
|
color: $text;
|
||||||
|
selection-background-color: $accent;
|
||||||
|
selection-color: $on_accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
QMainWindow, QDialog {
|
||||||
|
background-color: $window;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Barre de menu ---------- */
|
||||||
|
QMenuBar {
|
||||||
|
background-color: $panel;
|
||||||
|
color: $text;
|
||||||
|
border-bottom: 1px solid $border;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
QMenuBar::item {
|
||||||
|
background: transparent;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
QMenuBar::item:selected {
|
||||||
|
background-color: $hover;
|
||||||
|
}
|
||||||
|
QMenu {
|
||||||
|
background-color: $panel;
|
||||||
|
border: 1px solid $border;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
QMenu::item {
|
||||||
|
padding: 6px 24px 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
QMenu::item:selected {
|
||||||
|
background-color: $accent;
|
||||||
|
color: $on_accent;
|
||||||
|
}
|
||||||
|
QMenu::separator {
|
||||||
|
height: 1px;
|
||||||
|
background: $border;
|
||||||
|
margin: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Barre de statut ---------- */
|
||||||
|
QStatusBar {
|
||||||
|
background-color: $panel;
|
||||||
|
color: $text_dim;
|
||||||
|
border-top: 1px solid $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Dock / sidebar ---------- */
|
||||||
|
QDockWidget {
|
||||||
|
color: $text;
|
||||||
|
titlebar-close-icon: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
QDockWidget::title {
|
||||||
|
background-color: $panel;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Arborescence des sessions ---------- */
|
||||||
|
QTreeWidget {
|
||||||
|
background-color: $panel;
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid $border;
|
||||||
|
padding: 4px;
|
||||||
|
show-decoration-selected: 1;
|
||||||
|
}
|
||||||
|
QTreeWidget::item {
|
||||||
|
padding: 6px 4px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 1px 2px;
|
||||||
|
}
|
||||||
|
QTreeWidget::item:hover {
|
||||||
|
background-color: $hover;
|
||||||
|
}
|
||||||
|
QTreeWidget::item:selected {
|
||||||
|
background-color: $accent;
|
||||||
|
color: $on_accent;
|
||||||
|
}
|
||||||
|
QTreeWidget::branch {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
QHeaderView::section {
|
||||||
|
background-color: $elevated;
|
||||||
|
color: $text_dim;
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid $border;
|
||||||
|
border-bottom: 1px solid $border;
|
||||||
|
padding: 5px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Onglets ---------- */
|
||||||
|
QTabWidget::pane {
|
||||||
|
border: none;
|
||||||
|
background-color: $window;
|
||||||
|
top: -1px;
|
||||||
|
}
|
||||||
|
QTabBar {
|
||||||
|
background-color: $panel;
|
||||||
|
}
|
||||||
|
QTabBar::tab {
|
||||||
|
background-color: $panel;
|
||||||
|
color: $text_dim;
|
||||||
|
padding: 9px 18px;
|
||||||
|
margin-right: 2px;
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
QTabBar::tab:selected {
|
||||||
|
background-color: $window;
|
||||||
|
color: $text;
|
||||||
|
border-bottom: 2px solid $accent;
|
||||||
|
}
|
||||||
|
QTabBar::tab:hover:!selected {
|
||||||
|
background-color: $hover;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
QTabBar::close-button {
|
||||||
|
subcontrol-position: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Boutons ---------- */
|
||||||
|
QPushButton {
|
||||||
|
background-color: $elevated;
|
||||||
|
color: $text;
|
||||||
|
border: 1px solid $border_strong;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background-color: $hover;
|
||||||
|
border-color: $accent;
|
||||||
|
}
|
||||||
|
QPushButton:pressed {
|
||||||
|
background-color: $pressed;
|
||||||
|
}
|
||||||
|
QPushButton:disabled {
|
||||||
|
color: $text_disabled;
|
||||||
|
background-color: $base;
|
||||||
|
}
|
||||||
|
QPushButton#primary, QDialogButtonBox QPushButton[default="true"] {
|
||||||
|
background-color: $accent;
|
||||||
|
border-color: $accent_hover;
|
||||||
|
color: $on_accent;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
QPushButton#primary:hover, QDialogButtonBox QPushButton[default="true"]:hover {
|
||||||
|
background-color: $accent_hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
QToolButton {
|
||||||
|
background-color: transparent;
|
||||||
|
color: $text;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
QToolButton:hover {
|
||||||
|
background-color: $hover;
|
||||||
|
border-color: $border_strong;
|
||||||
|
}
|
||||||
|
QToolButton:pressed, QToolButton:checked {
|
||||||
|
background-color: $accent;
|
||||||
|
color: $on_accent;
|
||||||
|
}
|
||||||
|
QToolButton::menu-indicator {
|
||||||
|
subcontrol-position: right center;
|
||||||
|
subcontrol-origin: padding;
|
||||||
|
right: 2px;
|
||||||
|
}
|
||||||
|
QToolButton#primary {
|
||||||
|
background-color: $accent;
|
||||||
|
border: 1px solid $accent_hover;
|
||||||
|
color: $on_accent;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
QToolButton#primary:hover {
|
||||||
|
background-color: $accent_hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Barre superieure des onglets terminal ---------- */
|
||||||
|
QFrame#tabBar {
|
||||||
|
background-color: $panel;
|
||||||
|
border-bottom: 1px solid $border;
|
||||||
|
}
|
||||||
|
QLabel#statusLabel {
|
||||||
|
color: $text_dim;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Champs de saisie ---------- */
|
||||||
|
QLineEdit, QTextEdit, QPlainTextEdit, QSpinBox, QComboBox {
|
||||||
|
background-color: $input;
|
||||||
|
color: $text;
|
||||||
|
border: 1px solid $border_strong;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
selection-background-color: $accent;
|
||||||
|
selection-color: $on_accent;
|
||||||
|
}
|
||||||
|
QLineEdit:focus, QTextEdit:focus, QSpinBox:focus, QComboBox:focus {
|
||||||
|
border: 1px solid $accent;
|
||||||
|
}
|
||||||
|
QComboBox::drop-down {
|
||||||
|
border: none;
|
||||||
|
width: 22px;
|
||||||
|
}
|
||||||
|
QComboBox QAbstractItemView {
|
||||||
|
background-color: $input;
|
||||||
|
color: $text;
|
||||||
|
border: 1px solid $border_strong;
|
||||||
|
selection-background-color: $accent;
|
||||||
|
selection-color: $on_accent;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
QSpinBox::up-button, QSpinBox::down-button {
|
||||||
|
background-color: $elevated;
|
||||||
|
width: 16px;
|
||||||
|
border-left: 1px solid $border;
|
||||||
|
}
|
||||||
|
QCheckBox {
|
||||||
|
color: $text;
|
||||||
|
spacing: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Listes ---------- */
|
||||||
|
QListWidget {
|
||||||
|
background-color: $panel;
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
QListWidget::item {
|
||||||
|
padding: 5px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
QListWidget::item:hover {
|
||||||
|
background-color: $hover;
|
||||||
|
}
|
||||||
|
QListWidget::item:selected {
|
||||||
|
background-color: $accent;
|
||||||
|
color: $on_accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Splitter ---------- */
|
||||||
|
QSplitter::handle {
|
||||||
|
background-color: $border;
|
||||||
|
}
|
||||||
|
QSplitter::handle:horizontal {
|
||||||
|
width: 2px;
|
||||||
|
}
|
||||||
|
QSplitter::handle:vertical {
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
QSplitter::handle:hover {
|
||||||
|
background-color: $accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Barres de defilement ---------- */
|
||||||
|
QScrollBar:vertical {
|
||||||
|
background: $base;
|
||||||
|
width: 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:vertical {
|
||||||
|
background: $scroll;
|
||||||
|
min-height: 24px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:vertical:hover {
|
||||||
|
background: $scroll_hover;
|
||||||
|
}
|
||||||
|
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
QScrollBar:horizontal {
|
||||||
|
background: $base;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:horizontal {
|
||||||
|
background: $scroll;
|
||||||
|
min-width: 24px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Barres de progression ---------- */
|
||||||
|
QProgressBar {
|
||||||
|
background-color: $input;
|
||||||
|
border: 1px solid $border_strong;
|
||||||
|
border-radius: 5px;
|
||||||
|
text-align: center;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
QProgressBar::chunk {
|
||||||
|
background-color: $accent;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Etiquettes ---------- */
|
||||||
|
QLabel {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
QLabel[heading="true"] {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
QToolTip {
|
||||||
|
background-color: $elevated;
|
||||||
|
color: $text;
|
||||||
|
border: 1px solid $border_strong;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
12
run.bat
Normal file
12
run.bat
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
@echo off
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
if not exist venv (
|
||||||
|
echo L'environnement virtuel n'existe pas. Lancez d'abord setup.bat
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
call venv\Scripts\activate.bat
|
||||||
|
python main.py
|
||||||
|
if errorlevel 1 pause
|
||||||
23
setup.bat
Normal file
23
setup.bat
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
if not exist venv (
|
||||||
|
echo Creation de l'environnement virtuel...
|
||||||
|
py -3 -m venv venv
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Erreur: impossible de creer le venv. Verifiez que Python est installe et accessible via "py -3".
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
call venv\Scripts\activate.bat
|
||||||
|
|
||||||
|
echo Installation des dependances...
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Installation terminee. Lancez run.bat pour demarrer l'application.
|
||||||
|
pause
|
||||||
50
tools/create_shortcut.ps1
Normal file
50
tools/create_shortcut.ps1
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Cree un raccourci Windows pour lancer l'application sans fenetre de console.
|
||||||
|
#
|
||||||
|
# Usage : clic droit sur ce fichier > "Executer avec PowerShell"
|
||||||
|
# ou : powershell -ExecutionPolicy Bypass -File tools\create_shortcut.ps1
|
||||||
|
#
|
||||||
|
# Options :
|
||||||
|
# -Desktop place aussi une copie sur le Bureau
|
||||||
|
# -StartMenu place aussi une copie dans le menu Demarrer
|
||||||
|
|
||||||
|
param(
|
||||||
|
[switch]$Desktop,
|
||||||
|
[switch]$StartMenu
|
||||||
|
)
|
||||||
|
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
$target = Join-Path $root "venv\Scripts\pythonw.exe" # pythonw = pas de console noire
|
||||||
|
$script = Join-Path $root "main.py"
|
||||||
|
$icon = Join-Path $root "resources\app.ico"
|
||||||
|
|
||||||
|
if (-not (Test-Path $target)) {
|
||||||
|
Write-Host "Environnement virtuel introuvable. Lancez d'abord setup.bat." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if (-not (Test-Path $icon)) {
|
||||||
|
# l'icone se regenere depuis l'application elle-meme
|
||||||
|
& (Join-Path $root "venv\Scripts\python.exe") (Join-Path $root "tools\make_icon.py")
|
||||||
|
}
|
||||||
|
|
||||||
|
$shell = New-Object -ComObject WScript.Shell
|
||||||
|
|
||||||
|
function New-AppShortcut([string]$path) {
|
||||||
|
$lnk = $shell.CreateShortcut($path)
|
||||||
|
$lnk.TargetPath = $target
|
||||||
|
$lnk.Arguments = '"' + $script + '"'
|
||||||
|
$lnk.WorkingDirectory = $root
|
||||||
|
$lnk.IconLocation = $icon
|
||||||
|
$lnk.Description = "Client SSH/SFTP personnel"
|
||||||
|
$lnk.Save()
|
||||||
|
Write-Host "Raccourci cree : $path" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
New-AppShortcut (Join-Path $root "PySSH Client.lnk")
|
||||||
|
|
||||||
|
if ($Desktop) {
|
||||||
|
New-AppShortcut (Join-Path ([Environment]::GetFolderPath("Desktop")) "PySSH Client.lnk")
|
||||||
|
}
|
||||||
|
if ($StartMenu) {
|
||||||
|
$programs = Join-Path ([Environment]::GetFolderPath("StartMenu")) "Programs"
|
||||||
|
New-AppShortcut (Join-Path $programs "PySSH Client.lnk")
|
||||||
|
}
|
||||||
34
tools/make_icon.py
Normal file
34
tools/make_icon.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""Genere resources/app.ico a partir de l'icone dessinee par l'application.
|
||||||
|
|
||||||
|
A relancer seulement si l'icone change : le fichier .ico est necessaire au
|
||||||
|
raccourci Windows (et a un futur packaging), alors que l'application dessine
|
||||||
|
la sienne en memoire au demarrage.
|
||||||
|
|
||||||
|
Usage : venv\\Scripts\\python.exe tools\\make_icon.py
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from PySide6.QtCore import QSize # noqa: E402
|
||||||
|
from PySide6.QtGui import QIcon # noqa: E402
|
||||||
|
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||||
|
|
||||||
|
from app.theme import build_app_icon # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
app = QApplication(sys.argv) # noqa: F841 - requis pour manipuler des QPixmap
|
||||||
|
icon: QIcon = build_app_icon()
|
||||||
|
target = ROOT / "resources" / "app.ico"
|
||||||
|
# plusieurs tailles pour un rendu net dans la barre des taches et l'explorateur
|
||||||
|
pixmap = icon.pixmap(QSize(256, 256))
|
||||||
|
if not pixmap.save(str(target), "ICO"):
|
||||||
|
raise SystemExit(f"Echec de l'ecriture de {target}")
|
||||||
|
print(f"Icone generee : {target}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user