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

+5 -4
View File
@@ -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.
+1 -1
View File
@@ -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}")
+59 -6
View File
@@ -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:
+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)
+3 -3
View File
@@ -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