Compare commits

..
4 Commits
Author SHA1 Message Date
sarah 1bd37db1ba macOS support 2026-10-14 13:18:18 +02:00
sarahandClaude Opus 5.5 5613f4f8fe Create the shared themes folder with the built-in themes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:24:36 +02:00
sarahandClaude Opus 5.5 9b4e0f5b1f Bundle libsecret for systems without it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 18:47:24 +02:00
sarahandClaude Opus 5.5 b5d2fe0f1c SaraSortd's service without systemd (runit, OpenRC, ...)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 17:51:30 +02:00
11 changed files with 330 additions and 85 deletions

No files matched your search

+62 -8
View File
@@ -6,6 +6,7 @@ one Sara program looks the same in all of them.
"""
import os
import shutil
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
@@ -20,17 +21,29 @@ 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 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"
SharedConfigDir = ConfigHome / "SaraSuite"
ThemesDir = SharedConfigDir / "Themes"
SharedConfigPath = SharedConfigDir / "Config.toml"
@@ -43,8 +56,8 @@ UserTemplatesDir = ConfigDir / "Templates"
BundleDir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
AssetsDir = BundleDir / "assets"
# Themes SaraFem seeds into ThemesDir. Only other themes travel with an
# export as full palettes (same rule as SaraFem's ExportSettings).
# Only themes other than these travel with an export as full palettes (same
# rule as SaraFem's ExportSettings).
BuiltinThemeNames = {"CatppuccinMocha", "Forest", "Gruvbox"}
@@ -76,10 +89,49 @@ _ThemeRoleMap = {
}
# The themes every SaraSuite program seeds into ThemesDir if they're missing
# (same values as SaraFem's and SaraView's), so whichever program is installed
# first, the folder and these three are there to choose from.
BuiltinThemes: dict[str, dict[str, str]] = {
"CatppuccinMocha": {
"Background": "#1e1e2e", "Surface": "#181825", "SurfaceAlt": "#313244", "Border": "#45475a",
"Text": "#cdd6f4", "TextMuted": "#6c7086", "Black": "#11111b", "White": "#cdd6f4",
"Red": "#f38ba8", "Orange": "#fab387", "Yellow": "#f9e2af", "Green": "#a6e3a1",
"Blue": "#89b4fa", "Purple": "#cba6f7",
},
"Forest": {
"Background": "#1b2420", "Surface": "#141b17", "SurfaceAlt": "#243029", "Border": "#334539",
"Text": "#d6e5d0", "TextMuted": "#7c9184", "Black": "#0d120f", "White": "#eef5ea",
"Red": "#e0685a", "Orange": "#e0a458", "Yellow": "#e0c458", "Green": "#7fb069",
"Blue": "#5c9e8f", "Purple": "#9b7fb0",
},
"Gruvbox": {
"Background": "#282828", "Surface": "#1d2021", "SurfaceAlt": "#3c3836", "Border": "#504945",
"Text": "#ebdbb2", "TextMuted": "#a89984", "Black": "#1d2021", "White": "#fbf1c7",
"Red": "#fb4934", "Orange": "#fe8019", "Yellow": "#fabd2f", "Green": "#b8bb26",
"Blue": "#83a598", "Purple": "#d3869b",
},
}
def EnsureSharedThemes() -> None:
"""Create ThemesDir with the built-in themes that aren't there yet
(never overwriting one: it may have been edited)."""
try:
ThemesDir.mkdir(parents=True, exist_ok=True)
for Name, RoleValues in BuiltinThemes.items():
ThemeFile = ThemesDir / f"{Name}.toml"
if not ThemeFile.exists():
ThemeFile.write_text(tomlkit.dumps({"Palette": RoleValues}))
except OSError:
pass # read-only or full: the built-in Catppuccin Mocha values still apply
def ApplyTheme(ThemeName: str) -> None:
"""Mutate Palette in place from ~/.config/SaraSuite/Themes/<ThemeName>.toml.
Keeps the built-in Catppuccin Mocha values if the file is missing. Must
run before any control reads Palette.X."""
EnsureSharedThemes()
ThemeFile = ThemesDir / f"{ThemeName}.toml"
if not ThemeFile.exists():
return
@@ -93,6 +145,7 @@ def ApplyTheme(ThemeName: str) -> None:
def AvailableThemes() -> list[str]:
EnsureSharedThemes()
Names = {ThemePath.stem for ThemePath in ThemesDir.glob("*.toml")} if ThemesDir.is_dir() else set()
Names.add("CatppuccinMocha")
return sorted(Names)
@@ -109,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
@@ -332,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
View File
@@ -17,9 +17,17 @@ podman run --rm --security-opt label=disable -v "$PWD:/src" -w /src \
quay.io/pypa/manylinux_2_28_x86_64 bash -euo pipefail -c '
uv venv --quiet --allow-existing --managed-python --python 3.14 build/Container/venv
uv pip install --quiet --python build/Container/venv -r requirements.txt pyinstaller
# The Flet window needs libsecret, which some systems lack: bundled as a
# fallback (RestoreLibraryPath.py uses only what the system lacks).
dnf -y -q install libsecret >/dev/null
mkdir -p build/FallbackLibs
for Library in libsecret-1.so.0 libgcrypt.so.20 libgpg-error.so.0; do cp -L /usr/lib64/$Library build/FallbackLibs/; done
# Each finds the others beside it, wherever they are put (a client lib folder).
patchelf --set-rpath "\$ORIGIN" build/FallbackLibs/libsecret-1.so.0 build/FallbackLibs/libgcrypt.so.20
build/Container/venv/bin/pyinstaller --noconfirm --clean --log-level WARN \
--onefile --windowed --name SaraSort --collect-data flet \
--runtime-hook "$PWD/Packaging/RestoreLibraryPath.py" \
--add-data "$PWD/build/FallbackLibs:FallbackLibs" \
--add-data "$PWD/assets:assets" \
--distpath build/PyInstaller/dist --workpath build/PyInstaller/work --specpath build/PyInstaller \
"$PWD/Main.py"'
+38 -2
View File
@@ -3,9 +3,45 @@
# desktop client then loads the bundled (older) libstdc++ instead of the
# system's and won't start ("GLIBCXX_3.4.29 not found"). Give them the
# original value back; the app itself has already loaded what it needs.
#
# Flet's window also needs libsecret, which isn't part of GTK and some
# systems don't install ("libsecret-1.so.0: cannot open shared object file").
# The build bundles it in FallbackLibs, with libgcrypt and libgpg-error, which
# it needs; the ones this system lacks (only those) are offered to what the
# app starts, after the original path.
import os
import subprocess
import sys
if "LD_LIBRARY_PATH_ORIG" in os.environ:
os.environ["LD_LIBRARY_PATH"] = os.environ["LD_LIBRARY_PATH_ORIG"]
def SystemHas(Name):
"""Whether the system itself has a library: asked of its linker cache
and standard folders, not dlopen, which would also find this bundle's
own copies (PyInstaller adds what bundled libraries need)."""
for Folder in ("/usr/lib64", "/usr/lib", "/lib64", "/lib", "/usr/lib/x86_64-linux-gnu", "/lib/x86_64-linux-gnu"):
if os.path.exists(os.path.join(Folder, Name)):
return True
try:
Cache = subprocess.run(["ldconfig", "-p"], capture_output=True, text=True, timeout=10,
env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin"}).stdout
except (OSError, subprocess.SubprocessError):
return False
return any(Line.strip().startswith(Name + " ") for Line in Cache.splitlines())
Paths = [os.environ["LD_LIBRARY_PATH_ORIG"]] if os.environ.get("LD_LIBRARY_PATH_ORIG") else []
Bundle = getattr(sys, "_MEIPASS", "")
Fallback = os.path.join(Bundle, "FallbackLibs")
if sys.platform.startswith("linux") and os.path.isdir(Fallback):
InUse = os.path.join(Bundle, "FallbackLibsInUse")
os.makedirs(InUse, exist_ok=True)
for Name in ("libgpg-error.so.0", "libgcrypt.so.20", "libsecret-1.so.0"):
if not SystemHas(Name) and not os.path.lexists(os.path.join(InUse, Name)):
os.symlink(os.path.join(Fallback, Name), os.path.join(InUse, Name))
if os.listdir(InUse):
Paths.append(InUse)
if Paths:
os.environ["LD_LIBRARY_PATH"] = os.pathsep.join(Paths)
else:
os.environ.pop("LD_LIBRARY_PATH", None)
+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}"
+4
View File
@@ -4,5 +4,9 @@
# matches the window to {Name}.desktop (StartupWMClass) instead of showing it
# as "com.appveyor.flet". Empty if that fails: Flet then uses its own client.
export FLET_VIEW_PATH="$("{Python}" "{ProgramDir}/FletIdentity.py" "srhtnin.{Name}")"
# Libraries Flet's window needs that this system lacks (libsecret), from
# SaraSuite: FletIdentity.py puts them beside the client copy.
Fallback="$FLET_VIEW_PATH/../fallback-libs"
[ -n "$FLET_VIEW_PATH" ] && [ -d "$Fallback" ] && export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}$Fallback"
cd "{AppDir}" || exit 1
exec "{Python}" Main.py "$@"
+4 -2
View File
@@ -2,7 +2,8 @@
A GUI for configuring [SaraSortd](https://git.srhtnin.garden/sarah/SaraSortd), built with Flet.
SaraSort doesn't sort anything itself: SaraSortd stays the backend, and SaraSort reads and edits its
configs and controls its systemd user service.
configs and controls its systemd user service (or, without systemd, the service SaraSuite runs
for it: see SaraSuite's README).
Needs SaraSortd **7.1** or newer for arrows with their own patterns (per-entry `ValidInputDirs`).
@@ -23,7 +24,8 @@ Needs SaraSortd **7.1** or newer for arrows with their own patterns (per-entry `
Patterns, Edit Rules, Add Patterns, Copy Patterns, Save as Template, Edit Output Config, Remove Arrow.
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.
SaraSuite install it came from), and its logs: its own log files, or the systemd journal (the
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.
+139 -54
View File
@@ -1,22 +1,31 @@
"""SaraSortd as a background service: status, start/stop/restart, autostart,
and its journal. SaraSortd's own log files are read from its LogDir.
and its logs. SaraSortd's own log files are read from its LogDir.
Linux: its systemd user unit (systemctl / journalctl).
Windows: SaraSuite's stand-in for a systemd service, used the same way here:
- it runs windowless as `pythonw.exe ServiceRun.py SaraSortd SaraSortd.py`
from SaraSortd's config folder, and ServiceRun.py records its PID in
%LOCALAPPDATA%\\SaraSuite\\Services\\SaraSortd.pid (that's how "running" is told);
- autostart is a shortcut to that command in the Startup folder.
Unit is the systemd unit on Linux and the service name ("SaraSortd") on Windows.
Linux with systemd: its systemd user unit (systemctl / journalctl).
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),
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
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from Core import ChildEnvironment, ConfigHome, DataHome, IsWindows, LoadConfig
from Core import AutostartDir, ChildEnvironment, ConfigHome, DataHome, Home, IsMac, IsWindows, LoadConfig, RunBySaraSuite
@dataclass
@@ -41,90 +50,163 @@ def _Run(Arguments: list[str], Timeout: int = 15) -> subprocess.CompletedProcess
creationflags=_NoWindow)
# --- Windows ------------------------------------------------------------------------------
# --- Run by SaraSuite (Windows; macOS; Linux without systemd) ------------------------------------------
def _PidFile(Name: str) -> Path:
return DataHome / "SaraSuite" / "Services" / f"{Name}.pid"
def _Name(Unit: str) -> str:
return Unit.removesuffix(".service")
def _StartupLink(Name: str) -> Path:
return ConfigHome / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" / f"{Name}.lnk"
def _ServicesFile(Name: str, Suffix: str) -> Path:
return DataHome / "SaraSuite" / "Services" / f"{Name}{Suffix}"
def _WindowsCommand() -> tuple[Path, list[str], Path]:
"""(pythonw.exe, its arguments, working folder), from SaraSuite's install layout."""
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"
def _Command(Name: str) -> tuple[Path, list[str], Path]:
"""(python, its arguments, working folder), from SaraSuite's install layout."""
Config = LoadConfig()
AppDir = Path(Config.SortdAppDir).expanduser()
ProgramDir = AppDir.parent
PythonW = ProgramDir / ".venv" / "Scripts" / "pythonw.exe"
Arguments = ["-X", "utf8", str(ProgramDir / "ServiceRun.py"), Config.ServiceName, str(Config.SortdScriptPath)]
return PythonW, Arguments, Path(Config.SortdConfigDir).expanduser()
ProgramDir = Path(Config.SortdAppDir).expanduser().parent
Folder = Path(Config.SortdConfigDir).expanduser()
if IsWindows:
Python = ProgramDir / ".venv" / "Scripts" / "pythonw.exe"
Arguments = ["-X", "utf8"]
else:
Python = ProgramDir / ".venv" / "bin" / "python"
Arguments = []
Arguments += [str(ProgramDir / "ServiceRun.py"), Name, str(Config.SortdScriptPath), str(Folder)]
return Python, Arguments, Folder
def _WindowsPid(Name: str) -> str:
def _Pid(Name: str) -> str:
try:
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 ""
except OSError:
return ""
try:
Pid = int(_PidFile(Name).read_text().strip())
Output = _Run(["tasklist", "/FI", f"PID eq {Pid}", "/NH", "/FO", "CSV"]).stdout
except (OSError, ValueError, subprocess.SubprocessError):
except (OSError, subprocess.SubprocessError):
return ""
return str(Pid) if re.search(rf'"{Pid}"', Output) else ""
def _WindowsStatus(Name: str) -> ServiceStatus:
PythonW, Arguments, _Folder = _WindowsCommand()
Pid = _WindowsPid(Name)
def _OwnStatus(Name: str) -> ServiceStatus:
Python, Arguments, _Folder = _Command(Name)
Pid = _Pid(Name)
Since = ""
if Pid:
Since = datetime.fromtimestamp(_PidFile(Name).stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
Since = datetime.fromtimestamp(_ServicesFile(Name, ".pid").stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
Runner = Path(Arguments[-4])
return ServiceStatus(
Active="active" if Pid else "inactive",
Sub="running" if Pid else "dead",
Enabled="enabled" if _StartupLink(Name).exists() else "disabled",
Enabled="enabled" if _AutostartFile(Name).exists() else "disabled",
Pid=Pid,
Since=Since,
Found=PythonW.is_file() and Path(Arguments[2]).is_file(),
Found=Python.is_file() and Runner.is_file(),
)
def _WindowsControl(Name: str, Action: str) -> str:
PythonW, Arguments, Folder = _WindowsCommand()
def _AutostartEntry(Python: Path, Arguments: list[str], Name: str) -> str:
"""The same entry SaraSuite writes (its Services.AutostartEntry)."""
def Quote(Text: str) -> str:
Text = Text.replace("%", "%%")
return Text if re.fullmatch(r"[\w@%+=:,./-]+", Text) else '"' + re.sub(r'(["`$\\])', r"\\\1", Text) + '"'
return (
"# Written by SaraSuite: starts this background service at login on systems\n"
"# without systemd (runit, OpenRC, s6, ...). Removed with the program.\n"
"[Desktop Entry]\n"
"Type=Application\n"
f"Name={Name}\n"
f"Comment={Name} (background service, run by SaraSuite)\n"
f"Exec={' '.join(Quote(Part) for Part in [str(Python), *Arguments])}\n"
"Terminal=false\n"
"NoDisplay=true\n"
"X-GNOME-Autostart-enabled=true\n"
)
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:
if Action in ("stop", "restart"):
Pid = _WindowsPid(Name)
if Pid:
Pid = _Pid(Name)
if Pid and IsWindows:
_Run(["taskkill", "/PID", Pid, "/T", "/F"])
_PidFile(Name).unlink(missing_ok=True)
elif Pid:
os.kill(int(Pid), signal.SIGTERM) # ServiceRun.py finishes cleanly
for _ in range(50):
if not _Pid(Name):
break
time.sleep(0.2)
else:
os.kill(int(Pid), signal.SIGKILL)
_ServicesFile(Name, ".pid").unlink(missing_ok=True)
if Action in ("start", "restart"):
if _WindowsPid(Name):
if _Pid(Name):
return ""
Folder.mkdir(parents=True, exist_ok=True)
Extra = ({"creationflags": getattr(subprocess, "DETACHED_PROCESS", 0) | _NoWindow} if IsWindows
else {"start_new_session": True})
subprocess.Popen(
[str(PythonW), *Arguments], cwd=str(Folder), env=ChildEnvironment(), close_fds=True,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=getattr(subprocess, "DETACHED_PROCESS", 0) | _NoWindow,
[str(Python), *Arguments], cwd=str(Folder), env=ChildEnvironment(), close_fds=True,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **Extra,
)
elif Action == "enable":
import pylnk3
Entry = _AutostartFile(Name)
Entry.parent.mkdir(parents=True, exist_ok=True)
if IsWindows:
import pylnk3
Link = _StartupLink(Name)
Link.parent.mkdir(parents=True, exist_ok=True)
Quoted = " ".join(f'"{Item}"' if " " in Item or "\\" in Item else Item for Item in Arguments)
pylnk3.for_file(str(PythonW), str(Link), arguments=Quoted, work_dir=str(Folder),
description=f"{Name} (background service)")
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":
_StartupLink(Name).unlink(missing_ok=True)
_AutostartFile(Name).unlink(missing_ok=True)
except (OSError, subprocess.SubprocessError) as Error:
return str(Error)
return ""
# --- Both -----------------------------------------------------------------------------------
# --- Both ------------------------------------------------------------------------------------
def Status(Unit: str) -> ServiceStatus:
if IsWindows:
return _WindowsStatus(Unit)
if RunBySaraSuite:
return _OwnStatus(_Name(Unit))
try:
Result = _Run(["systemctl", "--user", "show", Unit,
"-p", "LoadState,ActiveState,SubState,UnitFileState,MainPID,ActiveEnterTimestamp"])
@@ -143,8 +225,8 @@ def Status(Unit: str) -> ServiceStatus:
def Control(Unit: str, Action: str) -> str:
"""Action: start, stop, restart, enable, disable. Returns "" or an error."""
if IsWindows:
return _WindowsControl(Unit, Action)
if RunBySaraSuite:
return _OwnControl(_Name(Unit), Action)
try:
Result = _Run(["systemctl", "--user", Action, Unit])
except (OSError, subprocess.SubprocessError) as Error:
@@ -160,9 +242,12 @@ def RestartIfRunning(Unit: str) -> str:
def Journal(Unit: str, Lines: int) -> list[str]:
if IsWindows:
return ["Windows has no systemd journal. SaraSortd's own log files (the other log source) "
"have everything it does."]
"""Its systemd journal, or (run by SaraSuite) ServiceRun.py's log of its output."""
if RunBySaraSuite:
LogPath = _ServicesFile(_Name(Unit), ".log")
if not LogPath.is_file():
return [f"No service log yet ({LogPath}): it's written once the service has run."]
return ReadTail(LogPath, Lines)
try:
Result = _Run(["journalctl", "--user", "-u", Unit, "-n", str(Lines), "--no-pager", "-o", "short-iso"])
except (OSError, subprocess.SubprocessError) as Error:
+6 -5
View File
@@ -1,5 +1,6 @@
"""SaraSortd's service: status, start/stop/restart, start on login, its
version, and its logs (its own log files and the systemd journal)."""
version, and its logs (its own log files, and the systemd journal or, where
SaraSuite runs it, its service log; see Service.py)."""
import asyncio
from pathlib import Path
@@ -7,7 +8,7 @@ from pathlib import Path
import flet as ft
import tomlkit
from Core import ChipButton, ChipButtonStyle, DataHome, Heading, IsWindows, Note, Palette
from Core import ChipButton, ChipButtonStyle, DataHome, Heading, IsMac, IsWindows, Note, Palette, RunBySaraSuite
from Service import Control, Journal, LogFiles, ReadTail, Status
from Widgets import App
@@ -44,7 +45,7 @@ class ServicePage(ft.Column):
self.RestartButton = ChipButton("Restart", lambda e: self._Do("restart"), ft.Icons.RESTART_ALT)
self.FilesButton = ChipButton("Log files", lambda e: self._SetSource("Files"))
self.JournalButton = ChipButton("Journal", lambda e: self._SetSource("Journal"))
self.JournalButton = ChipButton("Service log" if RunBySaraSuite else "Journal", lambda e: self._SetSource("Journal"))
self.FilePicker = ft.Dropdown(label="Log file", dense=True, width=260, on_select=lambda e: self._LoadLog())
self.HideChecking = ft.Checkbox(label="Hide \"Checking\" lines", value=True, on_change=lambda e: self._LoadLog())
self.Follow = ft.Checkbox(label="Follow", value=True)
@@ -105,10 +106,10 @@ class ServicePage(ft.Column):
self.StateText.value = Text
self.StateText.color = Color
self.Details.controls = [
Note(f"{'Service' if IsWindows else 'Unit'}: {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"PID: {Current.Pid or '-'}"),
Note(f"{'Autostart (Startup folder)' if IsWindows 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
+57 -9
View File
@@ -11,8 +11,9 @@ import flet as ft
import tomlkit
from Core import (
AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, ExportSettings, 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:
@@ -137,7 +185,7 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[], None]) -> None:
ConfigDirField = ft.TextField(label="SaraSortd config folder", value=Config.SortdConfigDir)
AppDirField = ft.TextField(label="SaraSortd program folder", value=Config.SortdAppDir)
UnitField = ft.TextField(label="systemd user unit", value=Config.ServiceName)
UnitField = ft.TextField(label="systemd user unit" if HasSystemd else "Service name", value=Config.ServiceName)
AutoRestartSwitch = ft.Switch(label="Restart SaraSortd after changes to GlobalConf.toml", value=Config.AutoRestart)
RefreshField = _NumberField("Service page refresh (seconds)", Config.RefreshSeconds)
LogLinesField = _NumberField("Log lines to show", Config.LogLines)
@@ -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")