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 ».