feat(removable): add MTP support and Yazi device views

This commit is contained in:
fy59 2026-10-08 20:57:07 +02:00
parent 21b5ae2571
commit c06c55db13
14 changed files with 1117 additions and 46 deletions

View file

@ -1,8 +1,9 @@
#!/usr/bin/python3 #!/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 from __future__ import annotations
import hashlib
import json import json
import logging import logging
import os import os
@ -12,16 +13,18 @@ import sys
import tempfile import tempfile
import threading import threading
import time import time
import uuid
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
SCHEMA = "labfy.removable-media" SCHEMA = "labfy.removable-media"
VERSION = 1 VERSION = 2
MAX_REQUEST = 16 * 1024 MAX_REQUEST = 16 * 1024
MAX_ERROR = 240 MAX_ERROR = 240
MAX_CLIENTS = 8 MAX_CLIENTS = 8
READ_TIMEOUT = 2.0 READ_TIMEOUT = 2.0
OPERATION_TIMEOUT_SECONDS = 30
SOCKET_NAME = "labfy-removable-media.sock" SOCKET_NAME = "labfy-removable-media.sock"
STATE_NAME = "labfy-removable-media.json" STATE_NAME = "labfy-removable-media.json"
ALLOWED_OPERATIONS = {"list", "mount", "unmount", "open", "safe-remove"} 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) 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: def runtime_dir() -> Path:
value = os.environ.get("XDG_RUNTIME_DIR") value = os.environ.get("XDG_RUNTIME_DIR")
if not value or not os.path.isabs(value): if not value or not os.path.isabs(value):
@ -93,17 +107,35 @@ class FilesystemRecord:
removable: bool removable: bool
ejectable: bool ejectable: bool
power_off_capable: 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]: def public(self, busy: bool = False, error: str = "") -> dict[str, Any]:
return { return {
"runtime_id": self.path, "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, "display_name": self.display_name,
"label": self.label, "label": self.label,
"filesystem": self.filesystem, "filesystem": self.filesystem,
"size_bytes": self.size_bytes, "size_bytes": self.size_bytes,
"mount_point": self.mount_point, "mount_point": self.mount_point,
"mounted": bool(self.mount_point), "mounted": self.mounted,
"removable": self.removable, "removable": self.removable,
"ejectable": self.ejectable, "ejectable": self.ejectable,
"power_off_capable": self.power_off_capable, "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: class UDisksBackend:
"""Adaptateur Gio minimal ; l'ObjectManager reste l'unique source de vérité.""" """Adaptateur Gio minimal ; l'ObjectManager reste l'unique source de vérité."""
@ -325,6 +373,266 @@ class UDisksBackend:
return sorted(result) 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: class MediaDaemon:
"""Modèle métier injectable et indépendant de Gio.""" """Modèle métier injectable et indépendant de Gio."""
@ -391,7 +699,7 @@ class MediaDaemon:
self._write_state() self._write_state()
def start(self) -> None: 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() self.devices_changed()
def devices_changed(self) -> None: def devices_changed(self) -> None:
@ -406,7 +714,10 @@ class MediaDaemon:
confirmed: dict[str, str] = {} confirmed: dict[str, str] = {}
for item in newcomers: for item in newcomers:
if item.mount_point: if item.mounted:
continue
if item.kind == "mtp":
self._start_mtp_operation(item, "mount", None)
continue continue
try: try:
self.backend.mount(item.path) self.backend.mount(item.path)
@ -430,6 +741,35 @@ class MediaDaemon:
] ]
self._write_state() 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: def _record(self, runtime_id: str) -> FilesystemRecord:
# CONTRACT: on relit UDisks avant toute mutation ; un identifiant d'objet # CONTRACT: on relit UDisks avant toute mutation ; un identifiant d'objet
# disparu ou réutilisé ne doit jamais viser un ancien support en cache. # disparu ou réutilisé ne doit jamais viser un ancien support en cache.
@ -524,8 +864,19 @@ class MediaDaemon:
self.refresh() self.refresh()
return {"ok": True, "state": self.state()} return {"ok": True, "state": self.state()}
assert runtime_id is not None assert runtime_id is not None
if operation == "safe-remove":
self.refresh() 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":
records = self._drive_records(runtime_id) records = self._drive_records(runtime_id)
if not records: if not records:
raise RequestError("Lecteur introuvable ou retiré") raise RequestError("Lecteur introuvable ou retiré")
@ -552,6 +903,44 @@ class MediaDaemon:
self.launcher(mount_point) self.launcher(mount_point)
return {"ok": True, "state": self.state()} 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 @staticmethod
def _launch_yazi(mount_point: str) -> None: def _launch_yazi(mount_point: str) -> None:
subprocess.Popen( subprocess.Popen(
@ -580,9 +969,11 @@ def validate_request(request: Any) -> tuple[str, str | None]:
if runtime_id is not None: if runtime_id is not None:
raise RequestError("runtime_id est interdit pour list") raise RequestError("runtime_id est interdit pour list")
return operation, None return operation, None
if not isinstance(runtime_id, str) or not runtime_id.startswith( valid_prefix = isinstance(runtime_id, str) and (
"/org/freedesktop/UDisks2/" runtime_id.startswith("/org/freedesktop/UDisks2/")
) or len(runtime_id) > 512: or runtime_id.startswith("mtp:")
)
if not valid_prefix or len(runtime_id) > 512:
raise RequestError("runtime_id invalide") raise RequestError("runtime_id invalide")
return operation, runtime_id return operation, runtime_id
@ -663,25 +1054,38 @@ class JsonSocketServer:
class GLibDispatcher: class GLibDispatcher:
"""Fait exécuter les mutations Gio dans le thread de la boucle GLib.""" """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.GLib = GLib
self.handler = handler self.handler = handler
def __call__(self, request: dict[str, Any]) -> dict[str, Any]: def __call__(self, request: dict[str, Any]) -> dict[str, Any]:
completed = threading.Event() completed = threading.Event()
result: list[dict[str, Any] | BaseException] = [] 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: def invoke() -> bool:
try: try:
result.append(self.handler(request)) cancellation.append(self.handler(request, complete))
except BaseException as error: except BaseException as error:
result.append(error) complete(error)
finally:
completed.set()
return False return False
self.GLib.idle_add(invoke) 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é") raise RequestError("Délai d'opération dépassé")
value = result[0] value = result[0]
if isinstance(value, BaseException): if isinstance(value, BaseException):
@ -697,13 +1101,17 @@ def main() -> int:
from gi.repository import Gio, GLib from gi.repository import Gio, GLib
base = runtime_dir() base = runtime_dir()
backend = UDisksBackend(Gio, GLib) backend = RemovableMediaBackend(
UDisksBackend(Gio, GLib), MTPBackend(Gio, GLib)
)
daemon = MediaDaemon(backend, base / STATE_NAME) daemon = MediaDaemon(backend, base / STATE_NAME)
# CONTRACT: s'abonner avant le scan ferme la fenêtre où un objet ajouté # CONTRACT: s'abonner avant le scan ferme la fenêtre où un objet ajouté
# entre ces deux opérations aurait échappé à l'automount. # entre ces deux opérations aurait échappé à l'automount.
backend.connect_changed(daemon.devices_changed) backend.connect_changed(daemon.devices_changed)
daemon.start() daemon.start()
server = JsonSocketServer(base / SOCKET_NAME, GLibDispatcher(GLib, daemon.handle)) server = JsonSocketServer(
base / SOCKET_NAME, GLibDispatcher(GLib, daemon.handle_async)
)
server.start() server.start()
threading.Thread(target=server.serve_forever, daemon=True).start() threading.Thread(target=server.serve_forever, daemon=True).start()
GLib.MainLoop().run() GLib.MainLoop().run()

View file

@ -22,7 +22,7 @@ def usage() -> int:
" labfy-removable-mediactl mount <runtime-id>\n" " labfy-removable-mediactl mount <runtime-id>\n"
" labfy-removable-mediactl unmount <runtime-id>\n" " labfy-removable-mediactl unmount <runtime-id>\n"
" labfy-removable-mediactl open <runtime-id>\n" " labfy-removable-mediactl open <runtime-id>\n"
" labfy-removable-mediactl safe-remove <drive-id>", " labfy-removable-mediactl safe-remove <safe-remove-id>",
file=sys.stderr, file=sys.stderr,
) )
return 2 return 2

View file

@ -33,7 +33,7 @@ et `timers.target.wants/` expriment les
activations utilisateur retenues. `.assets/enabled-user-aux-units.txt` capture activations utilisateur retenues. `.assets/enabled-user-aux-units.txt` capture
en complément les timers et sockets activés observés sur la machine. 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 `labfy-removable-media.service` appartient au package Stow `systemd` et son
exécutable `labfy-removable-media`, ainsi que le client 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 Sway. L'unité est rattachée à `wayland-session@sway.desktop.target`, redémarre
sur échec et n'est pas un service système. sur échec et n'est pas un service système.
Le daemon observe l'ObjectManager UDisks du bus système et publie son état Un seul daemon de session assure le cycle de vie, le socket et le snapshot de
runtime JSON V1 dans `$XDG_RUNTIME_DIR/labfy-removable-media.json`. Son socket `labfy-removable-media`. Il observe l'ObjectManager UDisks du bus système pour
Unix `$XDG_RUNTIME_DIR/labfy-removable-media.sock` et le snapshot sont créés les lecteurs USB et SD/MMC, et `Gio.VolumeMonitor`/GVFS-MTP pour les
avec le mode `0600`. L'IPC n'accepte qu'une requête JSON téléphones. Il publie le snapshot JSON `labfy.removable-media` version `2`
terminée par LF pour `list`, `mount`, `unmount`, `open` ou `safe-remove` ; les dans `$XDG_RUNTIME_DIR/labfy-removable-media.json`. Son socket Unix
identifiants runtime sont relus dans UDisks avant toute mutation. `$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 Seuls les systèmes de fichiers des lecteurs USB ou SD/MMC éligibles et les
blocs `HintIgnore` et `HintSystem`, ainsi que les volumes `crypto_LUKS`, sont volumes MTP détectés par GVFS sont présentés. Pour UDisks, les blocs
exclus ; aucun déverrouillage LUKS n'est tenté. MTP ne relève pas de ce contrat `HintIgnore` et `HintSystem`, ainsi que les volumes `crypto_LUKS`, sont exclus ;
UDisks `Block`/`Filesystem` et n'est pas pris en charge. Un objet UDisks aucun déverrouillage LUKS n'est tenté. Pendant la vie du daemon, chaque support
nouvellement présent est monté au plus une fois : un échec est isolé des autres présent est monté automatiquement au plus une fois : un échec est isolé des
volumes, et une nouvelle tentative automatique demande une disparition puis une autres supports, et une nouvelle tentative automatique demande une disparition
réinsertion de l'objet. Un démontage manuel ne provoque donc pas de remontage puis une réinsertion. Pendant cette même vie, un démontage manuel ne provoque
sur un événement de propriétés ultérieur. 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 Le schéma V2 publie des identifiants MTP opaques, `kind` et `actions`. Chaque
systèmes de fichiers, y compris ceux masqués par l'interface, puis relit leur appareil publie aussi `safe_remove_runtime_id`, y compris lorsque `safe-remove`
état avant d'appeler `PowerOff` ou `Eject`. 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 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 : 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' python3 -B -m unittest discover -s tests/removable_media -p 'test_*.py'
``` ```
Les tests utilisent des backends et sockets synthétiques. Ils couvrent le Les 29 tests `unittest` utilisent des backends et sockets synthétiques. Ils
filtrage UDisks, l'automontage borné, les opérations par volume ou lecteur, le couvrent le filtrage UDisks, le cycle de vie MTP/GVFS, l'automontage borné, les
contrat JSON et les limites de l'IPC, mais ne constituent pas une validation sur opérations par volume ou lecteur, le contrat JSON V2 et les limites de l'IPC.
matériel USB/SD réel. Cette validation n'a pas encore été effectuée. 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 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. participe ni aux checkpoints, ni au choix de restauration au démarrage.

View file

@ -1,5 +1,5 @@
[Unit] [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 PartOf=wayland-session@sway.desktop.target
After=wayland-session@sway.desktop.target After=wayland-session@sway.desktop.target

View file

@ -106,17 +106,23 @@ class MediaDaemonTests(unittest.TestCase):
state = daemon.state() state = daemon.state()
self.assertEqual(state["schema"], "labfy.removable-media") 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), {"schema", "version", "devices", "updated_at"})
self.assertEqual( self.assertEqual(
set(state["devices"][0]), 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", "filesystem", "size_bytes", "mount_point", "mounted",
"removable", "ejectable", "power_off_capable", "busy", "error", "removable", "ejectable", "power_off_capable", "busy", "error",
}, },
) )
self.assertTrue(state["devices"][0]["mounted"]) 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(stat.S_IMODE(self.state_path.stat().st_mode), 0o600)
self.assertEqual(json.loads(self.state_path.read_text()), state) self.assertEqual(json.loads(self.state_path.read_text()), state)

View file

@ -25,6 +25,10 @@ SPEC.loader.exec_module(media)
class IpcTests(unittest.TestCase): class IpcTests(unittest.TestCase):
def test_request_allowlist_and_shape(self): def test_request_allowlist_and_shape(self):
self.assertEqual(media.validate_request({"operation": "list"}), ("list", None)) 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): with self.assertRaises(media.RequestError):
media.validate_request({"operation": "format", "runtime_id": "/tmp/x"}) media.validate_request({"operation": "format", "runtime_id": "/tmp/x"})
with self.assertRaises(media.RequestError): with self.assertRaises(media.RequestError):
@ -70,6 +74,30 @@ class IpcTests(unittest.TestCase):
self.assertIn("volumineuse", response["error"]) self.assertIn("volumineuse", response["error"])
self.assertEqual(called, []) 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): def test_slow_incomplete_request_times_out_without_calling_handler(self):
called = [] called = []
server = media.JsonSocketServer(Path("unused"), lambda request: called.append(request)) server = media.JsonSocketServer(Path("unused"), lambda request: called.append(request))

View file

@ -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()

View file

@ -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 -- <cwd>` 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.

View file

@ -25,3 +25,9 @@ Status:children_add(function()
" ", " ",
} }
end, 500, Status.RIGHT) 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()

View file

@ -5,6 +5,7 @@
[mgr] [mgr]
keymap = [ keymap = [
{ on = "M", run = "plugin devices", desc = "Périphériques amovibles" },
{ on = "<Esc>", run = "escape", desc = "Exit visual mode, clear selection, or cancel search" }, { on = "<Esc>", run = "escape", desc = "Exit visual mode, clear selection, or cancel search" },
{ on = "<C-[>", run = "escape", desc = "Exit visual mode, clear selection, or cancel search" }, { on = "<C-[>", run = "escape", desc = "Exit visual mode, clear selection, or cancel search" },
{ on = "q", run = "quit", desc = "Quit the process" }, { on = "q", run = "quit", desc = "Quit the process" },

View file

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

View file

@ -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

View file

@ -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

View file

@ -1,5 +1,5 @@
[manager] [manager]
ratio = [ 1, 4, 3 ] ratio = [ 2, 5, 3 ]
show_hidden = true # Voir les fichiers .cachés show_hidden = true # Voir les fichiers .cachés
linemode = "size" # Nom + taille linemode = "size" # Nom + taille
sort_by = "modified" # Tri par date sort_by = "modified" # Tri par date
@ -61,7 +61,7 @@ rules = [
"$schema" = "https://yazi-rs.github.io/schemas/yazi.json" "$schema" = "https://yazi-rs.github.io/schemas/yazi.json"
[mgr] [mgr]
ratio = [ 1, 4, 3 ] ratio = [ 2, 5, 3 ]
sort_by = "alphabetical" sort_by = "alphabetical"
sort_sensitive = false sort_sensitive = false
sort_reverse = false sort_reverse = false