Files
pyssh-client/app/main_window.py
Florian 9216f636bd feat: ajouter le Credential Vault (memorisation securisee optionnelle)
Par defaut, inchange : les mots de passe sont toujours redemandes a chaque
connexion. Nouvelle case a cocher optionnelle dans l'editeur de session,
visible seulement en authentification par mot de passe : "Memoriser le mot
de passe de maniere securisee".

- app/credentials.py : abstraction CredentialStore (set_secret/get_secret/
  delete_secret/is_available), pour supporter plus tard d'autres systemes que
  Windows. WindowsCredentialStore utilise Windows Credential Manager
  (CredWrite/CredRead/CredDelete) via pywin32-ctypes - deja present dans
  l'environnement (dependance de PyInstaller), mais pas encore declare comme
  dependance d'execution : ajoute a requirements.txt (marque sys_platform ==
  "win32"), sinon la fonctionnalite aurait silencieusement ete indisponible
  sur une installation propre. NullCredentialStore en repli si indisponible :
  jamais d'ecriture en clair a la place.
- Session.password_storage ("none" par defaut | "secure") : seulement le mode
  est stocke dans sessions.json, jamais le secret. Le mot de passe est ecrit/
  lu dans le trousseau via une cle applicative (session:<id>), et supprime
  automatiquement si l'utilisateur desactive la memorisation ou supprime la
  session.
