mirror of
https://git.srhtnin.garden/sarah/SaraSort.git
synced 2026-10-14 18:48:11 +02:00
macOS support
This commit is contained in:
1 parent
5613f4f8fe
commit
1bd37db1ba
8 files changed
+113
-29
No files matched your search
@@ -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
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
|
||||
Reference in new issue
Block a user