diff --git a/Core.py b/Core.py index 112a83e..9ea10a5 100644 --- a/Core.py +++ b/Core.py @@ -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 diff --git a/Packaging/SaraSuite/Manifest.toml b/Packaging/SaraSuite/Manifest.toml index a481581..3fb0bef 100644 --- a/Packaging/SaraSuite/Manifest.toml +++ b/Packaging/SaraSuite/Manifest.toml @@ -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}" diff --git a/README.md b/README.md index 28bd7f1..3821884 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Service.py b/Service.py index 5beb188..3c33f0b 100644 --- a/Service.py +++ b/Service.py @@ -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 ` (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": diff --git a/ServicePage.py b/ServicePage.py index b45963b..d930289 100644 --- a/ServicePage.py +++ b/ServicePage.py @@ -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 diff --git a/Settings.py b/Settings.py index 356db32..aca7056 100644 --- a/Settings.py +++ b/Settings.py @@ -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() diff --git a/TemplatesPage.py b/TemplatesPage.py index 75a0533..2b379a5 100644 --- a/TemplatesPage.py +++ b/TemplatesPage.py @@ -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), diff --git a/Widgets.py b/Widgets.py index ececbcf..333e91e 100644 --- a/Widgets.py +++ b/Widgets.py @@ -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")