diff --git a/Bookmarks.py b/Bookmarks.py index c5757d5..5edd0d6 100644 --- a/Bookmarks.py +++ b/Bookmarks.py @@ -5,13 +5,14 @@ from pathlib import Path import toml -from Core import BookmarksPath, ConfigDir, IsWindows +from Core import BookmarksPath, ConfigDir, IsMac, IsWindows DefaultIcon = "BOOKMARK_OUTLINE" # The freedesktop trash folder (Linux). Windows' Recycle Bin isn't a folder -# that can be browsed, so there's no Trash bookmark there (deleting still goes -# to the Recycle Bin, through send2trash). -TrashPath = None if IsWindows else Path.home() / ".local" / "share" / "Trash" / "files" +# that can be browsed, and macOS only lets apps with Full Disk Access list +# ~/.Trash, so there's no Trash bookmark there (deleting still goes to the +# Recycle Bin / Trash, through send2trash). +TrashPath = None if IsWindows or IsMac else Path.home() / ".local" / "share" / "Trash" / "files" # One-time marker: without it, re-adding the Trash bookmark to old files (see # LoadBookmarks) would run on every load and undo the user removing it. diff --git a/Browser.py b/Browser.py index b36de61..b58dfb8 100644 --- a/Browser.py +++ b/Browser.py @@ -927,7 +927,7 @@ class Browser(ft.Row): if sys.platform == "win32": os.startfile(str(EntryPath)) # the default app else: - subprocess.Popen(["xdg-open", str(EntryPath)]) + subprocess.Popen(["open" if sys.platform == "darwin" else "xdg-open", str(EntryPath)]) except OSError as Error: self._Notify(f"Couldn't open {EntryPath.name}: {Error}") diff --git a/Core.py b/Core.py index 98e059d..9d87671 100644 --- a/Core.py +++ b/Core.py @@ -48,14 +48,20 @@ BundledFontFamily = "Fixedsys Excelsior" ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme IsWindows = sys.platform == "win32" +IsMac = sys.platform == "darwin" # Each system's own per-user folders, like SaraSuite's: ~/.config and ~/.cache -# on Linux; %APPDATA% (settings) and %LOCALAPPDATA% (caches) on Windows. +# on Linux; %APPDATA% (settings) and %LOCALAPPDATA% (caches) on Windows; +# ~/Library/Application Support and ~/Library/Caches on macOS. if IsWindows: ConfigHome = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") CacheDir = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") / "SaraFem" / "Cache" # Where the file tree starts by default: the system drive (C:\), not "/". DefaultFiletreeRoot = (os.environ.get("SystemDrive") or "C:") + "\\" +elif IsMac: + ConfigHome = Path.home() / "Library" / "Application Support" + CacheDir = Path.home() / "Library" / "Caches" / "SaraFem" + DefaultFiletreeRoot = "/" else: ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") CacheDir = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "SaraFem" @@ -593,12 +599,59 @@ def _WindowsFonts() -> set[str]: return Families +def _MacFonts() -> set[str]: + """Font families on macOS (no fontconfig there), read from the font files' + name tables in the system, shared and personal Fonts folders. The + typographic family (name ID 16) where there is one, else ID 1.""" + import struct + + Families: set[str] = set() + for Folder in (Path("/System/Library/Fonts"), Path("/Library/Fonts"), Path.home() / "Library" / "Fonts"): + if not Folder.is_dir(): + continue + for FilePath in Folder.rglob("*"): + if FilePath.suffix.lower() not in (".ttf", ".otf", ".ttc"): + continue + try: + Data = FilePath.read_bytes() + except OSError: + continue + Offsets = [0] + if Data[:4] == b"ttcf": # a collection: every font in it + Count = struct.unpack_from(">I", Data, 8)[0] + Offsets = list(struct.unpack_from(f">{Count}I", Data, 12)) + for Offset in Offsets: + try: + for Index in range(struct.unpack_from(">H", Data, Offset + 4)[0]): + Tag, _Sum, Start, _Length = struct.unpack_from(">4sIII", Data, Offset + 12 + Index * 16) + if Tag != b"name": + continue + _Format, Count, Strings = struct.unpack_from(">HHH", Data, Start) + Found: dict[int, str] = {} + for Record in range(Count): + PlatformId, _Encoding, Language, NameId, Length, At = struct.unpack_from( + ">6H", Data, Start + 6 + Record * 12) + if NameId not in (1, 16) or NameId in Found: + continue + Raw = Data[Start + Strings + At:Start + Strings + At + Length] + if PlatformId in (0, 3): + Found[NameId] = Raw.decode("utf-16-be", "replace") + elif PlatformId == 1 and Language == 0: + Found[NameId] = Raw.decode("mac-roman", "replace") + Name = (Found.get(16) or Found.get(1) or "").strip() + if Name and not Name.startswith("."): # ".SF NS" and co. are system-internal + Families.add(Name) + except struct.error: + continue + return Families + + def InstalledFontFamilies() -> list[str]: - """Font families fontconfig (or, on Windows, the registry) knows about, - for the font picker. The bundled font is always first, since it isn't - installed system-wide.""" - if IsWindows: - Families = _WindowsFonts() + """Font families fontconfig (or, on Windows, the registry; on macOS, the + font files themselves) knows about, for the font picker. The bundled font + is always first, since it isn't installed system-wide.""" + if IsWindows or IsMac: + Families = _WindowsFonts() if IsWindows else _MacFonts() Families.discard(BundledFontFamily) return [BundledFontFamily] + sorted(Families, key=str.lower) try: diff --git a/Devices.py b/Devices.py index 8a672e6..b82f8d4 100644 --- a/Devices.py +++ b/Devices.py @@ -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/ 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) diff --git a/Main.py b/Main.py index afb52af..6b4b830 100644 --- a/Main.py +++ b/Main.py @@ -19,8 +19,8 @@ def _SetCursorThemeFallback() -> None: """GTK's cursor lookup reads XCURSOR_THEME directly; it isn't always inherited from the desktop session (shows as 'Unable to load ... from the cursor theme'). Fill it in from gsettings if missing, so the warning has nowhere to come from. - GTK/GNOME only: nothing to do on Windows.""" - if os.environ.get("XCURSOR_THEME") or sys.platform == "win32": + GTK/GNOME only: nothing to do on Windows or macOS.""" + if os.environ.get("XCURSOR_THEME") or not sys.platform.startswith("linux"): return try: Result = subprocess.run( @@ -250,7 +250,7 @@ def Main(Page: ft.Page) -> None: if __name__ == "__main__": - if "--portal-service" in sys.argv and sys.platform != "win32": # Linux's file-picker portal only + if "--portal-service" in sys.argv and sys.platform.startswith("linux"): # Linux's file-picker portal only # No Flet page at all here — the D-Bus FileChooser backend is a # plain asyncio service (see PortalService.py) that # spawns *this same executable* again with --pick-* flags for the