- Un bastion en mode "session PySSH" reutilise un mot de passe deja memorise
  pour cette session (lecture seule depuis ce chemin : l'ecriture ne se fait
  que via l'ouverture directe de la session concernee).

tests/test_credentials.py : NullCredentialStore, aller-retour reel avec
Windows Credential Manager (nettoye et verifie apres coup, aucune entree
residuelle), integration ConnectionController (memorise puis reutilise sans
redemander), suppression en cascade, reutilisation par un bastion, et
verification que le secret n'apparait jamais dans la serialisation JSON de
la session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 03:21:32 +02:00

266 lines
12 KiB
Python

"""Fenetre principale : assemble le panneau des sessions, les onglets, les menus.
La logique metier vit dans des controleurs dedies (app.controllers) et les widgets
d'onglet dans app.ui.tabs ; cette classe se contente de les construire et de les
relier.
"""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtGui import QAction, QActionGroup, QKeySequence
from PySide6.QtWidgets import (
QMainWindow, QDockWidget, QMessageBox, QStatusBar, QApplication,
)
from app.session_manager import SessionStore
from app.key_manager import KeyManager
from app.local_terminal import SHELL_LABELS, DEFAULT_SHELL
from app.terminal_widget import TerminalWidget
from app.dialogs.key_dialog import KeyManagerDialog
from app.dialogs.known_hosts_dialog import KnownHostsDialog
from app.dialogs.workspace_dialog import WorkspaceDialog
from app.dialogs.snippet_dialog import SnippetManagerDialog
from app.dialogs.quick_connect_dialog import QuickConnectDialog
from app.theme import THEME_LABELS, apply_theme, current_theme, save_theme
from app.workspace import WorkspaceStore
from app.snippets import SnippetStore
from app.groups import GroupSettingsStore
from app.credentials import default_credential_store
from app.controllers.broadcast_manager import BroadcastManager
from app.controllers.session_controller import SessionController
from app.controllers.connection_controller import ConnectionController
from app.controllers.workspace_manager import WorkspaceManager
from app.ui.split_pane import SplitContainer
# re-exporte pour compatibilite (utilise par tests/test_detect.py et du code existant)
from app.services.ssh_target import detect_ssh_target # noqa: F401
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.workspace_store = WorkspaceStore()
self.snippet_store = SnippetStore()
self.group_store = GroupSettingsStore()
self.credential_store = default_credential_store()
self.broadcast = BroadcastManager(self._iter_tabs, self)
self.connection_controller = ConnectionController(
self.session_store, self.key_manager, self.broadcast,
snippet_store=self.snippet_store, credential_store=self.credential_store, parent_widget=self,
)
self.session_controller = SessionController(
self.session_store, self.key_manager, self.broadcast.button, parent_widget=self,
group_store=self.group_store, credential_store=self.credential_store,
)
self.workspace_manager = WorkspaceManager(
self.workspace_store, self.session_store, self.connection_controller, parent_widget=self,
)
self._wire_controllers()
self._build_menu()
self._build_sidebar()
self.setCentralWidget(self.connection_controller.tabs)
self.setStatusBar(QStatusBar())
# demarre directement dans un terminal utilisable, sans avoir a en choisir un -
# sauf si un workspace vient d'etre restaure automatiquement (il a deja le sien)
if not self.workspace_manager.try_restore_last_on_startup():
self.connection_controller.open_local_terminal(DEFAULT_SHELL)
# ---------- acces retro-compatible (tests, code externe) ----------
@property
def tabs(self):
return self.connection_controller.tabs
def _iter_tabs(self):
"""Widgets de terminal joignables par le Multi-Send : les onglets de
premier niveau, et les panneaux a l'interieur des onglets divises."""
tabs = self.connection_controller.tabs
result = []
for i in range(tabs.count()):
widget = tabs.widget(i)
if isinstance(widget, SplitContainer):
result.extend(pane.content for pane in widget.panes)
else:
result.append(widget)
return result
# ---------- cablage entre controleurs ----------
def _wire_controllers(self) -> None:
sc, cc = self.session_controller, self.connection_controller
sc.open_session_requested.connect(cc.open_session)
sc.open_sftp_requested.connect(cc.open_sftp_session)
sc.open_local_shell_requested.connect(cc.open_local_terminal)
sc.open_openssh_host_requested.connect(cc.open_openssh_host)
sc.quick_sftp_requested.connect(cc.quick_sftp)
sc.manage_keys_requested.connect(self.open_key_manager)
sc.manage_known_hosts_requested.connect(self.open_known_hosts_manager)
cc.session_saved.connect(sc.reload)
# ---------- construction UI ----------
def _build_menu(self) -> None:
file_menu = self.menuBar().addMenu("&Fichier")
quick_connect_action = QAction("Recherche rapide...", self)
quick_connect_action.setShortcut(QKeySequence("Ctrl+K"))
quick_connect_action.triggered.connect(self.open_quick_connect)
file_menu.addAction(quick_connect_action)
file_menu.addSeparator()
new_session_action = QAction("Nouvelle session...", self)
new_session_action.setShortcut(QKeySequence("Ctrl+N"))
new_session_action.triggered.connect(self.session_controller.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.connection_controller.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.connection_controller.quick_sftp)
file_menu.addAction(quick_sftp_action)
file_menu.addSeparator()
default_action = QAction(
f"Nouveau terminal local ({SHELL_LABELS.get(DEFAULT_SHELL, DEFAULT_SHELL)})", self
)
default_action.setShortcut(QKeySequence("Ctrl+Shift+T"))
default_action.triggered.connect(lambda: self.connection_controller.open_local_terminal(DEFAULT_SHELL))
file_menu.addAction(default_action)
others_menu = file_menu.addMenu("Autre terminal local")
for shell_id, label in SHELL_LABELS.items():
others_menu.addAction(label, lambda s=shell_id: self.connection_controller.open_local_terminal(s))
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")
self.toggle_sidebar_action = QAction("Panneau des sessions", self, checkable=True)
self.toggle_sidebar_action.setChecked(True)
self.toggle_sidebar_action.setShortcut(QKeySequence("F9"))
self.toggle_sidebar_action.triggered.connect(self.toggle_sidebar)
view_menu.addAction(self.toggle_sidebar_action)
view_menu.addSeparator()
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)
workspaces_menu = self.menuBar().addMenu("&Workspaces")
manage_workspaces_action = QAction("Gestionnaire de workspaces...", self)
manage_workspaces_action.setShortcut(QKeySequence("Ctrl+Shift+W"))
manage_workspaces_action.triggered.connect(self.open_workspace_manager)
workspaces_menu.addAction(manage_workspaces_action)
snippets_menu = self.menuBar().addMenu("&Snippets")
manage_snippets_action = QAction("Gestionnaire de snippets...", self)
manage_snippets_action.triggered.connect(self.open_snippet_manager)
snippets_menu.addAction(manage_snippets_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)
# Closable : sans cette option, le panneau n'a pas de bouton de fermeture
# et ne peut pas etre masque.
dock.setFeatures(
QDockWidget.DockWidgetMovable
| QDockWidget.DockWidgetFloatable
| QDockWidget.DockWidgetClosable
)
self.sessions_dock = dock
dock.setWidget(self.session_controller.widget)
self.addDockWidget(Qt.LeftDockWidgetArea, dock)
self.resizeDocks([dock], [300], Qt.Horizontal)
# garde la case du menu Affichage synchronisee si on ferme le panneau par sa croix
dock.visibilityChanged.connect(self.toggle_sidebar_action.setChecked)
# ---------- cles / affichage / a propos ----------
def toggle_sidebar(self, visible: bool | None = None) -> None:
"""Affiche ou masque le panneau des sessions (F9)."""
if visible is None:
visible = not self.sessions_dock.isVisible()
self.sessions_dock.setVisible(visible)
self.toggle_sidebar_action.setChecked(visible)
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 open_workspace_manager(self) -> None:
dialog = WorkspaceDialog(self.workspace_manager, parent=self)
dialog.exec()
def open_snippet_manager(self) -> None:
dialog = SnippetManagerDialog(self.snippet_store, parent=self)
dialog.exec()
def open_quick_connect(self) -> None:
dialog = QuickConnectDialog(
self.session_store, self.connection_controller, self.workspace_manager,
self.snippet_store, self.session_controller, self.key_manager, 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:
self.connection_controller.close_all()
super().closeEvent(event)