macOS support

This commit is contained in:
sarah committed 2026-10-14 13:18:19 +02:00
1 parent 3fcaf328a9
commit b2eedc9ec8
5 files changed
+161 -21

No files matched your search

+93 -7
View File
@@ -14,14 +14,18 @@ the same service other file managers use:
- With AutoMountDevices on, a newly plugged-in device is mounted right away.
Falls back to polling /run/media/<user> if udisks2 isn't reachable.
Windows polls its drive letters (ListWindowsDrives), macOS /Volumes
(ListMacVolumes, ejecting with diskutil).
Everything runs on the page's own event loop (Page.run_task), so calling
.update() from here is safe."""
from __future__ import annotations # the D-Bus type hints below don't exist on Windows
from __future__ import annotations # the D-Bus type hints below don't exist on Windows or macOS
import asyncio
import getpass
import os
import plistlib
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
@@ -30,8 +34,9 @@ from typing import Callable, Optional
import flet as ft
IsWindows = sys.platform == "win32"
if not IsWindows:
# udisks2 is Linux-only; Windows lists its drives itself (ListWindowsDrives).
IsMac = sys.platform == "darwin"
if not IsWindows and not IsMac:
# udisks2 is Linux-only; Windows and macOS list their drives themselves.
from dbus_next import BusType, Message, MessageType, Variant
from dbus_next.aio import MessageBus
@@ -43,8 +48,9 @@ BlockInterface = f"{UDisks}.Block"
FilesystemInterface = f"{UDisks}.Filesystem"
DriveInterface = f"{UDisks}.Drive"
PollInterval = 3.0 # seconds, for Windows and the no-udisks2 fallback
PollInterval = 3.0 # seconds, for Windows, macOS and the no-udisks2 fallback
MountRoots = [Path("/run/media") / getpass.getuser(), Path("/media") / getpass.getuser()]
MacVolumesDir = Path("/Volumes")
@dataclass
@@ -112,6 +118,56 @@ def ListWindowsDrives(ShowInternal: bool, ShowSystemDrive: bool) -> list[Device]
return Devices
_MacVolumeInfo: dict[str, dict] = {} # mount point -> diskutil's info, asked once per volume
def _MacInfo(MountPoint: Path) -> dict:
Key = str(MountPoint)
if Key not in _MacVolumeInfo:
try:
Output = subprocess.run(["diskutil", "info", "-plist", Key], capture_output=True, timeout=10).stdout
_MacVolumeInfo[Key] = plistlib.loads(Output) if Output else {}
except (OSError, subprocess.SubprocessError, plistlib.InvalidFileException):
_MacVolumeInfo[Key] = {}
return _MacVolumeInfo[Key]
def ListMacVolumes(ShowInternal: bool, ShowSystemDrive: bool) -> list[Device]:
"""macOS: what's mounted in /Volumes. Removable and ejectable volumes
(USB sticks, memory cards, disk images, discs); internal disks and
network shares with ShowInternal; the startup disk (the /Volumes entry
that points at "/") with ShowSystemDrive. macOS mounts volumes itself, so
there's nothing to mount; CanPowerOff means diskutil can eject it."""
Devices: list[Device] = []
Seen: set[str] = set()
try:
Entries = sorted(MacVolumesDir.iterdir(), key=lambda Entry: Entry.name.lower())
except OSError:
return Devices
for Entry in Entries:
try:
IsSystem = Entry.resolve() == Path("/")
except OSError:
continue
if IsSystem:
if ShowSystemDrive:
Devices.append(Device(str(Entry), Entry.name, Path("/"), IsSystemMount=True))
continue
if not Entry.is_dir():
continue
Seen.add(str(Entry))
Info = _MacInfo(Entry)
Ejectable = bool(Info.get("Ejectable") or Info.get("RemovableMedia") or Info.get("Removable"))
Internal = bool(Info.get("Internal")) or not Info # no info: a network share
if Internal and not Ejectable and not ShowInternal:
continue
Devices.append(Device(str(Entry), Entry.name, Entry, CanPowerOff=Ejectable))
# Forget volumes that went away, so one plugged in again is asked about afresh.
for Gone in set(_MacVolumeInfo) - Seen:
del _MacVolumeInfo[Gone]
return Devices
def ListFallbackDevices() -> list[Device]:
Devices: list[Device] = []
for Root in MountRoots:
@@ -251,8 +307,8 @@ class DevicesPanel(ft.Container):
self.Client = None
async def _Start(self) -> None:
if IsWindows:
await self._PollLoop() # no udisks2: Windows' own drive list, checked every few seconds
if IsWindows or IsMac:
await self._PollLoop() # no udisks2: the system's own drive list, checked every few seconds
return
try:
self.Client = await UDisksClient.Connect()
@@ -267,6 +323,9 @@ class DevicesPanel(ft.Container):
while not self.Unmounted:
if IsWindows:
self._Show(ListWindowsDrives(self.Config.ShowInternalDrives, self.Config.ShowSystemDrive))
elif IsMac:
Volumes = await asyncio.to_thread(ListMacVolumes, self.Config.ShowInternalDrives, self.Config.ShowSystemDrive)
self._Show(Volumes)
else:
self._Show(ListFallbackDevices())
await asyncio.sleep(PollInterval)
@@ -334,7 +393,7 @@ class DevicesPanel(ft.Container):
tooltip="Eject",
# Never offer to eject the system/boot drive — unmounting
# your own running root filesystem would crash it.
visible=self.Client is not None and not Current.IsSystemMount
visible=(self.Client is not None or (IsMac and Current.CanPowerOff)) and not Current.IsSystemMount
and (Mounted or Current.CanPowerOff),
on_click=lambda e, D=Current: self.page.run_task(self._EjectDevice, D),
),
@@ -368,7 +427,34 @@ class DevicesPanel(ft.Container):
if Open:
self.OnNavigate(MountPoint)
async def _EjectMacVolume(self, Target: Device) -> None:
"""macOS: diskutil unmounts it (and every other volume on the same
disk) and, for a removable disk, lets it be pulled out."""
if self.CurrentPath is not None and Target.MountPoint is not None and (
self.CurrentPath == Target.MountPoint or Target.MountPoint in self.CurrentPath.parents
):
self.OnNavigate(Path.home())
self.Busy.add(Target.Key)
try:
Process = await asyncio.create_subprocess_exec(
"diskutil", "eject", str(Target.MountPoint),
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
)
Output = (await Process.communicate())[0].decode(errors="replace").strip()
if Process.returncode == 0:
self._Notify(f"{Target.Label} can be removed now.", Kind="success")
else:
self._Notify(f"Couldn't eject {Target.Label}: {Output or 'diskutil failed'}")
except OSError as Error:
self._Notify(f"Couldn't eject {Target.Label}: {Error}")
finally:
self.Busy.discard(Target.Key)
self._Show(await asyncio.to_thread(ListMacVolumes, self.Config.ShowInternalDrives, self.Config.ShowSystemDrive))
async def _EjectDevice(self, Target: Device) -> None:
if IsMac and Target.CanPowerOff and not Target.IsSystemMount and Target.Key not in self.Busy:
await self._EjectMacVolume(Target)
return
if self.Client is None or Target.Key in self.Busy or Target.IsSystemMount:
return # never unmount the system drive, regardless of how this got called
self.Busy.add(Target.Key)