fix(ui): plus d'abort à l'arrêt, et temps restant de l'accès (v0.8.7)
prepare_quit() attend les threads en cours avant toute destruction, mais sa liste en oubliait deux : le sondage d'état ajouté en 0.8.6 — ma régression — et le thread de validation de la fenêtre de saisie, parenté à cette fenêtre depuis la v0.8.2 et jamais attendu. Fermer la fenêtre de code pendant qu'une requête est en vol suffisait à avorter le process, ce qui arrive dès que le serveur tarde. Reproduit avec un serveur qui accepte sans jamais répondre, puis vérifié corrigé sur les deux chemins. Le sondage est ramené à 4 s : l'arrêt l'attend désormais, et dix secondes y seraient une interface figée. Son échec ne coûte rien, contrairement à une validation ratée qui gâche un code déjà saisi. Ajoute le temps restant sur la ligne Accès distant, et un repli pour les serveurs sans GET /api/session : sur un 404, le client observe si le trafic atteint le réseau distant plutôt que de réclamer un code déjà validé. Un 403 reste une réponse qui fait autorité. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -234,6 +234,22 @@ class AccessCodeDialog(QDialog):
|
||||
self._btn_ok.setText("Vérification…" if busy else "Valider")
|
||||
self._code_input.setEnabled(not busy)
|
||||
|
||||
def wait_for_worker(self, msecs: int) -> None:
|
||||
"""Attend la fin de la requête en cours, s'il y en a une.
|
||||
|
||||
Le worker est parenté à cette fenêtre : la détruire pendant qu'il
|
||||
tourne encore avorte le process (« QThread: Destroyed while thread is
|
||||
still running »). La fenêtre survit à sa propre fermeture — elle est
|
||||
parentée à la fenêtre principale — donc le moment critique est l'arrêt
|
||||
de l'application, qui appelle ceci avant que quoi que ce soit ne soit
|
||||
détruit.
|
||||
"""
|
||||
try:
|
||||
if self._worker is not None and self._worker.isRunning():
|
||||
self._worker.wait(msecs)
|
||||
except RuntimeError:
|
||||
pass # objet Qt déjà détruit
|
||||
|
||||
def message(self) -> str:
|
||||
return self._message
|
||||
|
||||
|
||||
+37
-2
@@ -52,6 +52,9 @@ class MainWindow(QMainWindow):
|
||||
# déclenché sans invite est déjà parti.
|
||||
self._probe_worker: ValueWorker | None = None
|
||||
self._probe_prompt = False
|
||||
# Conservée après fermeture : la fenêtre reste parentée à celle-ci, et
|
||||
# son thread doit être attendu avant destruction (cf. prepare_quit).
|
||||
self._access_dialog: AccessCodeDialog | None = None
|
||||
self._reconnect_failures = 0
|
||||
self._quitting = False
|
||||
self._warned_foreign: set[str] = set()
|
||||
@@ -621,6 +624,24 @@ class MainWindow(QMainWindow):
|
||||
return True
|
||||
return datetime.now(timezone.utc) < self._access_until
|
||||
|
||||
def _remaining_text(self) -> str:
|
||||
"""Temps restant avant l'échéance, tel qu'on le lit d'un coup d'œil.
|
||||
|
||||
Rafraîchi au rythme du sondage de statut (3 s) : la minute affichée
|
||||
est donc juste, ce qui suffit pour une fenêtre qui se compte en
|
||||
heures. Aucun minuteur supplémentaire pour l'entretenir.
|
||||
"""
|
||||
seconds = int((self._access_until
|
||||
- datetime.now(timezone.utc)).total_seconds())
|
||||
# Arrondi à la minute, et non tronqué : une fenêtre de 8 h qui vient
|
||||
# de s'ouvrir doit se lire « 8 h 00 min », pas « 7 h 59 min ».
|
||||
minutes = (seconds + 30) // 60
|
||||
if minutes >= 60:
|
||||
return f"{minutes // 60} h {minutes % 60:02d} min"
|
||||
if minutes >= 1:
|
||||
return f"{minutes} min"
|
||||
return f"{max(seconds, 0)} s"
|
||||
|
||||
def _access_state(self) -> str:
|
||||
"""État de l'autorisation réseau accordée par le serveur.
|
||||
|
||||
@@ -639,7 +660,13 @@ class MainWindow(QMainWindow):
|
||||
if self._access_valid():
|
||||
if self._access_until is None:
|
||||
return "Ouvert ✓"
|
||||
return f"Ouvert ✓ jusqu'à {self._access_until.astimezone():%H:%M}"
|
||||
fin = self._access_until.astimezone()
|
||||
# La date n'est rappelée que si l'échéance n'est pas aujourd'hui :
|
||||
# la durée est réglable jusqu'à 168 h côté serveur, « jusqu'à
|
||||
# 02:30 » serait alors ambigu.
|
||||
quand = (f"{fin:%H:%M}" if fin.date() == datetime.now().date()
|
||||
else f"{fin:%d/%m à %H:%M}")
|
||||
return f"Ouvert ✓ jusqu'à {quand} (reste {self._remaining_text()})"
|
||||
if self._access_open:
|
||||
# L'autorisation a couru puis expiré : le dire, plutôt que de
|
||||
# laisser croire qu'aucun code n'a jamais été saisi.
|
||||
@@ -902,6 +929,7 @@ class MainWindow(QMainWindow):
|
||||
def _open_access_dialog(self):
|
||||
"""Ouvre la fenêtre de saisie et journalise la réponse du serveur."""
|
||||
dlg = AccessCodeDialog(self._cfg, self)
|
||||
self._access_dialog = dlg
|
||||
accepted = dlg.exec() == AccessCodeDialog.DialogCode.Accepted
|
||||
self._access_open = accepted
|
||||
self._access_until = dlg.access_until() if accepted else None
|
||||
@@ -1035,14 +1063,21 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# Laisser les threads en cours se terminer avant de démonter le tunnel :
|
||||
# une connexion et une déconnexion simultanées laisseraient un état mixte.
|
||||
for worker in (self._worker, self._ping_worker):
|
||||
for worker in (self._worker, self._ping_worker, self._probe_worker):
|
||||
try:
|
||||
if worker is not None and worker.isRunning():
|
||||
worker.wait(15000)
|
||||
except RuntimeError:
|
||||
pass # objet Qt déjà détruit
|
||||
if self._access_dialog is not None:
|
||||
# Une validation peut être partie puis la fenêtre fermée aussitôt :
|
||||
# son thread survit à la fermeture et ne doit pas être détruit avec
|
||||
# la fenêtre principale.
|
||||
self._access_dialog.wait_for_worker(15000)
|
||||
self._access_dialog = None
|
||||
self._worker = None
|
||||
self._ping_worker = None
|
||||
self._probe_worker = None
|
||||
|
||||
if self._session_id is not None:
|
||||
hist.end_session(self._session_id, self._prev_rx or 0,
|
||||
|
||||
Reference in New Issue
Block a user