feat(removable): add MTP support and Yazi device views
This commit is contained in:
parent
21b5ae2571
commit
c06c55db13
14 changed files with 1117 additions and 46 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ def usage() -> int:
|
|||
" labfy-removable-mediactl mount <runtime-id>\n"
|
||||
" labfy-removable-mediactl unmount <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,
|
||||
)
|
||||
return 2
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
347
tests/removable_media/test_mtp.py
Normal file
347
tests/removable_media/test_mtp.py
Normal 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()
|
||||
51
yazi/.config/yazi/README.md
Normal file
51
yazi/.config/yazi/README.md
Normal 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.
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
[mgr]
|
||||
|
||||
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 = "<C-[>", run = "escape", desc = "Exit visual mode, clear selection, or cancel search" },
|
||||
{ on = "q", run = "quit", desc = "Quit the process" },
|
||||
|
|
|
|||
5
yazi/.config/yazi/plugins/devices.yazi/README.md
Normal file
5
yazi/.config/yazi/plugins/devices.yazi/README.md
Normal 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.
|
||||
163
yazi/.config/yazi/plugins/devices.yazi/main.lua
Normal file
163
yazi/.config/yazi/plugins/devices.yazi/main.lua
Normal 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
|
||||
38
yazi/.config/yazi/plugins/disk-space.yazi/main.lua
Normal file
38
yazi/.config/yazi/plugins/disk-space.yazi/main.lua
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue