From aa1494270975900e4cfa539983f865953aab63de Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 4 Sep 2026 08:48:28 -0400 Subject: [PATCH] [ADD] vpn : parcourir les profils AnyConnect au lieu de taper le chemin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Personne ne retient « /opt/cisco/secureclient/vpn/profile ». Le chemin tapé oblige à le connaître ; le parcours seul n'aide pas quand le fichier vient d'ailleurs, d'un courriel ou du portail d'un site. Les deux, dans cet ordre, et la saisie reprend la main dès qu'on sort du parcours sans rien choisir ou qu'on en rapporte un répertoire. Le parcours n'est proposé que si l'un des répertoires connus existe : l'ouvrir sur un chemin absent afficherait une liste vide, ce qui ressemble à une panne. Vérifié : 7 tests unitaires, dont chacun des replis. --- EN --- Nobody remembers "/opt/cisco/secureclient/vpn/profile". A typed path requires knowing it; browsing alone does not help when the file comes from elsewhere, from an e-mail or a site's portal. Both, in that order, and typing takes over as soon as one leaves the browser without choosing anything, or brings back a directory from it. Browsing is offered only if one of the known directories exists: opening it on an absent path would show an empty list, which looks like a fault. Checked: 7 unit tests, one for each fallback. Assisted-by: Claude Opus 5 --- script/todo/todo_i18n.py | 8 ++++ script/todo/vpn_menu.py | 59 ++++++++++++++++++++++++++++- test/test_vpn_menu.py | 80 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 2 deletions(-) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index f3b858e..29a821a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -11779,6 +11779,14 @@ TRANSLATIONS = { " maintained." ), }, + "Found": { + "fr": "Trouvé", + "en": "Found", + }, + "Browse it? (Y/n)": { + "fr": "Le parcourir ? (O/n)", + "en": "Browse it? (Y/n)", + }, "Install it as well? (y/N)": { "fr": "L'installer aussi ? (o/N)", "en": "Install it as well? (y/N)", diff --git a/script/todo/vpn_menu.py b/script/todo/vpn_menu.py index 76246a5..5c5f0be 100644 --- a/script/todo/vpn_menu.py +++ b/script/todo/vpn_menu.py @@ -23,6 +23,13 @@ import os import click +try: + from script.todo import todo_file_browser +except Exception: + # urwid peut manquer : le parcours devient indisponible, la saisie + # directe reste. Un menu qui ne s'ouvre plus serait pire. + todo_file_browser = None + from script.todo.todo_i18n import t from script.vpn import anyconnect_xml, presets, profiles from script.vpn.drivers import DRIVERS, get_driver @@ -65,6 +72,17 @@ PRESET_REPLAYED_NOTE = ( " everything personal is kept." ) +# Répertoires où chercher un profil AnyConnect, dans l'ordre. Les deux +# premiers sont ceux du client de Cisco — le nom a changé entre AnyConnect +# et Secure Client. Les deux derniers parce qu'un site le distribue aussi +# par courriel ou par son portail, et le fichier atterrit alors là. +ANYCONNECT_DIRS = ( + "/opt/cisco/secureclient/vpn/profile", + "/opt/cisco/anyconnect/profile", + "~/Downloads", + "~/Téléchargements", +) + # Où le client de Cisco dépose les profils qu'un site distribue. Le dire # évite d'avoir à le chercher, et c'est le seul endroit où il se trouve # quand le client graphique a déjà servi sur la machine. @@ -386,6 +404,43 @@ class VpnMenuMixin: ) self._vpn_edit_profile(seed=seed) + def _vpn_select_xml(self): + """Chemin du profil `.xml`, "" si l'utilisateur renonce. + + Le parcours d'abord, la saisie ensuite, parce que ni l'un ni l'autre + ne suffit : le parcours part des répertoires du client de Cisco et + n'aide pas si le fichier vient d'ailleurs ; le chemin tapé oblige à + le connaître, or personne ne retient + « /opt/cisco/secureclient/vpn/profile ». + + Le parcours n'est PROPOSÉ que si un de ces répertoires existe : + l'ouvrir sur un chemin absent afficherait une liste vide, ce qui + ressemble à une panne. + """ + start = next( + ( + path + for path in (os.path.expanduser(d) for d in ANYCONNECT_DIRS) + if os.path.isdir(path) + ), + "", + ) + if todo_file_browser is not None and start: + print(f" {t('Found')} : {start}") + if self._is_yes(input(f"{t('Browse it? (Y/n)')} : ") or "o"): + self._xml_path = "" + browser = todo_file_browser.FileBrowser( + start, self._on_xml_selected + ) + browser.run_main_frame() + if self._xml_path and os.path.isfile(self._xml_path): + return self._xml_path + answer = input(f"{t('Path to the .xml profile')} : ").strip() + return os.path.expanduser(answer) if answer else "" + + def _on_xml_selected(self, path): + self._xml_path = path + def _vpn_import_anyconnect(self): """Transforme un profil AnyConnect (`.xml`) en préréglages. @@ -398,11 +453,11 @@ class VpnMenuMixin: nomme un établissement. """ print(t(ANYCONNECT_LOCATION_NOTE)) - path = input(f"{t('Path to the .xml profile')} : ").strip() + path = self._vpn_select_xml() if not path: return try: - found = anyconnect_xml.parse_file(os.path.expanduser(path)) + found = anyconnect_xml.parse_file(path) except anyconnect_xml.ProfileXmlError as error: print(f"\n✗ {error}") return diff --git a/test/test_vpn_menu.py b/test/test_vpn_menu.py index 0cf21e2..49cb9b5 100644 --- a/test/test_vpn_menu.py +++ b/test/test_vpn_menu.py @@ -655,6 +655,86 @@ class SsoHelperOffer(MenuBase): self.assertIn("entretenu", printed) +class ChoosingTheXmlProfile(MenuBase): + """Le choix du fichier `.xml` : parcours d'abord, saisie ensuite. + + Ni l'un ni l'autre ne suffit. Le parcours part des répertoires du + client de Cisco et n'aide pas si le fichier vient d'ailleurs ; le + chemin tapé oblige à le connaître, or personne ne retient + « /opt/cisco/secureclient/vpn/profile ». + """ + + def setUp(self): + super().setUp() + self.xml = os.path.join(self.tmp.name, "campus.xml") + with open(self.xml, "w") as fh: + fh.write("") + + def browsing(self, chosen, *answers, dirs=None): + """Déroule `_vpn_select_xml` avec un parcours qui rend `chosen`.""" + picked = [] + + class FauxNavigateur: + def __init__(self, start, callback): + picked.append(start) + self._callback = callback + + def run_main_frame(inner): + if chosen is not None: + inner._callback(chosen) + + with patch( + "script.todo.vpn_menu.ANYCONNECT_DIRS", + dirs if dirs is not None else (self.tmp.name,), + ): + with patch( + "script.todo.vpn_menu.todo_file_browser.FileBrowser", + FauxNavigateur, + ): + with self.answering(*answers): + with redirect_stdout(io.StringIO()): + return self.todo._vpn_select_xml(), picked + + def test_the_browser_starts_in_the_cisco_directory(self): + path, started = self.browsing(self.xml, "") + self.assertEqual(path, self.xml) + self.assertEqual(started, [self.tmp.name]) + + def test_declining_the_browser_falls_back_to_typing(self): + path, started = self.browsing(self.xml, "n", self.xml) + self.assertEqual(path, self.xml) + self.assertEqual(started, [], "le parcours ne devait pas s'ouvrir") + + def test_leaving_the_browser_empty_falls_back_to_typing(self): + """On peut sortir du parcours sans rien choisir : la saisie reste.""" + path, _ = self.browsing(None, "", self.xml) + self.assertEqual(path, self.xml) + + def test_a_path_that_is_not_a_file_does_not_pass_as_chosen(self): + """Le parcours peut rendre un répertoire : il ne vaut pas fichier, + et la saisie reprend la main.""" + path, _ = self.browsing(self.tmp.name, "", self.xml) + self.assertEqual(path, self.xml) + + def test_no_cisco_directory_means_no_offer(self): + """Ouvrir un parcours sur un chemin absent afficherait une liste + vide, ce qui ressemble à une panne. Liste de réponses courte : si + la question était posée, le test lèverait StopIteration.""" + path, started = self.browsing( + self.xml, self.xml, dirs=("/nowhere/cisco",) + ) + self.assertEqual(path, self.xml) + self.assertEqual(started, []) + + def test_a_typed_path_is_expanded(self): + path, _ = self.browsing(None, "n", "~") + self.assertEqual(path, os.path.expanduser("~")) + + def test_giving_up_returns_nothing(self): + path, _ = self.browsing(None, "n", "") + self.assertEqual(path, "") + + class FromPreset(MenuBase): """Le chemin « créer un profil à partir d'un préréglage ».