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
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
# ~/.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()
if IsWindows:
ConfigHome = Path(os.environ.get("APPDATA") or Home / "AppData" / "Roaming")
DataHome = Path(os.environ.get("LOCALAPPDATA") or Home / "AppData" / "Local")
elif IsMac:
ConfigHome = DataHome = Home / "Library" / "Application Support"
else:
ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Home / ".config")
DataHome = Path(os.environ.get("XDG_DATA_HOME") or Home / ".local" / "share")
# Linux: whether systemd runs the system (and so SaraSortd's --user unit),
# the same check as SaraSuite's. Without it (runit on Void, OpenRC, ...),
# SaraSuite runs SaraSortd itself, the way it does on Windows (Service.py).
HasSystemd = not IsWindows and Path("/run/systemd/system").is_dir() and shutil.which("systemctl") is not None
# SaraSuite runs SaraSortd itself, the way it does on Windows and macOS (Service.py).
HasSystemd = IsLinux and Path("/run/systemd/system").is_dir() and shutil.which("systemctl") is not None
RunBySaraSuite = IsWindows or not HasSystemd
AutostartDir = ConfigHome / "autostart"
@@ -157,12 +162,13 @@ class AppConfig:
# Where SaraSuite installs SaraSortd: GlobalConf.toml and the two
# directory config templates live in SortdConfigDir, SaraSortd.py in
# SortdAppDir, and it runs as a systemd user unit (Linux) or a background
# service started from the Startup folder (Windows; see Service.py).
SortdConfigDir: str = (str(ConfigHome / "SaraSuite" / "SaraSortd") if IsWindows
# service started at login from the Startup folder (Windows) or a
# LaunchAgent (macOS; see Service.py).
SortdConfigDir: str = (str(ConfigHome / "SaraSuite" / "SaraSortd") if not IsLinux
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")
ServiceName: str = "SaraSortd" if IsWindows else "SaraSortd.service"
ServiceName: str = "SaraSortd.service" if IsLinux else "SaraSortd"
# SaraSortd only reads GlobalConf.toml when it starts.
AutoRestart: bool = True
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:
"""/home/sarah/Sorting -> ~/Sorting, for labels."""
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
+8 -1
View File
@@ -1,7 +1,7 @@
# Kept in sync between SaraSuite/Programs/SaraSort/ and SaraSort/Packaging/SaraSuite/.
Name = "SaraSort"
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."
[Source]
@@ -31,6 +31,13 @@ Name = "SaraSort"
Script = "Main.py"
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/.
[[Leftovers]]
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.
- **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
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
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.
+28 -5
View File
@@ -2,19 +2,21 @@
and its logs. SaraSortd's own log files are read from its LogDir.
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 runs as `python ServiceRun.py SaraSortd SaraSortd.py <ConfigDir>`
(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"
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)
or an XDG autostart entry, ~/.config/autostart/SaraSortd.desktop (Linux).
- autostart is a shortcut to that command in the Startup folder (Windows),
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
service is called the same without ".service".
"""
import os
import plistlib
import re
import signal
import subprocess
@@ -23,7 +25,7 @@ from dataclasses import dataclass
from datetime import datetime
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
@@ -48,7 +50,7 @@ def _Run(Arguments: list[str], Timeout: int = 15) -> subprocess.CompletedProcess
creationflags=_NoWindow)
# --- Run by SaraSuite (Windows; Linux without systemd) ------------------------------------------
# --- Run by SaraSuite (Windows; macOS; Linux without systemd) ------------------------------------------
def _Name(Unit: str) -> str:
@@ -62,6 +64,8 @@ def _ServicesFile(Name: str, Suffix: str) -> Path:
def _AutostartFile(Name: str) -> Path:
if IsWindows:
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"
@@ -85,6 +89,11 @@ def _Pid(Name: str) -> str:
Pid = int(_ServicesFile(Name, ".pid").read_text().strip())
except (OSError, ValueError):
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:
try:
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:
Python, Arguments, Folder = _Command(Name)
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)
pylnk3.for_file(str(Python), str(Entry), arguments=Quoted, work_dir=str(Folder),
description=f"{Name} (background service)")
elif IsMac:
Entry.write_bytes(_LaunchAgent(Python, Arguments, Name, Folder))
else:
Entry.write_text(_AutostartEntry(Python, Arguments, Name))
elif Action == "disable":
+2 -2
View File
@@ -8,7 +8,7 @@ from pathlib import Path
import flet as ft
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 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"Since: {Current.Since 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.StopButton.disabled = not Current.Running
+56 -8
View File
@@ -11,8 +11,9 @@ import flet as ft
import tomlkit
from Core import (
AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, ExportSettings, HasSystemd,
Home, ImportSettings, IsWindows, LoadSharedSettings, Palette, SharedSettings,
AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, ConfigDir, DisplayPath,
ExportSettings, HasSystemd, Home, ImportSettings, IsMac, IsWindows, LoadSharedSettings, Palette,
SharedConfigPath, SharedSettings,
)
from Sortd import MajorVersion, Setup, SortdError
from SortdTransfer import ApplyBundle, Bundle, BundleDirectories, ExportSortd, MissingDirectories, ReadBundleSafely
@@ -56,11 +57,58 @@ 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 _Fonts() -> list[str]:
"""Installed font families (fontconfig, or the registry on Windows), with
the bundled font first."""
if IsWindows:
Families = _WindowsFonts()
"""Installed font families (fontconfig, the registry on Windows, the font
files themselves on macOS), with the bundled font first."""
if IsWindows or IsMac:
Families = _WindowsFonts() if IsWindows else _MacFonts()
Families.discard(BundledFontFamily)
return [BundledFontFamily] + sorted(Families, key=str.lower)
try:
@@ -240,7 +288,7 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[], None]) -> None:
Categories: dict[str, ft.Control] = {
"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."),
UseSharedSwitch,
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 "") + "."),
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 "
"~/.config/SaraSuite/SaraSort/Backups/ first.", size=13),
f"{DisplayPath(str(ConfigDir / 'Backups'))} first.", size=13),
]
try:
Daemon = Setup(Config).DaemonVersion()
+2 -2
View File
@@ -5,7 +5,7 @@ import os
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 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),
),
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)),
ft.Divider(height=1, color=Palette.Surface1),
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
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 Sortd import Setup, SortdError
@@ -72,7 +72,7 @@ class App:
if IsWindows:
os.startfile(PathText) # Explorer
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:
self.Notify(f"Couldn't open {PathText}: {Error}", "error")