diff --git a/bin/.local/bin/labfy-removable-media b/bin/.local/bin/labfy-removable-media index dcb90a1f0..31def69db 100755 --- a/bin/.local/bin/labfy-removable-media +++ b/bin/.local/bin/labfy-removable-media @@ -1,8 +1,9 @@ #!/usr/bin/python3 -"""Daemon d'accès conservateur aux supports amovibles via UDisks2.""" +"""Daemon d'accès aux supports bloc UDisks2 et aux appareils MTP Gio.""" from __future__ import annotations +import hashlib import json import logging import os @@ -12,16 +13,18 @@ import sys import tempfile import threading import time +import uuid from dataclasses import dataclass, replace from pathlib import Path from typing import Any, Callable SCHEMA = "labfy.removable-media" -VERSION = 1 +VERSION = 2 MAX_REQUEST = 16 * 1024 MAX_ERROR = 240 MAX_CLIENTS = 8 READ_TIMEOUT = 2.0 +OPERATION_TIMEOUT_SECONDS = 30 SOCKET_NAME = "labfy-removable-media.sock" STATE_NAME = "labfy-removable-media.json" ALLOWED_OPERATIONS = {"list", "mount", "unmount", "open", "safe-remove"} @@ -55,6 +58,17 @@ def public_operation_error(error: BaseException, operation: str) -> str: return bounded_error(error) +def public_mtp_operation_error(error: BaseException, operation: str) -> str: + """Ne publie jamais le détail Gio, qui peut contenir l'URI MTP.""" + if isinstance(error, RequestError): + return bounded_error(error) + return { + "mount": "Impossible de monter l'appareil MTP.", + "unmount": "Impossible de démonter l'appareil MTP.", + "safe-remove": "Impossible de fermer la session MTP.", + }.get(operation, "Impossible d'effectuer l'opération sur l'appareil MTP.") + + def runtime_dir() -> Path: value = os.environ.get("XDG_RUNTIME_DIR") if not value or not os.path.isabs(value): @@ -93,17 +107,35 @@ class FilesystemRecord: removable: bool ejectable: bool power_off_capable: bool + kind: str = "block" + mounted_state: bool | None = None + + @property + def mounted(self) -> bool: + return bool(self.mount_point) if self.mounted_state is None else self.mounted_state + + @property + def safe_remove_runtime_id(self) -> str: + return self.drive_path + + def actions(self) -> list[str]: + actions = ["unmount", "open"] if self.mount_point else ["mount", "open"] + if self.power_off_capable or self.ejectable: + actions.append("safe-remove") + return actions def public(self, busy: bool = False, error: str = "") -> dict[str, Any]: return { "runtime_id": self.path, - "drive_runtime_id": self.drive_path, + "safe_remove_runtime_id": self.safe_remove_runtime_id, + "kind": self.kind, + "actions": self.actions(), "display_name": self.display_name, "label": self.label, "filesystem": self.filesystem, "size_bytes": self.size_bytes, "mount_point": self.mount_point, - "mounted": bool(self.mount_point), + "mounted": self.mounted, "removable": self.removable, "ejectable": self.ejectable, "power_off_capable": self.power_off_capable, @@ -112,6 +144,22 @@ class FilesystemRecord: } +@dataclass(frozen=True) +class MTPRecord(FilesystemRecord): + """Vue publique d'un volume MTP, sans URI ni identité matérielle.""" + + kind: str = "mtp" + + def actions(self) -> list[str]: + if not self.mounted: + return ["mount"] + actions = ["unmount"] + if self.mount_point: + actions.append("open") + actions.append("safe-remove") + return actions + + class UDisksBackend: """Adaptateur Gio minimal ; l'ObjectManager reste l'unique source de vérité.""" @@ -325,6 +373,266 @@ class UDisksBackend: return sorted(result) +class MTPBackend: + """Adaptateur des volumes MTP exposés par GVFS via Gio.VolumeMonitor.""" + + def __init__(self, Gio: Any, GLib: Any, monitor: Any | None = None) -> None: + self.Gio = Gio + self.GLib = GLib + self.monitor = monitor or Gio.VolumeMonitor.get() + self._nonce = uuid.uuid4().hex + self._counter = 0 + self._ids_by_identity: dict[str, str] = {} + self._identity_by_id: dict[str, str] = {} + self._volumes_by_id: dict[str, Any] = {} + + @staticmethod + def _is_mtp(volume: Any) -> bool: + try: + root = volume.get_activation_root() + return root is not None and str(root.get_uri_scheme()).lower() == "mtp" + except (AttributeError, TypeError): + return False + + def _live_volumes(self) -> list[Any]: + return [volume for volume in self.monitor.get_volumes() if self._is_mtp(volume)] + + @staticmethod + def _identity(volume: Any) -> str: + """Identifie une présence sans conserver ni publier son URI MTP.""" + root = volume.get_activation_root() + uri = str(root.get_uri()) + return hashlib.sha256(uri.encode("utf-8")).hexdigest() + + def _runtime_id(self, volume: Any) -> str: + identity = self._identity(volume) + runtime_id = self._ids_by_identity.get(identity) + if runtime_id is None: + self._counter += 1 + runtime_id = f"mtp:{self._nonce}:{self._counter}" + self._ids_by_identity[identity] = runtime_id + self._identity_by_id[runtime_id] = identity + self._volumes_by_id[runtime_id] = volume + return runtime_id + + def _forget_volume(self, volume: Any) -> None: + if not self._is_mtp(volume): + return + identity = self._identity(volume) + runtime_id = self._ids_by_identity.pop(identity, None) + if runtime_id is not None: + self._identity_by_id.pop(runtime_id, None) + self._volumes_by_id.pop(runtime_id, None) + + @staticmethod + def _mount_path(volume: Any) -> str: + mount = volume.get_mount() + if mount is None: + return "" + root = mount.get_root() + if root is None: + return "" + # CONTRACT: ce chemin local est fourni par GVFS. Une URI MTP ne doit + # jamais servir à reconstruire à la main un chemin sous /run/user. + return str(root.get_path() or "") + + @staticmethod + def _size(volume: Any) -> int: + mount = volume.get_mount() + if mount is None: + return 0 + try: + info = mount.get_root().query_filesystem_info("filesystem::size", None) + return int(info.get_attribute_uint64("filesystem::size")) + except Exception: + return 0 + + def records(self) -> list[FilesystemRecord]: + volumes = self._live_volumes() + live_identities = {self._identity(volume) for volume in volumes} + for identity, runtime_id in list(self._ids_by_identity.items()): + if identity not in live_identities: + self._ids_by_identity.pop(identity, None) + self._identity_by_id.pop(runtime_id, None) + self._volumes_by_id.pop(runtime_id, None) + records: list[FilesystemRecord] = [] + for volume in volumes: + runtime_id = self._runtime_id(volume) + name = str(volume.get_name() or "Appareil Android") + mount = volume.get_mount() + display_name = name + if mount is not None: + try: + display_name = str(mount.get_name() or name) + except AttributeError: + pass + records.append( + MTPRecord( + path=runtime_id, + drive_path=runtime_id, + display_name=display_name, + label=name, + filesystem="mtp", + size_bytes=self._size(volume), + mount_point=self._mount_path(volume), + removable=True, + ejectable=False, + power_off_capable=False, + mounted_state=mount is not None, + ) + ) + return sorted(records, key=lambda item: item.path) + + def connect_changed(self, callback: Callable[[], None]) -> None: + def volume_removed(_monitor: Any, volume: Any, *_args: Any) -> None: + self._forget_volume(volume) + callback() + + self.monitor.connect("volume-removed", volume_removed) + for signal in ( + "volume-added", + "volume-changed", + "mount-added", + "mount-changed", + "mount-removed", + ): + self.monitor.connect(signal, lambda *_args: callback()) + + def _volume(self, runtime_id: str) -> Any: + # INVARIANT: relire le moniteur avant toute action et utiliser le wrapper + # vivant correspondant à la présence ayant reçu cet identifiant opaque. + identity = self._identity_by_id.get(runtime_id) + volume = next( + ( + item + for item in self._live_volumes() + if identity is not None and self._identity(item) == identity + ), + None, + ) + if volume is None: + raise RequestError("Appareil MTP introuvable ou retiré") + self._volumes_by_id[runtime_id] = volume + return volume + + def operate_async( + self, + runtime_id: str, + operation: str, + callback: Callable[[str | None, BaseException | None], None], + ) -> Callable[[], None]: + volume = self._volume(runtime_id) + cancellable = self.Gio.Cancellable.new() + finished = False + timeout_id: int | None = None + settle_id: int | None = None + + def complete(path: str | None = None, error: BaseException | None = None) -> None: + nonlocal finished + if finished: + return + finished = True + if timeout_id is not None: + self.GLib.source_remove(timeout_id) + if settle_id is not None: + self.GLib.source_remove(settle_id) + callback(path, error) + + def timed_out() -> bool: + cancellable.cancel() + complete(error=RequestError("Délai d'opération MTP dépassé")) + return False + + timeout_id = self.GLib.timeout_add_seconds(OPERATION_TIMEOUT_SECONDS, timed_out) + + def verify_postcondition(expect_mounted: bool) -> bool: + try: + current = self._volume(runtime_id) + mounted = current.get_mount() is not None + if mounted != expect_mounted: + return True + complete(self._mount_path(current) if mounted else "") + except BaseException as error: + complete(error=error) + return False + + def settle(expect_mounted: bool) -> None: + nonlocal settle_id + if not verify_postcondition(expect_mounted): + return + # WHY: GVFS peut publier GMount quelques instants après mount_finish. + # Le polling rend la main entre chaque lecture et reste couvert par + # le timeout/cancellable global de l'opération. + settle_id = self.GLib.timeout_add(100, verify_postcondition, expect_mounted) + + def mount_done(source: Any, result: Any, *_args: Any) -> None: + try: + source.mount_finish(result) + settle(True) + except BaseException as error: + complete(error=error) + + def unmount_done(source: Any, result: Any, *_args: Any) -> None: + try: + source.unmount_with_operation_finish(result) + settle(False) + except BaseException as error: + complete(error=error) + + try: + if operation == "mount": + volume.mount( + self.Gio.MountMountFlags.NONE, None, cancellable, mount_done + ) + elif operation in {"unmount", "safe-remove"}: + mount = volume.get_mount() + if mount is None: + if operation == "safe-remove": + raise RequestError("L'appareil MTP n'a pas de session à fermer") + complete("") + else: + # CONTRACT: le retrait MTP ferme uniquement la session GVFS ; + # il ne déclenche jamais UDisks PowerOff/Eject. + mount.unmount_with_operation( + self.Gio.MountUnmountFlags.NONE, + None, + cancellable, + unmount_done, + ) + else: + raise RequestError("Opération MTP non autorisée") + except BaseException as error: + complete(error=error) + + return cancellable.cancel + + +class RemovableMediaBackend: + """Agrège les deux sources sans modifier la politique UDisks existante.""" + + def __init__(self, block: UDisksBackend, mtp: MTPBackend) -> None: + self.block = block + self.mtp = mtp + + def records(self) -> list[FilesystemRecord]: + return self.block.records() + self.mtp.records() + + def connect_changed(self, callback: Callable[[], None]) -> None: + self.block.connect_changed(callback) + self.mtp.connect_changed(callback) + + def __getattr__(self, name: str) -> Any: + return getattr(self.block, name) + + def operate_mtp_async( + self, + runtime_id: str, + operation: str, + callback: Callable[[str | None, BaseException | None], None], + ) -> Callable[[], None]: + return self.mtp.operate_async(runtime_id, operation, callback) + + class MediaDaemon: """Modèle métier injectable et indépendant de Gio.""" @@ -391,7 +699,7 @@ class MediaDaemon: self._write_state() def start(self) -> None: - """Effectue le scan initial après l'abonnement aux événements UDisks.""" + """Effectue le scan initial après l'abonnement aux deux sources.""" self.devices_changed() def devices_changed(self) -> None: @@ -406,7 +714,10 @@ class MediaDaemon: confirmed: dict[str, str] = {} for item in newcomers: - if item.mount_point: + if item.mounted: + continue + if item.kind == "mtp": + self._start_mtp_operation(item, "mount", None) continue try: self.backend.mount(item.path) @@ -430,6 +741,35 @@ class MediaDaemon: ] self._write_state() + def _start_mtp_operation( + self, + record: FilesystemRecord, + operation: str, + done: Callable[[dict[str, Any] | BaseException], None] | None, + ) -> Callable[[], None]: + self._set_busy(record.safe_remove_runtime_id, True) + + def completed(path: str | None, error: BaseException | None) -> None: + with self._lock: + result_error: BaseException | None = None + if error is None: + self._errors.pop(record.path, None) + else: + message = public_mtp_operation_error(error, operation) + self._errors[record.path] = message + result_error = RequestError(message) + LOGGER.warning("Échec Gio pendant une opération MTP : %s", error) + self._busy_drives.discard(record.safe_remove_runtime_id) + self.refresh() + if done is not None: + done(result_error or {"ok": True, "state": self.state()}) + + try: + return self.backend.operate_mtp_async(record.path, operation, completed) + except BaseException as error: + completed(None, error) + return lambda: None + def _record(self, runtime_id: str) -> FilesystemRecord: # CONTRACT: on relit UDisks avant toute mutation ; un identifiant d'objet # disparu ou réutilisé ne doit jamais viser un ancien support en cache. @@ -524,8 +864,19 @@ class MediaDaemon: self.refresh() return {"ok": True, "state": self.state()} assert runtime_id is not None + self.refresh() + mtp_record = next( + (item for item in self._records if item.path == runtime_id and item.kind == "mtp"), + None, + ) + if mtp_record is not None: + if operation == "open": + if "open" not in mtp_record.actions() or not mtp_record.mount_point: + raise RequestError("L'appareil MTP n'a pas fourni de chemin local") + self.launcher(mtp_record.mount_point) + return {"ok": True, "state": self.state()} + raise RequestError("Cette opération MTP exige une complétion asynchrone") if operation == "safe-remove": - self.refresh() records = self._drive_records(runtime_id) if not records: raise RequestError("Lecteur introuvable ou retiré") @@ -552,6 +903,44 @@ class MediaDaemon: self.launcher(mount_point) return {"ok": True, "state": self.state()} + def handle_async( + self, + request: dict[str, Any], + done: Callable[[dict[str, Any] | BaseException], None], + ) -> Callable[[], None]: + """Lance une requête depuis GLib et remet sa réponse au callback.""" + try: + operation, runtime_id = validate_request(request) + if operation == "list": + done(self.handle(request)) + return lambda: None + assert runtime_id is not None + self.refresh() + record = next( + ( + item + for item in self._records + if item.kind == "mtp" + and runtime_id in {item.path, item.safe_remove_runtime_id} + ), + None, + ) + if record is None: + done(self.handle(request)) + return lambda: None + if operation not in record.actions(): + raise RequestError("Opération indisponible pour cet appareil MTP") + if operation == "open": + if not record.mount_point: + raise RequestError("L'appareil MTP n'a pas fourni de chemin local") + self.launcher(record.mount_point) + done({"ok": True, "state": self.state()}) + return lambda: None + return self._start_mtp_operation(record, operation, done) + except BaseException as error: + done(error) + return lambda: None + @staticmethod def _launch_yazi(mount_point: str) -> None: subprocess.Popen( @@ -580,9 +969,11 @@ def validate_request(request: Any) -> tuple[str, str | None]: if runtime_id is not None: raise RequestError("runtime_id est interdit pour list") return operation, None - if not isinstance(runtime_id, str) or not runtime_id.startswith( - "/org/freedesktop/UDisks2/" - ) or len(runtime_id) > 512: + valid_prefix = isinstance(runtime_id, str) and ( + runtime_id.startswith("/org/freedesktop/UDisks2/") + or runtime_id.startswith("mtp:") + ) + if not valid_prefix or len(runtime_id) > 512: raise RequestError("runtime_id invalide") return operation, runtime_id @@ -663,25 +1054,38 @@ class JsonSocketServer: class GLibDispatcher: """Fait exécuter les mutations Gio dans le thread de la boucle GLib.""" - def __init__(self, GLib: Any, handler: Callable[[dict[str, Any]], dict[str, Any]]) -> None: + def __init__( + self, + GLib: Any, + handler: Callable[ + [dict[str, Any], Callable[[dict[str, Any] | BaseException], None]], + Callable[[], None], + ], + ) -> None: self.GLib = GLib self.handler = handler def __call__(self, request: dict[str, Any]) -> dict[str, Any]: completed = threading.Event() result: list[dict[str, Any] | BaseException] = [] + cancellation: list[Callable[[], None]] = [] + + def complete(value: dict[str, Any] | BaseException) -> None: + if not completed.is_set(): + result.append(value) + completed.set() def invoke() -> bool: try: - result.append(self.handler(request)) + cancellation.append(self.handler(request, complete)) except BaseException as error: - result.append(error) - finally: - completed.set() + complete(error) return False self.GLib.idle_add(invoke) - if not completed.wait(30): + if not completed.wait(OPERATION_TIMEOUT_SECONDS + 2): + if cancellation: + self.GLib.idle_add(cancellation[0]) raise RequestError("Délai d'opération dépassé") value = result[0] if isinstance(value, BaseException): @@ -697,13 +1101,17 @@ def main() -> int: from gi.repository import Gio, GLib base = runtime_dir() - backend = UDisksBackend(Gio, GLib) + backend = RemovableMediaBackend( + UDisksBackend(Gio, GLib), MTPBackend(Gio, GLib) + ) daemon = MediaDaemon(backend, base / STATE_NAME) # CONTRACT: s'abonner avant le scan ferme la fenêtre où un objet ajouté # entre ces deux opérations aurait échappé à l'automount. backend.connect_changed(daemon.devices_changed) daemon.start() - server = JsonSocketServer(base / SOCKET_NAME, GLibDispatcher(GLib, daemon.handle)) + server = JsonSocketServer( + base / SOCKET_NAME, GLibDispatcher(GLib, daemon.handle_async) + ) server.start() threading.Thread(target=server.serve_forever, daemon=True).start() GLib.MainLoop().run() diff --git a/bin/.local/bin/labfy-removable-mediactl b/bin/.local/bin/labfy-removable-mediactl index f2c6642fe..c2479bcd6 100755 --- a/bin/.local/bin/labfy-removable-mediactl +++ b/bin/.local/bin/labfy-removable-mediactl @@ -22,7 +22,7 @@ def usage() -> int: " labfy-removable-mediactl mount \n" " labfy-removable-mediactl unmount \n" " labfy-removable-mediactl open \n" - " labfy-removable-mediactl safe-remove ", + " labfy-removable-mediactl safe-remove ", file=sys.stderr, ) return 2 diff --git a/docs/systemd.md b/docs/systemd.md index afe4251eb..504d5a36b 100644 --- a/docs/systemd.md +++ b/docs/systemd.md @@ -33,7 +33,7 @@ et `timers.target.wants/` expriment les activations utilisateur retenues. `.assets/enabled-user-aux-units.txt` capture en complément les timers et sockets activés observés sur la machine. -## Supports amovibles USB/SD +## Supports amovibles USB/SD et MTP `labfy-removable-media.service` appartient au package Stow `systemd` et son exécutable `labfy-removable-media`, ainsi que le client @@ -43,25 +43,39 @@ de ces deux packages, le lien versionné dans Sway. L'unité est rattachée à `wayland-session@sway.desktop.target`, redémarre sur échec et n'est pas un service système. -Le daemon observe l'ObjectManager UDisks du bus système et publie son état -runtime JSON V1 dans `$XDG_RUNTIME_DIR/labfy-removable-media.json`. Son socket -Unix `$XDG_RUNTIME_DIR/labfy-removable-media.sock` et le snapshot sont créés -avec le mode `0600`. L'IPC n'accepte qu'une requête JSON -terminée par LF pour `list`, `mount`, `unmount`, `open` ou `safe-remove` ; les -identifiants runtime sont relus dans UDisks avant toute mutation. +Un seul daemon de session assure le cycle de vie, le socket et le snapshot de +`labfy-removable-media`. Il observe l'ObjectManager UDisks du bus système pour +les lecteurs USB et SD/MMC, et `Gio.VolumeMonitor`/GVFS-MTP pour les +téléphones. Il publie le snapshot JSON `labfy.removable-media` version `2` +dans `$XDG_RUNTIME_DIR/labfy-removable-media.json`. Son socket Unix +`$XDG_RUNTIME_DIR/labfy-removable-media.sock` et le snapshot sont créés avec le +mode `0600`. L'IPC n'accepte qu'une requête JSON terminée par LF pour `list`, +`mount`, `unmount`, `open` ou `safe-remove` ; les identifiants runtime opaques +sont relus dans leur backend avant toute mutation. -Seuls les systèmes de fichiers des lecteurs USB ou SD/MMC sont présentés. Les -blocs `HintIgnore` et `HintSystem`, ainsi que les volumes `crypto_LUKS`, sont -exclus ; aucun déverrouillage LUKS n'est tenté. MTP ne relève pas de ce contrat -UDisks `Block`/`Filesystem` et n'est pas pris en charge. Un objet UDisks -nouvellement présent est monté au plus une fois : un échec est isolé des autres -volumes, et une nouvelle tentative automatique demande une disparition puis une -réinsertion de l'objet. Un démontage manuel ne provoque donc pas de remontage -sur un événement de propriétés ultérieur. +Seuls les systèmes de fichiers des lecteurs USB ou SD/MMC éligibles et les +volumes MTP détectés par GVFS sont présentés. Pour UDisks, les blocs +`HintIgnore` et `HintSystem`, ainsi que les volumes `crypto_LUKS`, sont exclus ; +aucun déverrouillage LUKS n'est tenté. Pendant la vie du daemon, chaque support +présent est monté automatiquement au plus une fois : un échec est isolé des +autres supports, et une nouvelle tentative automatique demande une disparition +puis une réinsertion. Pendant cette même vie, un démontage manuel ne provoque +donc pas de remontage sur un événement de propriétés ultérieur. Au démarrage ou +au redémarrage, le cold-start V1 effectue un nouveau scan : un téléphone déjà +présent peut alors être monté. Pour MTP, le montage et cette règle de présence +reposent sur GVFS. -La commande `safe-remove` porte sur le lecteur : elle démonte d'abord tous ses -systèmes de fichiers, y compris ceux masqués par l'interface, puis relit leur -état avant d'appeler `PowerOff` ou `Eject`. +Le schéma V2 publie des identifiants MTP opaques, `kind` et `actions`. Chaque +appareil publie aussi `safe_remove_runtime_id`, y compris lorsque `safe-remove` +n'est pas disponible. Il ne publie pas d'URI MTP ni de champ d'identité +matérielle. Pour ouvrir un support, UDisks fournit son véritable point de +montage ; le chemin du `GMount` est réservé à MTP. Ce chemin, conservé dans le +snapshot privé (`0600`), peut contenir un identifiant intégré par GVFS. La +commande `safe-remove` UDisks porte sur le lecteur : elle +démonte d'abord tous ses systèmes de fichiers, y compris ceux masqués par +l'interface, puis relit leur état avant d'appeler `PowerOff` ou `Eject`. Pour +MTP, `safe-remove` ferme logiquement le montage GVFS ; il n'appelle ni +`PowerOff` ni `Eject`. Après Stow et le rechargement de systemd user, vérifier l'unité et exécuter les tests synthétiques sans périphérique réel : @@ -74,10 +88,14 @@ systemctl --user is-enabled labfy-removable-media.service python3 -B -m unittest discover -s tests/removable_media -p 'test_*.py' ``` -Les tests utilisent des backends et sockets synthétiques. Ils couvrent le -filtrage UDisks, l'automontage borné, les opérations par volume ou lecteur, le -contrat JSON et les limites de l'IPC, mais ne constituent pas une validation sur -matériel USB/SD réel. Cette validation n'a pas encore été effectuée. +Les 29 tests `unittest` utilisent des backends et sockets synthétiques. Ils +couvrent le filtrage UDisks, le cycle de vie MTP/GVFS, l'automontage borné, les +opérations par volume ou lecteur, le contrat JSON V2 et les limites de l'IPC. +Ils sont passants. Le `qmllint` est également passant. Ces validations +logicielles ne constituent pas une recette UI sur matériel MTP : celle-ci reste +à effectuer. Un Samsung réel a été reconnu et les opérations ciblées de montage +et de retrait en sécurité ont été vérifiées dans le code isolé. La validation +matérielle USB Ventoy V1 avait été effectuée avant le commit précédent. La gestion des supports amovibles est indépendante de Session Restore : elle ne participe ni aux checkpoints, ni au choix de restauration au démarrage. diff --git a/systemd/.config/systemd/user/labfy-removable-media.service b/systemd/.config/systemd/user/labfy-removable-media.service index 2e2e4440b..9855ee6ec 100644 --- a/systemd/.config/systemd/user/labfy-removable-media.service +++ b/systemd/.config/systemd/user/labfy-removable-media.service @@ -1,5 +1,5 @@ [Unit] -Description=Gestion des supports amovibles USB et SD +Description=Gestion des supports amovibles USB, SD et Android MTP PartOf=wayland-session@sway.desktop.target After=wayland-session@sway.desktop.target diff --git a/tests/removable_media/test_backend.py b/tests/removable_media/test_backend.py index 888876415..716725de9 100644 --- a/tests/removable_media/test_backend.py +++ b/tests/removable_media/test_backend.py @@ -106,17 +106,23 @@ class MediaDaemonTests(unittest.TestCase): state = daemon.state() self.assertEqual(state["schema"], "labfy.removable-media") - self.assertEqual(state["version"], 1) + self.assertEqual(state["version"], 2) self.assertEqual(set(state), {"schema", "version", "devices", "updated_at"}) self.assertEqual( set(state["devices"][0]), { - "runtime_id", "drive_runtime_id", "display_name", "label", + "runtime_id", "safe_remove_runtime_id", "kind", "actions", + "display_name", "label", "filesystem", "size_bytes", "mount_point", "mounted", "removable", "ejectable", "power_off_capable", "busy", "error", }, ) self.assertTrue(state["devices"][0]["mounted"]) + self.assertEqual(state["devices"][0]["kind"], "block") + self.assertEqual( + state["devices"][0]["safe_remove_runtime_id"], + "/org/freedesktop/UDisks2/drives/usb", + ) self.assertEqual(stat.S_IMODE(self.state_path.stat().st_mode), 0o600) self.assertEqual(json.loads(self.state_path.read_text()), state) diff --git a/tests/removable_media/test_ipc.py b/tests/removable_media/test_ipc.py index 72fb54888..892008301 100644 --- a/tests/removable_media/test_ipc.py +++ b/tests/removable_media/test_ipc.py @@ -25,6 +25,10 @@ SPEC.loader.exec_module(media) class IpcTests(unittest.TestCase): def test_request_allowlist_and_shape(self): self.assertEqual(media.validate_request({"operation": "list"}), ("list", None)) + self.assertEqual( + media.validate_request({"operation": "mount", "runtime_id": "mtp:nonce:1"}), + ("mount", "mtp:nonce:1"), + ) with self.assertRaises(media.RequestError): media.validate_request({"operation": "format", "runtime_id": "/tmp/x"}) with self.assertRaises(media.RequestError): @@ -70,6 +74,30 @@ class IpcTests(unittest.TestCase): self.assertIn("volumineuse", response["error"]) self.assertEqual(called, []) + def test_dispatcher_waits_for_async_completion(self): + pending = [] + + class GLib: + @staticmethod + def idle_add(callback): + callback() + + dispatcher = media.GLibDispatcher( + GLib, lambda request, done: pending.append((request, done)) or (lambda: None) + ) + result = [] + thread = threading.Thread( + target=lambda: result.append(dispatcher({"operation": "list"})) + ) + thread.start() + while not pending: + threading.Event().wait(0.001) + pending[0][1]({"ok": True}) + thread.join(2) + + self.assertFalse(thread.is_alive()) + self.assertEqual(result, [{"ok": True}]) + def test_slow_incomplete_request_times_out_without_calling_handler(self): called = [] server = media.JsonSocketServer(Path("unused"), lambda request: called.append(request)) diff --git a/tests/removable_media/test_mtp.py b/tests/removable_media/test_mtp.py new file mode 100644 index 000000000..758a614b7 --- /dev/null +++ b/tests/removable_media/test_mtp.py @@ -0,0 +1,347 @@ +import importlib.util +from importlib.machinery import SourceFileLoader +import json +import stat +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).parents[2] / "bin/.local/bin/labfy-removable-media" +SPEC = importlib.util.spec_from_loader( + "labfy_removable_media_mtp", SourceFileLoader("labfy_removable_media_mtp", str(SCRIPT)) +) +assert SPEC and SPEC.loader +media = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = media +SPEC.loader.exec_module(media) + + +class FakeRoot: + def __init__(self, scheme="mtp", path=None, uri="mtp://fake-device/"): + self.scheme = scheme + self.path = path + self.uri = uri + + def get_uri_scheme(self): + return self.scheme + + def get_path(self): + return self.path + + def get_uri(self): + return self.uri + + def query_filesystem_info(self, _attributes, _cancellable): + return type("Info", (), {"get_attribute_uint64": lambda self, _name: 1234})() + + +class FakeMount: + def __init__(self, volume, path): + self.volume = volume + self.root = FakeRoot(path=path) + + def get_root(self): + return self.root + + def get_name(self): + return "Stockage du téléphone" + + def unmount_with_operation(self, _flags, _operation, _cancellable, callback): + self.volume.pending = lambda: setattr(self.volume, "current_mount", None) + callback(self, object()) + + def unmount_with_operation_finish(self, _result): + self.volume.pending() + + +class FakeVolume: + def __init__(self, scheme="mtp", name="Téléphone"): + self.activation = FakeRoot(scheme=scheme) + self.name = name + self.current_mount = None + self.pending = None + self.mount_calls = 0 + + def get_activation_root(self): + return self.activation + + def get_name(self): + return self.name + + def get_mount(self): + return self.current_mount + + def mount(self, _flags, _operation, _cancellable, callback): + self.mount_calls += 1 + self.pending = lambda: setattr( + self, "current_mount", FakeMount(self, "/run/user/1000/gvfs/mtp:host=fake") + ) + callback(self, object()) + + def mount_finish(self, _result): + self.pending() + + +class FakeMonitor: + def __init__(self, volumes): + self.volumes = volumes + self.callbacks = {} + + def get_volumes(self): + return list(self.volumes) + + def connect(self, signal, callback): + self.callbacks[signal] = callback + + +class FakeCancellable: + def __init__(self): + self.cancelled = False + + def cancel(self): + self.cancelled = True + + +class FakeGio: + class Cancellable: + @staticmethod + def new(): + return FakeCancellable() + + class MountMountFlags: + NONE = 0 + + class MountUnmountFlags: + NONE = 0 + + +class FakeGLib: + def __init__(self): + self.sources = {} + self.next_source = 0 + + def timeout_add_seconds(self, _delay, callback): + self.next_source += 1 + self.sources[self.next_source] = callback + return self.next_source + + def timeout_add(self, _delay, callback, *args): + self.next_source += 1 + self.sources[self.next_source] = lambda: callback(*args) + return self.next_source + + def source_remove(self, source): + self.sources.pop(source, None) + + +class MTPBackendTests(unittest.TestCase): + def backend(self, volumes): + return media.MTPBackend(FakeGio, FakeGLib(), FakeMonitor(volumes)) + + def test_classifies_only_activation_root_mtp_and_uses_opaque_ids(self): + mtp = FakeVolume("mtp") + backend = self.backend([mtp, FakeVolume("gphoto2"), FakeVolume(None)]) + + records = backend.records() + + self.assertEqual(len(records), 1) + self.assertEqual(records[0].kind, "mtp") + self.assertTrue(records[0].path.startswith("mtp:")) + self.assertNotIn("Téléphone", records[0].path) + self.assertNotIn("mtp://", records[0].path) + self.assertEqual(records[0].actions(), ["mount"]) + + def test_mount_and_safe_remove_complete_async_with_real_root_path(self): + volume = FakeVolume() + backend = self.backend([volume]) + runtime_id = backend.records()[0].path + completed = [] + + backend.operate_async(runtime_id, "mount", lambda path, error: completed.append((path, error))) + self.assertEqual(completed, [("/run/user/1000/gvfs/mtp:host=fake", None)]) + mounted = backend.records()[0] + self.assertEqual(mounted.mount_point, "/run/user/1000/gvfs/mtp:host=fake") + self.assertEqual(mounted.display_name, "Stockage du téléphone") + self.assertEqual(mounted.actions(), ["unmount", "open", "safe-remove"]) + + completed.clear() + backend.operate_async(runtime_id, "safe-remove", lambda path, error: completed.append((path, error))) + self.assertEqual(completed, [("", None)]) + self.assertIsNone(volume.get_mount()) + + def test_mounted_without_local_path_omits_only_open(self): + volume = FakeVolume() + volume.current_mount = FakeMount(volume, None) + record = self.backend([volume]).records()[0] + + self.assertTrue(record.public()["mounted"]) + self.assertEqual(record.mount_point, "") + self.assertEqual(record.actions(), ["unmount", "safe-remove"]) + + def test_disappearance_rejects_stale_runtime_id(self): + volume = FakeVolume() + backend = self.backend([volume]) + runtime_id = backend.records()[0].path + backend.monitor.volumes = [] + + with self.assertRaisesRegex(media.RequestError, "introuvable"): + backend.operate_async(runtime_id, "mount", lambda *_args: None) + + def test_new_wrappers_keep_id_and_live_wrapper_is_used_for_action(self): + shared = {"mount": None} + + class RewrappedVolume(FakeVolume): + def get_mount(self): + return shared["mount"] + + def mount(self, _flags, _operation, _cancellable, callback): + shared["mount"] = FakeMount( + self, "/run/user/1000/gvfs/mtp:host=fake" + ) + callback(self, object()) + + def mount_finish(self, _result): + pass + + class RewrappingMonitor(FakeMonitor): + def __init__(self): + super().__init__([]) + self.calls = 0 + self.last_wrapper = None + + def get_volumes(self): + self.calls += 1 + self.last_wrapper = RewrappedVolume(name=f"Wrapper {self.calls}") + return [self.last_wrapper] + + monitor = RewrappingMonitor() + backend = media.MTPBackend(FakeGio, FakeGLib(), monitor) + + first_id = backend.records()[0].path + second_id = backend.records()[0].path + completed = [] + backend.operate_async( + first_id, "mount", lambda path, error: completed.append((path, error)) + ) + + self.assertEqual(second_id, first_id) + self.assertGreaterEqual(monitor.calls, 3) + self.assertIsNotNone(monitor.last_wrapper.get_mount()) + self.assertEqual(completed, [("/run/user/1000/gvfs/mtp:host=fake", None)]) + + def test_observed_removal_invalidates_id_before_same_uri_reinsertion(self): + first = FakeVolume() + monitor = FakeMonitor([first]) + backend = media.MTPBackend(FakeGio, FakeGLib(), monitor) + backend.connect_changed(lambda: None) + first_id = backend.records()[0].path + + monitor.callbacks["volume-removed"](monitor, first) + monitor.volumes = [FakeVolume()] + + self.assertNotEqual(backend.records()[0].path, first_id) + + def test_timeout_cancels_pending_mount_and_returns_bounded_french_error(self): + class PendingVolume(FakeVolume): + def mount(self, _flags, _operation, cancellable, _callback): + self.cancellable = cancellable + + volume = PendingVolume() + backend = self.backend([volume]) + runtime_id = backend.records()[0].path + completed = [] + backend.operate_async( + runtime_id, "mount", lambda path, error: completed.append((path, error)) + ) + timeout = next(iter(backend.GLib.sources.values())) + + self.assertFalse(timeout()) + self.assertTrue(volume.cancellable.cancelled) + self.assertIsInstance(completed[0][1], media.RequestError) + self.assertIn("Délai d'opération MTP dépassé", str(completed[0][1])) + + +class CompositeBackend: + def __init__(self, mtp_backend): + self.mtp = mtp_backend + + def records(self): + return self.mtp.records() + + def operate_mtp_async(self, runtime_id, operation, callback): + return self.mtp.operate_async(runtime_id, operation, callback) + + +class MTPDaemonTests(unittest.TestCase): + def test_public_v2_snapshot_uses_opaque_id_and_authoritative_mount_path(self): + volume = FakeVolume(name="Téléphone de test") + raw_uri = "mtp://[usb:001,007]/raw-serial-ABC-123/" + volume.activation.uri = raw_uri + # CONTRACT: GVFS peut inclure une identité dans son chemin local ; ce + # chemin exact reste nécessaire à Yazi et le snapshot est privé (0600). + mount_path = "/run/user/1000/gvfs/mtp:host=raw-serial-ABC-123" + volume.current_mount = FakeMount(volume, mount_path) + + with tempfile.TemporaryDirectory() as directory: + state_path = Path(directory) / media.STATE_NAME + daemon = media.MediaDaemon( + CompositeBackend(media.MTPBackend(FakeGio, FakeGLib(), FakeMonitor([volume]))), + state_path, + ) + + state = daemon.state() + device = state["devices"][0] + snapshot = state_path.read_text() + snapshot_mode = stat.S_IMODE(state_path.stat().st_mode) + + self.assertEqual(state["version"], 2) + self.assertEqual( + set(device), + { + "runtime_id", "safe_remove_runtime_id", "kind", "actions", + "display_name", "label", "filesystem", "size_bytes", + "mount_point", "mounted", "removable", "ejectable", + "power_off_capable", "busy", "error", + }, + ) + self.assertEqual(device["kind"], "mtp") + self.assertEqual(device["actions"], ["unmount", "open", "safe-remove"]) + self.assertTrue(device["mounted"]) + self.assertTrue(device["runtime_id"].startswith("mtp:")) + self.assertEqual(device["runtime_id"], device["safe_remove_runtime_id"]) + self.assertEqual(device["mount_point"], mount_path) + self.assertNotIn("mtp://", device["runtime_id"]) + self.assertNotIn("raw-serial-ABC-123", device["runtime_id"]) + self.assertNotIn(raw_uri, json.dumps(state, ensure_ascii=False)) + self.assertIn(mount_path, snapshot) + self.assertEqual(snapshot_mode, 0o600) + + def test_manual_unmount_is_not_automounted_again(self): + volume = FakeVolume() + monitor = FakeMonitor([volume]) + mtp = media.MTPBackend(FakeGio, FakeGLib(), monitor) + with tempfile.TemporaryDirectory() as directory: + daemon = media.MediaDaemon( + CompositeBackend(mtp), Path(directory) / media.STATE_NAME + ) + mtp.connect_changed(daemon.devices_changed) + daemon.start() + runtime_id = daemon.state()["devices"][0]["runtime_id"] + self.assertEqual(volume.mount_calls, 1) + replies = [] + daemon.handle_async( + {"operation": "unmount", "runtime_id": runtime_id}, replies.append + ) + monitor.callbacks["mount-changed"](monitor, None) + + self.assertEqual(len(replies), 1) + self.assertFalse(daemon.state()["devices"][0]["mounted"]) + self.assertEqual(volume.mount_calls, 1) + self.assertIn("volume-changed", monitor.callbacks) + self.assertIn("mount-changed", monitor.callbacks) + + +if __name__ == "__main__": + unittest.main() diff --git a/yazi/.config/yazi/README.md b/yazi/.config/yazi/README.md new file mode 100644 index 000000000..bdcd7abd6 --- /dev/null +++ b/yazi/.config/yazi/README.md @@ -0,0 +1,51 @@ +# Yazi : supports amovibles et espace disque + +Configuration suivie par GNU Stow (`stow yazi`). Yazi 26.9.1 est requis. + +## Supports + +`devices.yazi` affiche un résumé au-dessus du panneau parent. La zone reste +informative : l'API du panneau parent ne fournit pas une liste indépendante +avec son propre curseur. Le menu `M` permet de choisir un support et une action : +ouvrir, monter puis ouvrir, démonter ou retirer en sécurité. Le montage d'un +support non monté demande confirmation. Les erreurs sont affichées par Yazi. + +La source de vérité est `labfy-removable-media.service`, déjà fourni par ces +dotfiles avec `labfy-removable-mediactl`. Le service utilise UDisks2 pour les +volumes bloc et Gio/GVfs pour MTP. Il élimine les partitions système et les +montages techniques. Installer `udisks2`, `gvfs` et `gvfs-mtp` sur Arch ; +`udiskie` n'est pas requis. Le service et sa commande doivent être installés via +les paquets Stow `systemd` et `bin`. La liste est relue toutes les cinq secondes +par une tâche Lua de l'instance Yazi ; aucun `lsblk` n'est lancé au rendu. +Le service réagit lui-même aux changements UDisks2/Gio. L'espace libre des +volumes bloc montés est lu avec `df` au plus toutes les trente secondes. MTP est +présenté comme MTP, sans capacité inventée. + +La colonne de gauche passe à 20 % de la largeur. Sous 17 colonnes, le résumé +est masqué afin de préserver le panneau parent. Sa hauteur est bornée à huit +lignes ; si beaucoup de supports sont connectés, `M` donne accès à la liste +complète. Les clics dans le résumé n'activent aucun périphérique. + +## Espace disque + +`disk-space.yazi` lit `df -B1 --output=size,avail -- ` en tâche +asynchrone au changement de répertoire. `avail` correspond à l'espace +utilisable par l'utilisateur courant, selon la réponse du système de fichiers. La capacité et l'espace libre sont affichés en unités +IEC. Si `df` échoue, la barre indique « Espace indisponible ». Le reste de la +barre de statut, notamment les ajouts existants, reste configuré dans +`init.lua`. + +`sduf.yazi` a été évalué : il indique l'espace utilisé sur la capacité totale, +avec analyse de `df -h`, ce qui ne répond pas au besoin d'espace réellement +disponible. `mount.yazi` est compatible avec Yazi 26.9.1 mais n'apporte ni +panneau permanent filtré ni MTP. `gvfs.yazi` annonce ne plus être maintenu ; +aucun de ces trois plugins n'est installé. + +## Vérification visuelle + +Lancer `yazi`, brancher ou débrancher un support et attendre au plus cinq +secondes. Vérifier le panneau, puis `M`. Pour un volume monté, choisir son +numéro puis `o`. Pour un volume non monté, choisir `m` et confirmer ; `u` et `e` +sont des opérations explicites. Vérifier l'espace libre après navigation vers +un autre système de fichiers. Ne pas démonter ni éjecter un support en cours +d'utilisation. diff --git a/yazi/.config/yazi/init.lua b/yazi/.config/yazi/init.lua index 6b34cd876..7bfbd4a15 100644 --- a/yazi/.config/yazi/init.lua +++ b/yazi/.config/yazi/init.lua @@ -25,3 +25,9 @@ Status:children_add(function() " ", } end, 500, Status.RIGHT) + +-- WHY: panneau non interactif au-dessus du parent et menu explicite sur M. +require("devices"):setup() + +-- WHY: espace réellement disponible sur le filesystem du répertoire courant. +require("disk-space"):setup() diff --git a/yazi/.config/yazi/keymap.toml b/yazi/.config/yazi/keymap.toml index bab006bc5..08b1139ca 100644 --- a/yazi/.config/yazi/keymap.toml +++ b/yazi/.config/yazi/keymap.toml @@ -5,6 +5,7 @@ [mgr] keymap = [ + { on = "M", run = "plugin devices", desc = "Périphériques amovibles" }, { on = "", run = "escape", desc = "Exit visual mode, clear selection, or cancel search" }, { on = "", run = "escape", desc = "Exit visual mode, clear selection, or cancel search" }, { on = "q", run = "quit", desc = "Quit the process" }, diff --git a/yazi/.config/yazi/plugins/devices.yazi/README.md b/yazi/.config/yazi/plugins/devices.yazi/README.md new file mode 100644 index 000000000..63829e38b --- /dev/null +++ b/yazi/.config/yazi/plugins/devices.yazi/README.md @@ -0,0 +1,5 @@ +# devices.yazi + +Zone informative des périphériques amovibles. Utilise le service +`labfy-removable-media` des dotfiles ; `M` ouvre le menu d'actions. +Le panneau parent reste navigable et cliquable dans sa propre zone. diff --git a/yazi/.config/yazi/plugins/devices.yazi/main.lua b/yazi/.config/yazi/plugins/devices.yazi/main.lua new file mode 100644 index 000000000..1690b7109 --- /dev/null +++ b/yazi/.config/yazi/plugins/devices.yazi/main.lua @@ -0,0 +1,163 @@ +--- @since 26.9.1 + +-- CONTRACT: le service UDisks2/Gio des dotfiles est l'unique source des +-- périphériques et des actions. Aucun point de montage n'est construit ici. +local M = {} +local client = (os.getenv("HOME") or "") .. "/.local/bin/labfy-removable-mediactl" +local devices, spaces, ready = {}, {}, false +local space_cache = {} + +local function clean(s) return tostring(s or ""):gsub("[%c]", " ") end +local function name(d) return clean(type(d.display_name) == "string" and d.display_name ~= "" and d.display_name or "Périphérique") end +local function allows(d, action) + for _, candidate in ipairs(type(d.actions) == "table" and d.actions or {}) do + if candidate == action then return true end + end + return false +end +local function detail(d) + if d.kind == "mtp" then return "MTP" end + if not d.mounted then return "Non monté" end + return spaces[d.runtime_id] and (spaces[d.runtime_id] .. " libres") or "Espace indisponible" +end +local publish = ya.sync(function(_, list, free, ok) + devices, spaces, ready = list, free, ok + ui.render() +end) +local function list() + local output, err = Command(client):arg("list"):output() + if err or not output or not output.status.success then return nil end + local ok, state = pcall(ya.json_decode, output.stdout) + if not ok or type(state) ~= "table" or state.schema ~= "labfy.removable-media" or type(state.devices) ~= "table" then return nil end + return state.devices +end +local function free_space(path) + local output, err = Command("df"):arg({ "-B1", "--output=avail", "--", path }):output() + if err or not output or not output.status.success then return nil end + local bytes = tonumber(output.stdout:match("\n%s*(%d+)")) + return bytes and string.format("%.1f GiB", bytes / 1073741824) or nil +end +local function refresh() + local found = list() + if not found then publish({}, {}, false); return end + local free, next_cache, now = {}, {}, ya.time() + for _, d in ipairs(found) do + if d.kind == "block" and d.mounted and type(d.mount_point) == "string" and d.mount_point:sub(1, 1) == "/" then + local previous = space_cache[d.runtime_id] + local value = previous and previous.path == d.mount_point and now - previous.at < 30 and previous.value or nil + value = value or free_space(d.mount_point) + free[d.runtime_id] = value + next_cache[d.runtime_id] = { path = d.mount_point, at = now, value = value } + end + end + space_cache = next_cache + publish(found, free, true) +end +local function operate(action, id) + local output, err = Command(client):arg({ action, id }):output() + if err or not output or not output.status.success then + ya.notify { title = "Périphériques", content = clean(output and output.stderr or err or "Échec inconnu"):sub(1, 240), level = "error", timeout = 6 } + return nil + end + local ok, state = pcall(ya.json_decode, output.stdout) + return ok and type(state) == "table" and state or nil +end +function M:entry() + local found = list() + if not found then + ya.notify { title = "Périphériques", content = "Service indisponible", level = "error", timeout = 5 } + return + end + if #found == 0 then + ya.notify { title = "Périphériques", content = "Aucun périphérique amovible", timeout = 3 } + return + end + local keys, cands = "123456789abcdefghijklmnopqrstuvwxyz", {} + for i, d in ipairs(found) do + if i > #keys then break end + cands[i] = { on = keys:sub(i, i), desc = name(d) .. " · " .. detail(d) } + end + local selected = ya.which { cands = cands } + local d = selected and found[selected] + if not d then return end + local actions = {} + if d.mounted and allows(d, "open") then + actions[#actions + 1] = { on = "o", desc = "Ouvrir " .. name(d), action = "open" } + elseif not d.mounted and allows(d, "mount") then + actions[#actions + 1] = { on = "m", desc = "Monter puis ouvrir " .. name(d), action = "open" } + end + if allows(d, "unmount") then actions[#actions + 1] = { on = "u", desc = "Démonter " .. name(d), action = "unmount" } end + if allows(d, "safe-remove") then + actions[#actions + 1] = { on = "e", desc = "Retirer en sécurité " .. name(d), action = "safe-remove" } + end + if #actions == 0 then + ya.notify { title = "Périphériques", content = "Aucune action disponible", level = "warn", timeout = 4 } + return + end + local selected_action = ya.which { cands = actions } + if not selected_action then return end + local action = actions[selected_action].action + if action == "open" and not d.mounted then + if not ya.confirm { pos = { "center", w = 52, h = 8 }, title = "Montage", body = "Monter " .. name(d) .. " puis ouvrir ?" } then return end + end + -- WHY: l'opération « open » du service lance une nouvelle fenêtre Kitty. + -- Naviguer dans cette instance ; seul le montage passe par le service. + if action == "open" and d.mounted then + if type(d.mount_point) == "string" and d.mount_point:sub(1, 1) == "/" then + ya.emit("cd", { d.mount_point }) + else + ya.notify { title = "Périphériques", content = "Point de montage indisponible", level = "error", timeout = 5 } + end + return + end + local result = operate(action == "open" and "mount" or action, + action == "safe-remove" and d.safe_remove_runtime_id or d.runtime_id) + if not result then return end + if action == "open" then + for _, item in ipairs(result.devices or {}) do + if item.runtime_id == d.runtime_id and item.mounted and type(item.mount_point) == "string" and item.mount_point:sub(1, 1) == "/" then + ya.emit("cd", { item.mount_point }) + break + end + end + end + refresh() +end +function M:setup() + -- INVARIANT: un seul temporisateur Lua par instance ; pas de lsblk au rendu. + ya.async(function() while true do refresh(); ya.sleep(5) end end) + local original_new, original_redraw, original_click = Parent.new, Parent.redraw, Parent.click + function Parent:new(area, tab) + local rows = area.w >= 17 and math.min(8, math.floor(area.h / 3)) or 0 + local body = ui.Rect { x = area.x, y = area.y + rows, w = area.w, h = area.h - rows } + local parent = original_new(self, body, tab) + parent._devices_header = rows > 0 and ui.Rect { x = area.x, y = area.y, w = area.w, h = rows } or nil + return parent + end + function Parent:redraw() + local output, area = original_redraw(self), self._devices_header + if not area then return output end + local lines = { ui.Line { ui.Span("PÉRIPHÉRIQUES"):fg("#b4befe"):bold() } } + if not ready then + lines[#lines + 1] = ui.Line(" État indisponible") + elseif #devices == 0 then + lines[#lines + 1] = ui.Line(" Aucun support") + else + for _, d in ipairs(devices) do + if #lines + 2 >= area.h then break end + local icon = d.kind == "mtp" and "󰄜 " or (d.mounted and "󰋊 " or "󰐑 ") + lines[#lines + 1] = ui.Line(ui.truncate(icon .. name(d), { max = area.w })) + lines[#lines + 1] = ui.Line { ui.Span(ui.truncate(" " .. detail(d), { max = area.w })):fg("#a6adc8") } + end + end + lines[#lines + 1] = ui.Line { ui.Span(string.rep("─", math.max(0, area.w - 1))):fg("#585b70") } + output[#output + 1] = ui.Text(lines):area(area) + return output + end + function Parent:click(event, up) + local area = self._devices_header + if area and event.y < area.y + area.h then return end + return original_click(self, event, up) + end +end +return M diff --git a/yazi/.config/yazi/plugins/disk-space.yazi/main.lua b/yazi/.config/yazi/plugins/disk-space.yazi/main.lua new file mode 100644 index 000000000..df534ea8a --- /dev/null +++ b/yazi/.config/yazi/plugins/disk-space.yazi/main.lua @@ -0,0 +1,38 @@ +--- @since 26.9.1 + +-- CONTRACT: « avail » est l'espace disponible pour l'utilisateur courant, +-- contrairement à la capacité totale ; df interroge le filesystem du CWD. +local M = {} +local value, last_path = "Espace indisponible", nil +local publish = ya.sync(function(_, path, display) + if last_path ~= path then return end + value = display + ui.render() +end) +local function human(bytes) + local units, i = { "B", "KiB", "MiB", "GiB", "TiB", "PiB" }, 1 + while bytes >= 1024 and i < #units do bytes, i = bytes / 1024, i + 1 end + return i == 1 and string.format("%d B", bytes) or string.format("%.1f %s", bytes, units[i]) +end +function M:setup() + local function load(path) + last_path, value = path, "Espace indisponible" + ui.render() + ya.async(function() + local output, err = Command("df"):arg({ "-B1", "--output=size,avail", "--", path }):output() + if err or not output or not output.status.success then return end + local total, available = output.stdout:match("\n%s*(%d+)%s+(%d+)") + total, available = tonumber(total), tonumber(available) + if total and available and total > 0 and available <= total then + publish(path, human(available) .. " libres / " .. human(total)) + end + end) + end + ps.sub("cd", function() load(tostring(cx.active.current.cwd)) end) + Status:children_add(function() + local path = tostring(cx.active.current.cwd) + if path ~= last_path then load(path) end + return ui.Line { ui.Span(" " .. value):fg("#b4befe") } + end, 2800, Status.RIGHT) +end +return M diff --git a/yazi/.config/yazi/yazi.toml b/yazi/.config/yazi/yazi.toml index aaebde5a6..f723bba49 100644 --- a/yazi/.config/yazi/yazi.toml +++ b/yazi/.config/yazi/yazi.toml @@ -1,5 +1,5 @@ [manager] -ratio = [ 1, 4, 3 ] +ratio = [ 2, 5, 3 ] show_hidden = true # Voir les fichiers .cachés linemode = "size" # Nom + taille sort_by = "modified" # Tri par date @@ -61,7 +61,7 @@ rules = [ "$schema" = "https://yazi-rs.github.io/schemas/yazi.json" [mgr] -ratio = [ 1, 4, 3 ] +ratio = [ 2, 5, 3 ] sort_by = "alphabetical" sort_sensitive = false sort_reverse = false