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
|
#!/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
|
||||||
|
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":
|
if operation == "safe-remove":
|
||||||
self.refresh()
|
|
||||||
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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
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)
|
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]
|
[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" },
|
||||||
|
|
|
||||||
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]
|
[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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue