macOS support

This commit is contained in:
sarah committed 2026-10-14 13:18:18 +02:00
1 parent 5613f4f8fe
commit 1bd37db1ba
8 files changed
+113 -29

No files matched your search

+14 -8
View File
@@ -21,21 +21,26 @@ BundledFontFamily = "Fixedsys Excelsior"
ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme
IsWindows = sys.platform == "win32" IsWindows = sys.platform == "win32"
IsMac = sys.platform == "darwin"
IsLinux = not IsWindows and not IsMac
# Each system's own per-user folders, like SaraSuite's: ~/.config and # Each system's own per-user folders, like SaraSuite's: ~/.config and
# ~/.local/share on Linux, %APPDATA% and %LOCALAPPDATA% on Windows. # ~/.local/share on Linux, %APPDATA% and %LOCALAPPDATA% on Windows,
# ~/Library/Application Support (both) on macOS.
Home = Path.home() Home = Path.home()
if IsWindows: if IsWindows:
ConfigHome = Path(os.environ.get("APPDATA") or Home / "AppData" / "Roaming") ConfigHome = Path(os.environ.get("APPDATA") or Home / "AppData" / "Roaming")
DataHome = Path(os.environ.get("LOCALAPPDATA") or Home / "AppData" / "Local") DataHome = Path(os.environ.get("LOCALAPPDATA") or Home / "AppData" / "Local")
elif IsMac:
ConfigHome = DataHome = Home / "Library" / "Application Support"
else: else:
ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Home / ".config") ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Home / ".config")
DataHome = Path(os.environ.get("XDG_DATA_HOME") or Home / ".local" / "share") DataHome = Path(os.environ.get("XDG_DATA_HOME") or Home / ".local" / "share")
# Linux: whether systemd runs the system (and so SaraSortd's --user unit), # Linux: whether systemd runs the system (and so SaraSortd's --user unit),
# the same check as SaraSuite's. Without it (runit on Void, OpenRC, ...), # the same check as SaraSuite's. Without it (runit on Void, OpenRC, ...),
# SaraSuite runs SaraSortd itself, the way it does on Windows (Service.py). # SaraSuite runs SaraSortd itself, the way it does on Windows and macOS (Service.py).
HasSystemd = not IsWindows and Path("/run/systemd/system").is_dir() and shutil.which("systemctl") is not None HasSystemd = IsLinux and Path("/run/systemd/system").is_dir() and shutil.which("systemctl") is not None
RunBySaraSuite = IsWindows or not HasSystemd RunBySaraSuite = IsWindows or not HasSystemd
AutostartDir = ConfigHome / "autostart" AutostartDir = ConfigHome / "autostart"
@@ -157,12 +162,13 @@ class AppConfig:
# Where SaraSuite installs SaraSortd: GlobalConf.toml and the two # Where SaraSuite installs SaraSortd: GlobalConf.toml and the two
# directory config templates live in SortdConfigDir, SaraSortd.py in # directory config templates live in SortdConfigDir, SaraSortd.py in
# SortdAppDir, and it runs as a systemd user unit (Linux) or a background # SortdAppDir, and it runs as a systemd user unit (Linux) or a background
# service started from the Startup folder (Windows; see Service.py). # service started at login from the Startup folder (Windows) or a
SortdConfigDir: str = (str(ConfigHome / "SaraSuite" / "SaraSortd") if IsWindows # LaunchAgent (macOS; see Service.py).
SortdConfigDir: str = (str(ConfigHome / "SaraSuite" / "SaraSortd") if not IsLinux
else "~/.config/SaraSuite/SaraSortd") else "~/.config/SaraSuite/SaraSortd")
SortdAppDir: str = (str(DataHome / "SaraSuite" / "Programs" / "SaraSortd" / "App") if IsWindows SortdAppDir: str = (str(DataHome / "SaraSuite" / "Programs" / "SaraSortd" / "App") if not IsLinux
else "~/.local/share/SaraSuite/Programs/SaraSortd/App") else "~/.local/share/SaraSuite/Programs/SaraSortd/App")
ServiceName: str = "SaraSortd" if IsWindows else "SaraSortd.service" ServiceName: str = "SaraSortd.service" if IsLinux else "SaraSortd"
# SaraSortd only reads GlobalConf.toml when it starts. # SaraSortd only reads GlobalConf.toml when it starts.
AutoRestart: bool = True AutoRestart: bool = True
RefreshSeconds: int = 5 # status and log refresh on the Service page RefreshSeconds: int = 5 # status and log refresh on the Service page
@@ -380,6 +386,6 @@ def Heading(Text: str) -> ft.Text:
def DisplayPath(PathText: str) -> str: def DisplayPath(PathText: str) -> str:
"""/home/sarah/Sorting -> ~/Sorting, for labels.""" """/home/sarah/Sorting -> ~/Sorting, for labels."""
HomeText = str(Home) HomeText = str(Home)
if PathText == HomeText or PathText.startswith(HomeText + "/"): if PathText == HomeText or PathText.startswith((HomeText + "/", HomeText + os.sep)):
return "~" + PathText[len(HomeText):] return "~" + PathText[len(HomeText):]
return PathText return PathText
+8 -1
View File
@@ -1,7 +1,7 @@
# Kept in sync between SaraSuite/Programs/SaraSort/ and SaraSort/Packaging/SaraSuite/. # Kept in sync between SaraSuite/Programs/SaraSort/ and SaraSort/Packaging/SaraSuite/.
Name = "SaraSort" Name = "SaraSort"
Parent = "SaraSortd" # a GUI for SaraSortd, listed under it Parent = "SaraSortd" # a GUI for SaraSortd, listed under it
Platforms = ["Linux", "Windows"] Platforms = ["Linux", "Windows", "macOS"]
Description = "A GUI for configuring SaraSortd: a flowchart of what goes where, its service and logs, configs and templates." Description = "A GUI for configuring SaraSortd: a flowchart of what goes where, its service and logs, configs and templates."
[Source] [Source]
@@ -31,6 +31,13 @@ Name = "SaraSort"
Script = "Main.py" Script = "Main.py"
Icon = "assets/Icon.png" Icon = "assets/Icon.png"
# macOS: an app bundle, ~/Applications/SaraSort.app (python Main.py, from the app folder).
[[Shortcuts]]
Platform = "macOS"
Name = "SaraSort"
Script = "Main.py"
Icon = "assets/Icon.png"
# Made by SaraSort itself: Config.toml, Layout.toml, Templates/. # Made by SaraSort itself: Config.toml, Layout.toml, Templates/.
[[Leftovers]] [[Leftovers]]
Path = "{ConfigDir}" Path = "{ConfigDir}"
+1 -1
View File
@@ -25,7 +25,7 @@ Needs SaraSortd **7.1** or newer for arrows with their own patterns (per-entry `
The background: Add Directory, Edit Mode, Tidy Layout, Reload. The background: Add Directory, Edit Mode, Tidy Layout, Reload.
- **Service**: running/stopped, start/stop/restart, start on login, SaraSortd's version (and the - **Service**: running/stopped, start/stop/restart, start on login, SaraSortd's version (and the
SaraSuite install it came from), and its logs: its own log files, or the systemd journal (the SaraSuite install it came from), and its logs: its own log files, or the systemd journal (the
service log, where SaraSuite runs it: Windows, and Linux without systemd). service log, where SaraSuite runs it: Windows, macOS, and Linux without systemd).
- **Configs**: `GlobalConf.toml`, the two templates and every directory's own config, as a form (with - **Configs**: `GlobalConf.toml`, the two templates and every directory's own config, as a form (with
help text, a live NewFileName preview and variable buttons) or as raw TOML. Saving only writes the help text, a live NewFileName preview and variable buttons) or as raw TOML. Saving only writes the
values you changed, so SaraSortd's own NextNum/NextChar/LastFile updates aren't overwritten. values you changed, so SaraSortd's own NextNum/NextChar/LastFile updates aren't overwritten.
+28 -5
View File
@@ -2,19 +2,21 @@
and its logs. SaraSortd's own log files are read from its LogDir. and its logs. SaraSortd's own log files are read from its LogDir.
Linux with systemd: its systemd user unit (systemctl / journalctl). Linux with systemd: its systemd user unit (systemctl / journalctl).
Windows, and Linux without systemd (runit, OpenRC, s6, ...): SaraSuite runs Windows, macOS, and Linux without systemd (runit, OpenRC, s6, ...): SaraSuite runs
it itself (its Services.py), and this does the same: it itself (its Services.py), and this does the same:
- it runs as `python ServiceRun.py SaraSortd SaraSortd.py <ConfigDir>` - it runs as `python ServiceRun.py SaraSortd SaraSortd.py <ConfigDir>`
(pythonw.exe on Windows); ServiceRun.py restarts it if it fails, keeps its (pythonw.exe on Windows); ServiceRun.py restarts it if it fails, keeps its
output in SaraSuite's Services folder as SaraSortd.log (the "Service log" output in SaraSuite's Services folder as SaraSortd.log (the "Service log"
here) and records its PID in SaraSortd.pid (that's how "running" is told); here) and records its PID in SaraSortd.pid (that's how "running" is told);
- autostart is a shortcut to that command in the Startup folder (Windows) - autostart is a shortcut to that command in the Startup folder (Windows),
or an XDG autostart entry, ~/.config/autostart/SaraSortd.desktop (Linux). a LaunchAgent, ~/Library/LaunchAgents/srhtnin.SaraSortd.plist (macOS), or
an XDG autostart entry, ~/.config/autostart/SaraSortd.desktop (Linux).
Unit is the systemd unit name ("SaraSortd.service"); run by SaraSuite, the Unit is the systemd unit name ("SaraSortd.service"); run by SaraSuite, the
service is called the same without ".service". service is called the same without ".service".
""" """
import os import os
import plistlib
import re import re
import signal import signal
import subprocess import subprocess
@@ -23,7 +25,7 @@ from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from Core import AutostartDir, ChildEnvironment, ConfigHome, DataHome, IsWindows, LoadConfig, RunBySaraSuite from Core import AutostartDir, ChildEnvironment, ConfigHome, DataHome, Home, IsMac, IsWindows, LoadConfig, RunBySaraSuite
@dataclass @dataclass
@@ -48,7 +50,7 @@ def _Run(Arguments: list[str], Timeout: int = 15) -> subprocess.CompletedProcess
creationflags=_NoWindow) creationflags=_NoWindow)
# --- Run by SaraSuite (Windows; Linux without systemd) ------------------------------------------ # --- Run by SaraSuite (Windows; macOS; Linux without systemd) ------------------------------------------
def _Name(Unit: str) -> str: def _Name(Unit: str) -> str:
@@ -62,6 +64,8 @@ def _ServicesFile(Name: str, Suffix: str) -> Path:
def _AutostartFile(Name: str) -> Path: def _AutostartFile(Name: str) -> Path:
if IsWindows: if IsWindows:
return ConfigHome / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" / f"{Name}.lnk" return ConfigHome / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" / f"{Name}.lnk"
if IsMac:
return Home / "Library" / "LaunchAgents" / f"srhtnin.{Name}.plist"
return AutostartDir / f"{Name}.desktop" return AutostartDir / f"{Name}.desktop"
@@ -85,6 +89,11 @@ def _Pid(Name: str) -> str:
Pid = int(_ServicesFile(Name, ".pid").read_text().strip()) Pid = int(_ServicesFile(Name, ".pid").read_text().strip())
except (OSError, ValueError): except (OSError, ValueError):
return "" return ""
if IsMac: # no /proc: ask ps for its command line
try:
return str(Pid) if "ServiceRun.py" in _Run(["ps", "-o", "command=", "-p", str(Pid)]).stdout else ""
except (OSError, subprocess.SubprocessError):
return ""
if not IsWindows: if not IsWindows:
try: try:
return str(Pid) if b"ServiceRun.py" in Path(f"/proc/{Pid}/cmdline").read_bytes() else "" return str(Pid) if b"ServiceRun.py" in Path(f"/proc/{Pid}/cmdline").read_bytes() else ""
@@ -134,6 +143,18 @@ def _AutostartEntry(Python: Path, Arguments: list[str], Name: str) -> str:
) )
def _LaunchAgent(Python: Path, Arguments: list[str], Name: str, Folder: Path) -> bytes:
"""The same LaunchAgent SaraSuite writes (its MacSupport.LaunchAgent)."""
return plistlib.dumps({
"Label": f"srhtnin.{Name}",
"ProgramArguments": [str(Python), *Arguments],
"WorkingDirectory": str(Folder),
"RunAtLoad": True,
"ProcessType": "Background",
"StandardErrorPath": str(Home / "Library" / "Logs" / f"{Name}.launchd.log"),
})
def _OwnControl(Name: str, Action: str) -> str: def _OwnControl(Name: str, Action: str) -> str:
Python, Arguments, Folder = _Command(Name) Python, Arguments, Folder = _Command(Name)
try: try:
@@ -169,6 +190,8 @@ def _OwnControl(Name: str, Action: str) -> str:
Quoted = " ".join(f'"{Item}"' if " " in Item or "\\" in Item else Item for Item in Arguments) Quoted = " ".join(f'"{Item}"' if " " in Item or "\\" in Item else Item for Item in Arguments)
pylnk3.for_file(str(Python), str(Entry), arguments=Quoted, work_dir=str(Folder), pylnk3.for_file(str(Python), str(Entry), arguments=Quoted, work_dir=str(Folder),
description=f"{Name} (background service)") description=f"{Name} (background service)")
elif IsMac:
Entry.write_bytes(_LaunchAgent(Python, Arguments, Name, Folder))
else: else:
Entry.write_text(_AutostartEntry(Python, Arguments, Name)) Entry.write_text(_AutostartEntry(Python, Arguments, Name))
elif Action == "disable": elif Action == "disable":
+2 -2
View File
@@ -8,7 +8,7 @@ from pathlib import Path
import flet as ft import flet as ft
import tomlkit import tomlkit
from Core import ChipButton, ChipButtonStyle, DataHome, Heading, IsWindows, Note, Palette, RunBySaraSuite from Core import ChipButton, ChipButtonStyle, DataHome, Heading, IsMac, IsWindows, Note, Palette, RunBySaraSuite
from Service import Control, Journal, LogFiles, ReadTail, Status from Service import Control, Journal, LogFiles, ReadTail, Status
from Widgets import App from Widgets import App
@@ -109,7 +109,7 @@ class ServicePage(ft.Column):
Note(f"{'Service' if RunBySaraSuite else 'Unit'}: {self.App.Config.ServiceName.removesuffix('.service') if RunBySaraSuite else self.App.Config.ServiceName}"), Note(f"{'Service' if RunBySaraSuite else 'Unit'}: {self.App.Config.ServiceName.removesuffix('.service') if RunBySaraSuite else self.App.Config.ServiceName}"),
Note(f"Since: {Current.Since or '-'}"), Note(f"Since: {Current.Since or '-'}"),
Note(f"PID: {Current.Pid or '-'}"), Note(f"PID: {Current.Pid or '-'}"),
Note(f"{'Autostart (Startup folder)' if IsWindows else 'Autostart (at login)' if RunBySaraSuite else 'Unit file'}: {Current.Enabled}"), Note(f"{'Autostart (Startup folder)' if IsWindows else 'Autostart (LaunchAgent)' if IsMac else 'Autostart (at login)' if RunBySaraSuite else 'Unit file'}: {Current.Enabled}"),
] ]
self.StartButton.disabled = Current.Running or not Current.Found self.StartButton.disabled = Current.Running or not Current.Found
self.StopButton.disabled = not Current.Running self.StopButton.disabled = not Current.Running
+56 -8
View File
@@ -11,8 +11,9 @@ import flet as ft
import tomlkit import tomlkit
from Core import ( from Core import (
AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, ExportSettings, HasSystemd, AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, ConfigDir, DisplayPath,
Home, ImportSettings, IsWindows, LoadSharedSettings, Palette, SharedSettings, ExportSettings, HasSystemd, Home, ImportSettings, IsMac, IsWindows, LoadSharedSettings, Palette,
SharedConfigPath, SharedSettings,
) )
from Sortd import MajorVersion, Setup, SortdError from Sortd import MajorVersion, Setup, SortdError
from SortdTransfer import ApplyBundle, Bundle, BundleDirectories, ExportSortd, MissingDirectories, ReadBundleSafely from SortdTransfer import ApplyBundle, Bundle, BundleDirectories, ExportSortd, MissingDirectories, ReadBundleSafely
@@ -56,11 +57,58 @@ def _WindowsFonts() -> set[str]:
return Families 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 _Fonts() -> list[str]: def _Fonts() -> list[str]:
"""Installed font families (fontconfig, or the registry on Windows), with """Installed font families (fontconfig, the registry on Windows, the font
the bundled font first.""" files themselves on macOS), with the bundled font first."""
if IsWindows: if IsWindows or IsMac:
Families = _WindowsFonts() Families = _WindowsFonts() if IsWindows else _MacFonts()
Families.discard(BundledFontFamily) Families.discard(BundledFontFamily)
return [BundledFontFamily] + sorted(Families, key=str.lower) return [BundledFontFamily] + sorted(Families, key=str.lower)
try: try:
@@ -240,7 +288,7 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[], None]) -> None:
Categories: dict[str, ft.Control] = { Categories: dict[str, ft.Control] = {
"SaraSuite": ft.Column( "SaraSuite": ft.Column(
[ [
_Note("Shared by every SaraSuite program (~/.config/SaraSuite/Config.toml). " _Note(f"Shared by every SaraSuite program ({DisplayPath(str(SharedConfigPath))}). "
"Other programs pick changes up the next time they start."), "Other programs pick changes up the next time they start."),
UseSharedSwitch, UseSharedSwitch,
ft.Divider(height=1, color=Palette.Surface1), ft.Divider(height=1, color=Palette.Surface1),
@@ -398,7 +446,7 @@ def ShowSortdImportDialog(TheApp: App, TheBundle: Bundle, OnDone: Callable[[], N
+ (f", made {TheBundle.Exported.replace('T', ' ')}" if TheBundle.Exported else "") + "."), + (f", made {TheBundle.Exported.replace('T', ' ')}" if TheBundle.Exported else "") + "."),
ft.Text(f"This replaces SaraSortd's configs in {Config.SortdConfigDir} and in {Total} " ft.Text(f"This replaces SaraSortd's configs in {Config.SortdConfigDir} and in {Total} "
f"director{'y' if Total == 1 else 'ies'}. The current ones are backed up to " f"director{'y' if Total == 1 else 'ies'}. The current ones are backed up to "
"~/.config/SaraSuite/SaraSort/Backups/ first.", size=13), f"{DisplayPath(str(ConfigDir / 'Backups'))} first.", size=13),
] ]
try: try:
Daemon = Setup(Config).DaemonVersion() Daemon = Setup(Config).DaemonVersion()
+2 -2
View File
@@ -5,7 +5,7 @@ import os
import flet as ft import flet as ft
from Core import ChipButton, DisplayPath, Heading, Note, Palette from Core import ChipButton, DisplayPath, Heading, Note, Palette, UserTemplatesDir
from Templates import AllTemplates, DeleteUserTemplate, SaveUserTemplate, SortTemplate from Templates import AllTemplates, DeleteUserTemplate, SaveUserTemplate, SortTemplate
from Widgets import App, DirField, ExpandPath, NameField from Widgets import App, DirField, ExpandPath, NameField
@@ -26,7 +26,7 @@ class TemplatesPage(ft.Column):
padding=ft.Padding.only(left=16, right=16, top=12, bottom=4), padding=ft.Padding.only(left=16, right=16, top=12, bottom=4),
), ),
ft.Container(Note("Pick one, choose where files come from and where they go. " ft.Container(Note("Pick one, choose where files come from and where they go. "
"Your own templates live in ~/.config/SaraSuite/SaraSort/Templates/."), f"Your own templates live in {DisplayPath(str(UserTemplatesDir))}."),
padding=ft.Padding.only(left=16, bottom=8)), padding=ft.Padding.only(left=16, bottom=8)),
ft.Divider(height=1, color=Palette.Surface1), ft.Divider(height=1, color=Palette.Surface1),
ft.Container(ft.Column([self.Grid], scroll=ft.ScrollMode.AUTO, expand=True), padding=16, expand=True), ft.Container(ft.Column([self.Grid], scroll=ft.ScrollMode.AUTO, expand=True), padding=16, expand=True),
+2 -2
View File
@@ -7,7 +7,7 @@ from typing import Callable, Optional
import flet as ft import flet as ft
from Core import AppConfig, ChildEnvironment, ChipButton, IsWindows, Note, NotificationStyle, Palette from Core import AppConfig, ChildEnvironment, ChipButton, IsMac, IsWindows, Note, NotificationStyle, Palette
from Service import RestartIfRunning from Service import RestartIfRunning
from Sortd import Setup, SortdError from Sortd import Setup, SortdError
@@ -72,7 +72,7 @@ class App:
if IsWindows: if IsWindows:
os.startfile(PathText) # Explorer os.startfile(PathText) # Explorer
else: else:
subprocess.Popen(["xdg-open", PathText], env=ChildEnvironment(), start_new_session=True) subprocess.Popen(["open" if IsMac else "xdg-open", PathText], env=ChildEnvironment(), start_new_session=True)
except OSError as Error: except OSError as Error:
self.Notify(f"Couldn't open {PathText}: {Error}", "error") self.Notify(f"Couldn't open {PathText}: {Error}", "error")