mirror of
https://git.srhtnin.garden/sarah/SaraFem.git
synced 2026-10-14 18:18:12 +02:00
macOS support
This commit is contained in:
1 parent
3fcaf328a9
commit
b2eedc9ec8
5 files changed
+161
-21
No files matched your search
+5
-4
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user