mirror of
https://git.srhtnin.garden/sarah/SaraSort.git
synced 2026-10-15 02:53:17 +02:00
Compare commits
6
Commits
6cb2bd74cc
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bd37db1ba | ||
|
|
5613f4f8fe | ||
|
|
9b4e0f5b1f | ||
|
|
b5d2fe0f1c | ||
|
|
826216cd03 | ||
|
|
c80dc58b56 |
No files matched your search
@@ -6,6 +6,7 @@ one Sara program looks the same in all of them.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -20,17 +21,29 @@ 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),
|
||||||
|
# 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"
|
SharedConfigDir = ConfigHome / "SaraSuite"
|
||||||
ThemesDir = SharedConfigDir / "Themes"
|
ThemesDir = SharedConfigDir / "Themes"
|
||||||
SharedConfigPath = SharedConfigDir / "Config.toml"
|
SharedConfigPath = SharedConfigDir / "Config.toml"
|
||||||
@@ -43,8 +56,8 @@ UserTemplatesDir = ConfigDir / "Templates"
|
|||||||
BundleDir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
|
BundleDir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
|
||||||
AssetsDir = BundleDir / "assets"
|
AssetsDir = BundleDir / "assets"
|
||||||
|
|
||||||
# Themes SaraFem seeds into ThemesDir. Only other themes travel with an
|
# Only themes other than these travel with an export as full palettes (same
|
||||||
# export as full palettes (same rule as SaraFem's ExportSettings).
|
# rule as SaraFem's ExportSettings).
|
||||||
BuiltinThemeNames = {"CatppuccinMocha", "Forest", "Gruvbox"}
|
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:
|
def ApplyTheme(ThemeName: str) -> None:
|
||||||
"""Mutate Palette in place from ~/.config/SaraSuite/Themes/<ThemeName>.toml.
|
"""Mutate Palette in place from ~/.config/SaraSuite/Themes/<ThemeName>.toml.
|
||||||
Keeps the built-in Catppuccin Mocha values if the file is missing. Must
|
Keeps the built-in Catppuccin Mocha values if the file is missing. Must
|
||||||
run before any control reads Palette.X."""
|
run before any control reads Palette.X."""
|
||||||
|
EnsureSharedThemes()
|
||||||
ThemeFile = ThemesDir / f"{ThemeName}.toml"
|
ThemeFile = ThemesDir / f"{ThemeName}.toml"
|
||||||
if not ThemeFile.exists():
|
if not ThemeFile.exists():
|
||||||
return
|
return
|
||||||
@@ -93,6 +145,7 @@ def ApplyTheme(ThemeName: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def AvailableThemes() -> list[str]:
|
def AvailableThemes() -> list[str]:
|
||||||
|
EnsureSharedThemes()
|
||||||
Names = {ThemePath.stem for ThemePath in ThemesDir.glob("*.toml")} if ThemesDir.is_dir() else set()
|
Names = {ThemePath.stem for ThemePath in ThemesDir.glob("*.toml")} if ThemesDir.is_dir() else set()
|
||||||
Names.add("CatppuccinMocha")
|
Names.add("CatppuccinMocha")
|
||||||
return sorted(Names)
|
return sorted(Names)
|
||||||
@@ -109,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
|
||||||
@@ -332,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
|
||||||
@@ -1,19 +1,36 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Builds dist/SaraSort-x86_64.AppImage: a PyInstaller one-file build (with
|
# Builds dist/SaraSort-x86_64.AppImage: a PyInstaller one-file build (with
|
||||||
# the font and icon bundled) wrapped by appimagetool, the same way SaraSuite's
|
# the font and icon bundled) wrapped by appimagetool, the same way SaraSuite's
|
||||||
# and SaraFem's AppImages are made. Needs appimagetool on PATH.
|
# and SaraFem's AppImages are made. Needs podman and
|
||||||
|
# appimagetool on PATH.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$(readlink -f "$0")")/.."
|
cd "$(dirname "$(readlink -f "$0")")/.."
|
||||||
|
|
||||||
[ -d .venv ] || python3 -m venv .venv
|
# PyInstaller runs in a manylinux_2_28 container (glibc 2.28) with uv's
|
||||||
.venv/bin/pip install --quiet --disable-pip-version-check -r requirements.txt pyinstaller
|
# portable Python, not with Fedora's: PyInstaller bundles libpython, and
|
||||||
|
# Fedora's needs a newer glibc than many distros have ("GLIBC_ABI_GNU2_TLS
|
||||||
|
# not found"). The Python, venv and downloads are cached in build/Container.
|
||||||
# --collect-data flet: without it ft.Icons crashes when frozen.
|
# --collect-data flet: without it ft.Icons crashes when frozen.
|
||||||
.venv/bin/pyinstaller --noconfirm --clean --log-level WARN \
|
mkdir -p build/Container
|
||||||
|
podman run --rm --security-opt label=disable -v "$PWD:/src" -w /src \
|
||||||
|
-e UV_CACHE_DIR=/src/build/Container/cache -e UV_PYTHON_INSTALL_DIR=/src/build/Container/python \
|
||||||
|
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 \
|
--onefile --windowed --name SaraSort --collect-data flet \
|
||||||
|
--runtime-hook "$PWD/Packaging/RestoreLibraryPath.py" \
|
||||||
|
--add-data "$PWD/build/FallbackLibs:FallbackLibs" \
|
||||||
--add-data "$PWD/assets:assets" \
|
--add-data "$PWD/assets:assets" \
|
||||||
--distpath build/PyInstaller/dist --workpath build/PyInstaller/work --specpath build/PyInstaller \
|
--distpath build/PyInstaller/dist --workpath build/PyInstaller/work --specpath build/PyInstaller \
|
||||||
"$PWD/Main.py"
|
"$PWD/Main.py"'
|
||||||
|
|
||||||
AppDir=build/AppDir
|
AppDir=build/AppDir
|
||||||
rm -rf "$AppDir"
|
rm -rf "$AppDir"
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# PyInstaller runtime hook. The frozen app runs with LD_LIBRARY_PATH pointing
|
||||||
|
# at its bundled libraries, and programs it starts inherit that: Flet's
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -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}"
|
||||||
|
|||||||
@@ -4,5 +4,9 @@
|
|||||||
# matches the window to {Name}.desktop (StartupWMClass) instead of showing it
|
# 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.
|
# as "com.appveyor.flet". Empty if that fails: Flet then uses its own client.
|
||||||
export FLET_VIEW_PATH="$("{Python}" "{ProgramDir}/FletIdentity.py" "srhtnin.{Name}")"
|
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
|
cd "{AppDir}" || exit 1
|
||||||
exec "{Python}" Main.py "$@"
|
exec "{Python}" Main.py "$@"
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
A GUI for configuring [SaraSortd](https://git.srhtnin.garden/sarah/SaraSortd), built with Flet.
|
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
|
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`).
|
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.
|
Patterns, Edit Rules, Add Patterns, Copy Patterns, Save as Template, Edit Output Config, Remove Arrow.
|
||||||
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.
|
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
|
- **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.
|
||||||
|
|||||||
+135
-50
@@ -1,22 +1,31 @@
|
|||||||
"""SaraSortd as a background service: status, start/stop/restart, autostart,
|
"""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).
|
Linux with systemd: its systemd user unit (systemctl / journalctl).
|
||||||
Windows: SaraSuite's stand-in for a systemd service, used the same way here:
|
Windows, macOS, and Linux without systemd (runit, OpenRC, s6, ...): SaraSuite runs
|
||||||
- it runs windowless as `pythonw.exe ServiceRun.py SaraSortd SaraSortd.py`
|
it itself (its Services.py), and this does the same:
|
||||||
from SaraSortd's config folder, and ServiceRun.py records its PID in
|
- it runs as `python ServiceRun.py SaraSortd SaraSortd.py <ConfigDir>`
|
||||||
%LOCALAPPDATA%\\SaraSuite\\Services\\SaraSortd.pid (that's how "running" is told);
|
(pythonw.exe on Windows); ServiceRun.py restarts it if it fails, keeps its
|
||||||
- autostart is a shortcut to that command in the Startup folder.
|
output in SaraSuite's Services folder as SaraSortd.log (the "Service log"
|
||||||
Unit is the systemd unit on Linux and the service name ("SaraSortd") on Windows.
|
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 re
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
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
|
@dataclass
|
||||||
@@ -41,90 +50,163 @@ def _Run(Arguments: list[str], Timeout: int = 15) -> subprocess.CompletedProcess
|
|||||||
creationflags=_NoWindow)
|
creationflags=_NoWindow)
|
||||||
|
|
||||||
|
|
||||||
# --- Windows ------------------------------------------------------------------------------
|
# --- Run by SaraSuite (Windows; macOS; Linux without systemd) ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _PidFile(Name: str) -> Path:
|
def _Name(Unit: str) -> str:
|
||||||
return DataHome / "SaraSuite" / "Services" / f"{Name}.pid"
|
return Unit.removesuffix(".service")
|
||||||
|
|
||||||
|
|
||||||
def _StartupLink(Name: str) -> Path:
|
def _ServicesFile(Name: str, Suffix: str) -> Path:
|
||||||
|
return DataHome / "SaraSuite" / "Services" / f"{Name}{Suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _AutostartFile(Name: str) -> Path:
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
def _WindowsCommand() -> tuple[Path, list[str], Path]:
|
def _Command(Name: str) -> tuple[Path, list[str], Path]:
|
||||||
"""(pythonw.exe, its arguments, working folder), from SaraSuite's install layout."""
|
"""(python, its arguments, working folder), from SaraSuite's install layout."""
|
||||||
Config = LoadConfig()
|
Config = LoadConfig()
|
||||||
AppDir = Path(Config.SortdAppDir).expanduser()
|
ProgramDir = Path(Config.SortdAppDir).expanduser().parent
|
||||||
ProgramDir = AppDir.parent
|
Folder = Path(Config.SortdConfigDir).expanduser()
|
||||||
PythonW = ProgramDir / ".venv" / "Scripts" / "pythonw.exe"
|
if IsWindows:
|
||||||
Arguments = ["-X", "utf8", str(ProgramDir / "ServiceRun.py"), Config.ServiceName, str(Config.SortdScriptPath)]
|
Python = ProgramDir / ".venv" / "Scripts" / "pythonw.exe"
|
||||||
return PythonW, Arguments, Path(Config.SortdConfigDir).expanduser()
|
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:
|
try:
|
||||||
Pid = int(_PidFile(Name).read_text().strip())
|
|
||||||
Output = _Run(["tasklist", "/FI", f"PID eq {Pid}", "/NH", "/FO", "CSV"]).stdout
|
Output = _Run(["tasklist", "/FI", f"PID eq {Pid}", "/NH", "/FO", "CSV"]).stdout
|
||||||
except (OSError, ValueError, subprocess.SubprocessError):
|
except (OSError, subprocess.SubprocessError):
|
||||||
return ""
|
return ""
|
||||||
return str(Pid) if re.search(rf'"{Pid}"', Output) else ""
|
return str(Pid) if re.search(rf'"{Pid}"', Output) else ""
|
||||||
|
|
||||||
|
|
||||||
def _WindowsStatus(Name: str) -> ServiceStatus:
|
def _OwnStatus(Name: str) -> ServiceStatus:
|
||||||
PythonW, Arguments, _Folder = _WindowsCommand()
|
Python, Arguments, _Folder = _Command(Name)
|
||||||
Pid = _WindowsPid(Name)
|
Pid = _Pid(Name)
|
||||||
Since = ""
|
Since = ""
|
||||||
if Pid:
|
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(
|
return ServiceStatus(
|
||||||
Active="active" if Pid else "inactive",
|
Active="active" if Pid else "inactive",
|
||||||
Sub="running" if Pid else "dead",
|
Sub="running" if Pid else "dead",
|
||||||
Enabled="enabled" if _StartupLink(Name).exists() else "disabled",
|
Enabled="enabled" if _AutostartFile(Name).exists() else "disabled",
|
||||||
Pid=Pid,
|
Pid=Pid,
|
||||||
Since=Since,
|
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:
|
def _AutostartEntry(Python: Path, Arguments: list[str], Name: str) -> str:
|
||||||
PythonW, Arguments, Folder = _WindowsCommand()
|
"""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:
|
try:
|
||||||
if Action in ("stop", "restart"):
|
if Action in ("stop", "restart"):
|
||||||
Pid = _WindowsPid(Name)
|
Pid = _Pid(Name)
|
||||||
if Pid:
|
if Pid and IsWindows:
|
||||||
_Run(["taskkill", "/PID", Pid, "/T", "/F"])
|
_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 Action in ("start", "restart"):
|
||||||
if _WindowsPid(Name):
|
if _Pid(Name):
|
||||||
return ""
|
return ""
|
||||||
Folder.mkdir(parents=True, exist_ok=True)
|
Folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
Extra = ({"creationflags": getattr(subprocess, "DETACHED_PROCESS", 0) | _NoWindow} if IsWindows
|
||||||
|
else {"start_new_session": True})
|
||||||
subprocess.Popen(
|
subprocess.Popen(
|
||||||
[str(PythonW), *Arguments], cwd=str(Folder), env=ChildEnvironment(), close_fds=True,
|
[str(Python), *Arguments], cwd=str(Folder), env=ChildEnvironment(), close_fds=True,
|
||||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **Extra,
|
||||||
creationflags=getattr(subprocess, "DETACHED_PROCESS", 0) | _NoWindow,
|
|
||||||
)
|
)
|
||||||
elif Action == "enable":
|
elif Action == "enable":
|
||||||
|
Entry = _AutostartFile(Name)
|
||||||
|
Entry.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if IsWindows:
|
||||||
import pylnk3
|
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)
|
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),
|
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:
|
||||||
|
Entry.write_text(_AutostartEntry(Python, Arguments, Name))
|
||||||
elif Action == "disable":
|
elif Action == "disable":
|
||||||
_StartupLink(Name).unlink(missing_ok=True)
|
_AutostartFile(Name).unlink(missing_ok=True)
|
||||||
except (OSError, subprocess.SubprocessError) as Error:
|
except (OSError, subprocess.SubprocessError) as Error:
|
||||||
return str(Error)
|
return str(Error)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
# --- Both -----------------------------------------------------------------------------------
|
# --- Both ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def Status(Unit: str) -> ServiceStatus:
|
def Status(Unit: str) -> ServiceStatus:
|
||||||
if IsWindows:
|
if RunBySaraSuite:
|
||||||
return _WindowsStatus(Unit)
|
return _OwnStatus(_Name(Unit))
|
||||||
try:
|
try:
|
||||||
Result = _Run(["systemctl", "--user", "show", Unit,
|
Result = _Run(["systemctl", "--user", "show", Unit,
|
||||||
"-p", "LoadState,ActiveState,SubState,UnitFileState,MainPID,ActiveEnterTimestamp"])
|
"-p", "LoadState,ActiveState,SubState,UnitFileState,MainPID,ActiveEnterTimestamp"])
|
||||||
@@ -143,8 +225,8 @@ def Status(Unit: str) -> ServiceStatus:
|
|||||||
|
|
||||||
def Control(Unit: str, Action: str) -> str:
|
def Control(Unit: str, Action: str) -> str:
|
||||||
"""Action: start, stop, restart, enable, disable. Returns "" or an error."""
|
"""Action: start, stop, restart, enable, disable. Returns "" or an error."""
|
||||||
if IsWindows:
|
if RunBySaraSuite:
|
||||||
return _WindowsControl(Unit, Action)
|
return _OwnControl(_Name(Unit), Action)
|
||||||
try:
|
try:
|
||||||
Result = _Run(["systemctl", "--user", Action, Unit])
|
Result = _Run(["systemctl", "--user", Action, Unit])
|
||||||
except (OSError, subprocess.SubprocessError) as Error:
|
except (OSError, subprocess.SubprocessError) as Error:
|
||||||
@@ -160,9 +242,12 @@ def RestartIfRunning(Unit: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def Journal(Unit: str, Lines: int) -> list[str]:
|
def Journal(Unit: str, Lines: int) -> list[str]:
|
||||||
if IsWindows:
|
"""Its systemd journal, or (run by SaraSuite) ServiceRun.py's log of its output."""
|
||||||
return ["Windows has no systemd journal. SaraSortd's own log files (the other log source) "
|
if RunBySaraSuite:
|
||||||
"have everything it does."]
|
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:
|
try:
|
||||||
Result = _Run(["journalctl", "--user", "-u", Unit, "-n", str(Lines), "--no-pager", "-o", "short-iso"])
|
Result = _Run(["journalctl", "--user", "-u", Unit, "-n", str(Lines), "--no-pager", "-o", "short-iso"])
|
||||||
except (OSError, subprocess.SubprocessError) as Error:
|
except (OSError, subprocess.SubprocessError) as Error:
|
||||||
|
|||||||
+6
-5
@@ -1,5 +1,6 @@
|
|||||||
"""SaraSortd's service: status, start/stop/restart, start on login, its
|
"""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
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -7,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
|
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
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ class ServicePage(ft.Column):
|
|||||||
self.RestartButton = ChipButton("Restart", lambda e: self._Do("restart"), ft.Icons.RESTART_ALT)
|
self.RestartButton = ChipButton("Restart", lambda e: self._Do("restart"), ft.Icons.RESTART_ALT)
|
||||||
|
|
||||||
self.FilesButton = ChipButton("Log files", lambda e: self._SetSource("Files"))
|
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.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.HideChecking = ft.Checkbox(label="Hide \"Checking\" lines", value=True, on_change=lambda e: self._LoadLog())
|
||||||
self.Follow = ft.Checkbox(label="Follow", value=True)
|
self.Follow = ft.Checkbox(label="Follow", value=True)
|
||||||
@@ -105,10 +106,10 @@ class ServicePage(ft.Column):
|
|||||||
self.StateText.value = Text
|
self.StateText.value = Text
|
||||||
self.StateText.color = Color
|
self.StateText.color = Color
|
||||||
self.Details.controls = [
|
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"Since: {Current.Since or '-'}"),
|
||||||
Note(f"PID: {Current.Pid 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.StartButton.disabled = Current.Running or not Current.Found
|
||||||
self.StopButton.disabled = not Current.Running
|
self.StopButton.disabled = not Current.Running
|
||||||
|
|||||||
+57
-9
@@ -11,8 +11,9 @@ import flet as ft
|
|||||||
import tomlkit
|
import tomlkit
|
||||||
|
|
||||||
from Core import (
|
from Core import (
|
||||||
AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, ExportSettings, Home,
|
AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, ConfigDir, DisplayPath,
|
||||||
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:
|
||||||
@@ -137,7 +185,7 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[], None]) -> None:
|
|||||||
|
|
||||||
ConfigDirField = ft.TextField(label="SaraSortd config folder", value=Config.SortdConfigDir)
|
ConfigDirField = ft.TextField(label="SaraSortd config folder", value=Config.SortdConfigDir)
|
||||||
AppDirField = ft.TextField(label="SaraSortd program folder", value=Config.SortdAppDir)
|
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)
|
AutoRestartSwitch = ft.Switch(label="Restart SaraSortd after changes to GlobalConf.toml", value=Config.AutoRestart)
|
||||||
RefreshField = _NumberField("Service page refresh (seconds)", Config.RefreshSeconds)
|
RefreshField = _NumberField("Service page refresh (seconds)", Config.RefreshSeconds)
|
||||||
LogLinesField = _NumberField("Log lines to show", Config.LogLines)
|
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] = {
|
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
@@ -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
@@ -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")
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user