11 Commits
Author SHA1 Message Date
sarahandClaude Opus 5.5 1feb6d4336 Version 1.2.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:38:41 +02:00
sarahandClaude Opus 5.5 768757a062 No pkill needed to restart the portal; missing optional tools are skipped
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:37:19 +02:00
sarahandClaude Opus 5.5 ce62249487 SaraFem's file picker without a password where possible; clearer pkexec errors; portals.conf without a desktop name
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:33:12 +02:00
sarahandClaude Opus 5.5 70c9730017 Version 1.2.4
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:28:46 +02:00
sarahandClaude Opus 5.5 2061babb43 Create the shared themes folder with the built-in themes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:24:35 +02:00
sarahandClaude Opus 5.5 21ae6379ac Version 1.2.3
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 18:51:18 +02:00
sarahandClaude Opus 5.5 63e06eea40 Programs find SaraSuite's libsecret where the system lacks it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 18:45:33 +02:00
sarahandClaude Opus 5.5 5890b52da6 Bundle libsecret for systems without it; first launch keeps Flet's message off the client path
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 18:43:15 +02:00
sarahandClaude Opus 5.5 61c992f959 Version 1.2.2
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 17:54:56 +02:00
sarahandClaude Opus 5.5 f63e708572 Background services without systemd (runit, OpenRC, ...)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 17:51:30 +02:00
sarahandClaude Opus 5.5 e7e233951f SaraView: install the variable engine with the client
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 17:36:20 +02:00
20 changed files with 625 additions and 136 deletions

No files matched your search

+73 -5
View File
@@ -13,6 +13,8 @@ picked for one Sara program looks the same in all of them.
"""
import os
import re
import shutil
import sys
from dataclasses import asdict, dataclass, field
from pathlib import Path
@@ -23,7 +25,7 @@ import tomlkit
AppName = "SaraSuite"
# Compared with the latest Gitea release to offer self-updates (SelfUpdate.py).
# Bump it, then run Packaging/Release.py, to publish a new SaraSuite.
AppVersion = "1.2.1"
AppVersion = "1.2.5"
IsFrozen = bool(getattr(sys, "frozen", False)) # running from the AppImage / .exe
IsWindows = sys.platform == "win32"
Platform = "Windows" if IsWindows else "Linux" # matches manifests' Platforms / Platform
@@ -64,7 +66,14 @@ ReceiptsDir = DataDir / "Receipts" # what each install placed, for exact remova
PristineDir = DataDir / "Pristine" # the default configs as last installed (merge base)
BackupsDir = DataDir / "Backups" # displaced files and pre-merge config copies
DownloadsDir = CacheHome / "SaraSuite" / "Downloads" # extracted source archives
ServicesDir = DataDir / "Services" # Windows: <Name>.pid of each running background service
# <Name>.pid and <Name>.log of each service SaraSuite runs itself (Services.py).
ServicesDir = DataDir / "Services"
# Linux: whether systemd runs the system, and so --user services. Without it
# (runit on Void, OpenRC, s6, dinit, ...) services run the way they do on
# Windows (Services.py), started at login from AutostartDir.
HasSystemd = not IsWindows and Path("/run/systemd/system").is_dir() and shutil.which("systemctl") is not None
AutostartDir = ConfigHome / "autostart"
# Bundled read-only files (program manifests, fonts). Inside a PyInstaller
# build they're unpacked to sys._MEIPASS rather than next to the code.
@@ -101,11 +110,50 @@ _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.
Falls back to the built-in Catppuccin Mocha values if the file is missing
(SaraFem seeds that directory; SaraSuite only reads it). Must run before
any control reads Palette.X."""
Seeds the built-in themes first (EnsureSharedThemes); a missing file keeps
the built-in Catppuccin Mocha values. Must run before any control reads
Palette.X."""
EnsureSharedThemes()
ThemeFile = ThemesDir / f"{ThemeName}.toml"
if not ThemeFile.exists():
return
@@ -221,6 +269,26 @@ def ChildEnvironment() -> dict[str, str]:
return Environment
def UserPortalsSupported() -> bool:
"""Whether xdg-desktop-portal reads portal files from
~/.local/share/xdg-desktop-portal/portals (1.17 and newer), so installing
one needs no password. True when it isn't installed either: then a
portal file can't be used anywhere, so it might as well not need root."""
import subprocess
for Candidate in ("/usr/libexec/xdg-desktop-portal", "/usr/lib/xdg-desktop-portal",
"/usr/lib/x86_64-linux-gnu/xdg-desktop-portal", "/usr/local/libexec/xdg-desktop-portal"):
if not Path(Candidate).is_file():
continue
try:
Output = subprocess.run([Candidate, "--version"], capture_output=True, text=True, timeout=10).stdout
except (OSError, subprocess.SubprocessError):
return False
Found = re.search(r"(\d+)\.(\d+)", Output)
return bool(Found) and (int(Found[1]), int(Found[2])) >= (1, 17)
return True
def CurrentDesktop() -> str:
"""First entry of $XDG_CURRENT_DESKTOP as-is (e.g. "COSMIC"), used for
portal descriptors' UseIn= and the <desktop>-portals.conf file name.
+118 -27
View File
@@ -11,6 +11,7 @@ import hashlib
import os
import re
import shutil
import signal
import subprocess
import tempfile
from dataclasses import dataclass, field
@@ -19,7 +20,11 @@ from pathlib import Path, PurePosixPath
from typing import Callable, Optional
from ConfigMerge import ApplySet, MergeConfig, MergeResult
from Core import BackupsDir, ChildEnvironment, DownloadsDir, IsWindows, LoadConfig, PristineDir, StartMenuDir, StartupDir
import Services
from Core import (
AutostartDir, BackupsDir, ChildEnvironment, DownloadsDir, HasSystemd, Home, IsWindows, LoadConfig,
PristineDir, StartMenuDir, StartupDir,
)
from Manifests import Leftover, Program, Render
from Receipts import IniRecord, LoadReceipt, PlacedFile, Receipt
from Sources import ClearDownloads, Download, LatestCommit, SourceError
@@ -41,11 +46,18 @@ def _Now() -> str:
def _Run(Command: list[str], Log: LogFn, Check: bool = True, Cwd: Optional[Path] = None) -> int:
"""Run a command, streaming its output into the log."""
Log(f"$ {' '.join(Command)}")
Process = subprocess.Popen(
Command, cwd=Cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=ChildEnvironment(),
encoding="utf-8", errors="replace",
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), # no console flashes on Windows
)
try:
Process = subprocess.Popen(
Command, cwd=Cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=ChildEnvironment(),
encoding="utf-8", errors="replace",
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), # no console flashes on Windows
)
except FileNotFoundError:
# Not installed here (e.g. update-desktop-database without desktop-file-utils).
if Check:
raise InstallError(f"{Command[0]} isn't installed")
Log(f" (skipped: {Command[0]} isn't installed)")
return 127
for Line in Process.stdout:
Log(f" {Line.rstrip()}")
Code = Process.wait()
@@ -55,6 +67,8 @@ def _Run(Command: list[str], Log: LogFn, Check: bool = True, Cwd: Optional[Path]
def _Systemctl(Log: LogFn, *Args: str, Check: bool = False) -> int:
if not HasSystemd:
return 0 # services run through Services.py instead
return _Run(["systemctl", "--user", *Args], Log, Check=Check)
@@ -75,18 +89,47 @@ def _BackupName(Target: str) -> str:
return Target.strip("/").replace("/", "__")
def _Pkexec(Command: list[str], Log: LogFn, Check: bool = True) -> int:
"""Run Command as root through pkexec, saying what went wrong in words."""
if shutil.which("pkexec") is None:
Code = 127
else:
Code = _Run(["pkexec", *Command], Log, Check=False)
if Code == 0:
return 0
if Code == 126:
Problem = "the password prompt was cancelled"
elif shutil.which("pkexec") is None:
Problem = "pkexec (polkit) isn't installed"
else:
Problem = ("no password prompt could be shown: polkit needs an authentication agent running "
"in your session (e.g. polkit-gnome or lxqt-policykit), which window managers don't start themselves")
Message = f"Couldn't do this as root ({Problem}). The command was: sudo {' '.join(Command)}"
if Check:
raise InstallError(Message)
Log(f"Warning: {Message}")
return Code
def _NeedsRoot(Template, Target: str) -> bool:
if Template.Root == "Auto":
return not (Target == str(Home) or Target.startswith(f"{Home}{os.sep}"))
return bool(Template.Root)
def _WriteRoot(Target: str, Text: str, Log: LogFn) -> None:
with tempfile.NamedTemporaryFile("w", delete=False, suffix=".sarasuite") as Temporary:
Temporary.write(Text)
os.chmod(Temporary.name, 0o644)
try:
_Run(["pkexec", "install", "-D", "-m", "644", Temporary.name, Target], Log)
_Pkexec(["install", "-D", "-m", "644", Temporary.name, Target], Log)
finally:
os.unlink(Temporary.name)
def _RemoveRoot(Target: str, Log: LogFn) -> None:
_Run(["pkexec", "rm", "-f", Target], Log)
# A leftover outside $HOME isn't worth failing an install or removal over.
_Pkexec(["rm", "-f", Target], Log, Check=False)
def _ReadIni(FilePath: Path) -> configparser.ConfigParser:
@@ -375,7 +418,8 @@ def _ApplyTemplates(Program: Program, Context: dict, OldReceipt: Optional[Receip
Target = Render(Template.Target, Context)
Text = Render((Program.Folder / "Templates" / Template.Source).read_text(), Context)
Previous = OldReceipt.FindFile(Target) if OldReceipt is not None else None
Placed = PlacedFile(Target, "Template", Root=Template.Root, Displaced=Previous.Displaced if Previous else "")
Root = _NeedsRoot(Template, Target)
Placed = PlacedFile(Target, "Template", Root=Root, Displaced=Previous.Displaced if Previous else "")
# A file that's already there but wasn't placed by SaraSuite (e.g. a
# hand-made desktop entry from development) is moved aside, not lost.
@@ -387,7 +431,7 @@ def _ApplyTemplates(Program: Program, Context: dict, OldReceipt: Optional[Receip
Log(f"Moved your existing {Target} aside (it comes back if {Program.Name} is removed)")
NewReceipt.Files.append(Placed)
if Template.Root:
if Root:
if Path(Target).exists() and Path(Target).read_text() == Text:
continue
_WriteRoot(Target, Text, Log)
@@ -499,11 +543,30 @@ def _ApplyShortcuts(Program: Program, Context: dict, OldReceipt: Optional[Receip
)
def _ApplyWindowsServices(Program: Program, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None:
"""A Startup-folder shortcut runs the service at login (through
ServiceRun.py, which records its PID); enabling also starts it now."""
import WindowsSupport
def _PlaceAutostart(Program: Program, Service, Command: tuple, OldReceipt: Optional[Receipt],
NewReceipt: Receipt, Log: LogFn) -> None:
"""Linux without systemd: the XDG autostart entry that starts a service at
login. Recorded like a shortcut, so it goes when the service is turned off
or the program removed (and one that was already there comes back)."""
Entry = AutostartDir / f"{Service.Name}.desktop"
Previous = OldReceipt.FindFile(str(Entry)) if OldReceipt is not None else None
Placed = PlacedFile(str(Entry), "Shortcut", Displaced=Previous.Displaced if Previous else "")
if Previous is None and Entry.exists():
Displaced = BackupsDir / Program.Name / "Displaced" / _BackupName(str(Entry))
Displaced.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(Entry, Displaced)
Placed.Displaced = str(Displaced)
Entry.parent.mkdir(parents=True, exist_ok=True)
Entry.write_text(Services.AutostartEntry(Service.Name, *Command))
NewReceipt.Files.append(Placed)
Log(f"Wrote {Entry}")
def _ApplyStandInServices(Program: Program, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None:
"""Services SaraSuite runs itself (Windows; Linux without systemd): at
login from a Startup-folder shortcut / an autostart entry, through
ServiceRun.py; enabling also starts it now. An update restarts it, so
it runs the new code."""
WasEnabled = set(OldReceipt.EnabledServices) if OldReceipt else set()
Runner = Program.ProgramDir / "ServiceRun.py"
for Service in Program.Services:
@@ -511,13 +574,19 @@ def _ApplyWindowsServices(Program: Program, Context: dict, OldReceipt: Optional[
WorkingDir = Path(Render(Service.WorkingDir, Context)) if Service.WorkingDir else Program.AppDir
if Program.OptionEnabled(Service.Option, NewReceipt.Options):
NewReceipt.EnabledServices.append(Service.Id)
_PlaceShortcut(
Program, StartupDir / f"{Service.Name}.lnk", f'"{Runner}" {Service.Name} "{Script}"',
WorkingDir, None, f"{Service.Name} (background service)", OldReceipt, NewReceipt, Log,
)
WindowsSupport.StartService(Service.Name, Program.PythonW, Runner, Script, WorkingDir, Log)
if IsWindows:
_PlaceShortcut(
Program, StartupDir / f"{Service.Name}.lnk", f'"{Runner}" {Service.Name} "{Script}" "{WorkingDir}"',
WorkingDir, None, f"{Service.Name} (background service)", OldReceipt, NewReceipt, Log,
)
else:
_PlaceAutostart(Program, Service, (Program.Python, Runner, Script, WorkingDir), OldReceipt, NewReceipt, Log)
if Service.Id in WasEnabled and not IsWindows: # Windows stopped it before the update
Services.Restart(Service.Name, Program.PythonW, Runner, Script, WorkingDir, Log)
else:
Services.Start(Service.Name, Program.PythonW, Runner, Script, WorkingDir, Log)
elif Service.Id in WasEnabled:
WindowsSupport.StopService(Service.Name, Log)
Services.Stop(Service.Name, Log)
def _StopWindowsServices(Program: Program, Receipt: Optional[Receipt], Log: LogFn) -> None:
@@ -525,17 +594,15 @@ def _StopWindowsServices(Program: Program, Receipt: Optional[Receipt], Log: LogF
its files or venv are replaced or removed."""
if not IsWindows or Receipt is None:
return
import WindowsSupport
for Name in Receipt.EnabledServices:
WindowsSupport.StopService(Name, Log)
Services.Stop(Name, Log)
def _ApplyServices(Program: Program, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn, Context: Optional[dict] = None) -> None:
if not Program.Services and not (OldReceipt and OldReceipt.EnabledServices):
return
if IsWindows:
_ApplyWindowsServices(Program, Context or Program.Context(NewReceipt.Answers), OldReceipt, NewReceipt, Log)
if IsWindows or not HasSystemd:
_ApplyStandInServices(Program, Context or Program.Context(NewReceipt.Answers), OldReceipt, NewReceipt, Log)
return
_Systemctl(Log, "daemon-reload")
WasEnabled = set(OldReceipt.EnabledServices) if OldReceipt else set()
@@ -550,6 +617,20 @@ def _ApplyServices(Program: Program, OldReceipt: Optional[Receipt], NewReceipt:
_Systemctl(Log, "disable", "--now", Service.Unit)
def _StopProcesses(Name: str, Log: LogFn) -> None:
"""Linux: stop your own processes called Name (like pkill -x -u $USER,
which not every system has)."""
Stopped = 0
for Entry in Path("/proc").iterdir():
try:
if Entry.name.isdigit() and Entry.stat().st_uid == os.getuid() and (Entry / "comm").read_text().strip() == Name[:15]:
os.kill(int(Entry.name), signal.SIGTERM)
Stopped += 1
except (OSError, ValueError):
continue
Log(f"Stopped {Name} ({Stopped} running) so it starts again with the change" if Stopped else f"{Name} wasn't running")
def _AfterIntegrationChanges(Program: Program, Log: LogFn) -> None:
if IsWindows:
return # Start Menu shortcuts need no refresh
@@ -557,7 +638,12 @@ def _AfterIntegrationChanges(Program: Program, Log: LogFn) -> None:
if any(Template.Target.endswith(".desktop") for Template in Program.Templates) and Applications.exists():
_Run(["update-desktop-database", str(Applications)], Log, Check=False)
for Unit in Program.RestartUnits:
_Systemctl(Log, "try-restart", Unit)
if HasSystemd:
_Systemctl(Log, "try-restart", Unit)
else:
# No systemd to restart it: stop it, and D-Bus starts it again
# when it's next needed (xdg-desktop-portal is D-Bus activated).
_StopProcesses(Unit.removesuffix(".service"), Log)
def Apply(
@@ -686,7 +772,9 @@ def PlanRemoval(Program: Program) -> RemovalPlan:
Plan.Items.append(RemovalItem(str(Downloaded), "Program", "downloaded source"))
if Current is not None:
ServiceNote = "background service, stopped and taken out of Startup" if IsWindows else "systemd user service, stopped and disabled"
ServiceNote = ("background service, stopped and taken out of Startup" if IsWindows
else "systemd user service, stopped and disabled" if HasSystemd
else "background service, stopped and taken out of autostart")
for Unit in Current.EnabledServices:
Plan.Items.append(RemovalItem(Unit, "Integration", ServiceNote))
for Placed in Current.Files:
@@ -733,6 +821,9 @@ def Remove(Program: Program, RemoveConfigs: bool, RemoveRuntime: bool, Log: LogF
if Current is not None:
if IsWindows:
_StopWindowsServices(Program, Current, Log)
elif not HasSystemd:
for Name in Current.EnabledServices:
Services.Stop(Name, Log)
else:
for Unit in Current.EnabledServices:
_Systemctl(Log, "disable", "--now", Unit)
+18
View File
@@ -38,6 +38,23 @@ else:
_MenuLabel = "Start menu" if IsWindows else "app menu"
def _KeepFallbackLibraries() -> None:
"""Linux AppImage: copy the bundled libsecret (and what it needs) to
~/.local/share/SaraSuite/Libraries, for FletIdentity.py to give the Flet
client of every installed program on systems without it."""
Bundled = Path(getattr(sys, "_MEIPASS", "")) / "FallbackLibs"
if IsWindows or not Bundled.is_dir():
return
Kept = DataDir / "Libraries"
try:
Kept.mkdir(parents=True, exist_ok=True)
for Library in Bundled.iterdir():
if not (Kept / Library.name).exists() or (Kept / Library.name).stat().st_size != Library.stat().st_size:
shutil.copy2(Library, Kept / Library.name)
except OSError:
pass # only a fallback
def _LoadFletIdentity():
# Lives in Programs/ (bundled as data) because installed programs get a
# copy of the same file next to them.
@@ -324,6 +341,7 @@ def Main(Page: ft.Page) -> None:
if __name__ == "__main__":
_KeepFallbackLibraries() # also before a command (an install from the command line)
if len(sys.argv) > 1:
if IsWindows and getattr(sys, "frozen", False):
# The .exe is built windowed (no console of its own); write the
+29 -9
View File
@@ -16,8 +16,8 @@ from typing import Optional
import tomlkit
from Core import (
CacheHome, ConfigHome, CurrentDesktop, DataHome, Home, IsWindows, LoadConfig, Platform,
ProgramsDir, SharedConfigDir, StartMenuDir, StartupDir,
AutostartDir, CacheHome, ConfigHome, CurrentDesktop, DataHome, HasSystemd, Home, IsWindows, LoadConfig,
Platform, ProgramsDir, SharedConfigDir, StartMenuDir, StartupDir, UserPortalsSupported,
)
# Never worth copying out of a source checkout, whatever Include says.
@@ -55,7 +55,9 @@ class TemplateFile:
Source: str # relative to the program's Templates/ folder
Target: str
Option: str = ""
Root: bool = False # lives outside $HOME, written through pkexec
# True: lives outside $HOME, written through pkexec. "Auto": only when its
# Target turns out to be outside $HOME (e.g. {PortalsDir} on an old system).
Root: bool | str = False
Executable: bool = False
@@ -83,19 +85,26 @@ class IniEdit:
@dataclass
class Service:
"""A background service. Linux: a systemd --user Unit (placed by a
Template). Windows: Script (relative to the app folder) run windowless by
ServiceRun.py in WorkingDir, at login through a Startup-folder shortcut."""
"""A background service. Linux with systemd: a --user Unit (placed by a
Template). Windows, and Linux without systemd (runit, OpenRC, ...):
Script (relative to the app folder) run by ServiceRun.py in WorkingDir,
at login through a Startup-folder shortcut / an XDG autostart entry
(Services.py), so a Linux service gives both Unit and Name/Script."""
Unit: str = ""
Option: str = "" # enabled + started only while this option is ticked
Name: str = "" # Windows: the service's name (PID file, Startup shortcut)
Name: str = "" # run by SaraSuite: the service's name (PID file, log, autostart)
Script: str = ""
WorkingDir: str = ""
@property
def RunBySaraSuite(self) -> bool:
return IsWindows or not HasSystemd
@property
def Id(self) -> str:
return self.Unit or self.Name
"""What the receipt records: the unit under systemd, else the name."""
return (self.Name or self.Unit) if self.RunBySaraSuite else (self.Unit or self.Name)
@dataclass
@@ -229,8 +238,17 @@ class Program:
else:
Context.update(
SystemdUserDir=str(ConfigHome / "systemd" / "user"),
AutostartDir=str(AutostartDir),
ApplicationsDir=str(DataHome / "applications"),
DBusServicesDir=str(DataHome / "dbus-1" / "services"),
# xdg-desktop-portal: where a portal file goes (your own folder
# where it's new enough, no password), the preferences file
# (portals.conf without a desktop name, e.g. a window manager),
# and the desktop's own entry in its list ("cosmic;" or "").
PortalsDir=str(DataHome / "xdg-desktop-portal" / "portals") if UserPortalsSupported()
else "/usr/share/xdg-desktop-portal/portals",
PortalsConf=f"{Desktop.lower()}-portals.conf" if Desktop else "portals.conf",
DesktopPortal=f"{Desktop.lower()};" if Desktop else "",
)
Context.update(Answers or {})
return Context
@@ -282,7 +300,9 @@ def LoadProgram(Folder: Path) -> Program:
RequirementsFile=Python.get("RequirementsFile", ""),
Options=[Option(**Item) for Item in _ForPlatform(Data.get("Options", []))],
Questions=[Question(**Item) for Item in _ForPlatform(Data.get("Questions", []))],
Templates=[TemplateFile(**Item) for Item in _ForPlatform(Data.get("Templates", []))],
# Without systemd, its unit files would just sit there unused.
Templates=[TemplateFile(**Item) for Item in _ForPlatform(Data.get("Templates", []))
if HasSystemd or "{SystemdUserDir}" not in Item.get("Target", "")],
Configs=[ConfigFile(**Item) for Item in _ForPlatform(Data.get("Configs", []))],
IniEdits=[IniEdit(**Item) for Item in _ForPlatform(Data.get("IniEdits", []))],
Services=[Service(**Item) for Item in _ForPlatform(Data.get("Services", []))],
+9
View File
@@ -18,8 +18,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 flet-desktop 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 SaraSuite --collect-data flet \
--runtime-hook "$PWD/Packaging/RestoreLibraryPath.py" \
--add-data "$PWD/build/FallbackLibs:FallbackLibs" \
--add-data "$PWD/assets:assets" --add-data "$PWD/Programs:Programs" \
--distpath build/PyInstaller/dist --workpath build/PyInstaller/work --specpath build/PyInstaller \
"$PWD/Main.py"'
+47
View File
@@ -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)
+52 -1
View File
@@ -18,13 +18,51 @@ Used two ways:
case Flet just uses its normal client.
"""
import os
import shutil
import sys
from pathlib import Path
OriginalId = b"com.appveyor.flet"
# Flet's window needs libsecret, which isn't part of GTK and some systems don't
# install. SaraSuite keeps copies of it and what it needs (from its AppImage)
# here; AddMissingLibraries puts the ones this system lacks in a fallback-libs
# folder beside the client copy, which the program's Launch.sh adds to
# LD_LIBRARY_PATH (a plugin loads libsecret, so the client's own lib folder,
# its RUNPATH, wouldn't be searched for it).
LibrariesDir = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "SaraSuite" / "Libraries"
FallbackLibraries = ("libgpg-error.so.0", "libgcrypt.so.20", "libsecret-1.so.0")
CopiesDir = Path.home() / ".cache" / "SaraSuite" / "FletClient"
def _SystemHas(Name: str) -> bool:
"""Whether the system has a library (its standard folders and linker
cache; the same check as the AppImages' RestoreLibraryPath.py)."""
import subprocess
for Folder in ("/usr/lib64", "/usr/lib", "/lib64", "/lib", "/usr/lib/x86_64-linux-gnu", "/lib/x86_64-linux-gnu"):
if (Path(Folder) / Name).exists():
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())
def AddMissingLibraries(Folder: Path) -> None:
"""Copies SaraSuite's fallback libraries the system doesn't have into
Folder (created only if one is needed)."""
if not sys.platform.startswith("linux") or not LibrariesDir.is_dir():
return
for Name in FallbackLibraries:
Source = LibrariesDir / Name
if Source.is_file() and not (Folder / Name).exists() and not _SystemHas(Name):
Folder.mkdir(parents=True, exist_ok=True)
shutil.copy2(Source, Folder / Name)
def ClientFor(AppId: str) -> str:
"""Folder of a client copy whose app ID is AppId ("" if not possible).
AppId needs a dot (a GTK rule) and at most 17 characters, the length of
@@ -45,6 +83,7 @@ def ClientFor(AppId: str) -> str:
if Binary.count(b"\x00" + OriginalId + b"\x00") != 1:
return "" # not the client layout this was written for
AddMissingLibraries(Copy.parent / "fallback-libs")
Target = Copy / "flet"
if not (Target.exists() and Target.read_bytes() == Patched):
Copy.mkdir(parents=True, exist_ok=True)
@@ -64,4 +103,16 @@ def ClientFor(AppId: str) -> str:
if __name__ == "__main__":
print(ClientFor(sys.argv[1]) if len(sys.argv) > 1 else "")
# Only the folder goes to stdout, which the launcher reads: Flet's own
# messages ("Preparing Flet ... for the first use", written straight to
# the file descriptor) go to stderr.
sys.stdout.flush()
Stdout = os.dup(1)
os.dup2(2, 1)
try:
Folder = ClientFor(sys.argv[1]) if len(sys.argv) > 1 else ""
finally:
sys.stdout.flush()
os.dup2(Stdout, 1)
os.close(Stdout)
print(Folder)
+7 -5
View File
@@ -18,7 +18,7 @@ RequirementsFile = "requirements.txt"
[[Options]]
Platform = "Linux"
Id = "FilePicker"
Label = "Use SaraFem as the system file picker (asks for your password: the portal file lives in /usr/share)"
Label = "Use SaraFem as the system file picker"
Default = true
[[Options]]
@@ -48,16 +48,18 @@ Option = "FilePicker"
[[Templates]]
Platform = "Linux"
Source = "SaraFem.portal"
Target = "/usr/share/xdg-desktop-portal/portals/SaraFem.portal"
# In your own folder where xdg-desktop-portal is new enough (1.17+); in
# /usr/share, through pkexec, on older systems.
Target = "{PortalsDir}/SaraFem.portal"
Option = "FilePicker"
Root = true
Root = "Auto"
[[IniEdits]]
Platform = "Linux"
File = "{ConfigHome}/xdg-desktop-portal/{Desktop}-portals.conf"
File = "{ConfigHome}/xdg-desktop-portal/{PortalsConf}"
Section = "preferred"
Key = "org.freedesktop.impl.portal.FileChooser"
Value = "SaraFem;{Desktop};gtk;"
Value = "SaraFem;{DesktopPortal}gtk;"
Option = "FilePicker"
[[IniEdits]]
+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
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 "$@"
+12 -8
View File
@@ -40,24 +40,28 @@ Target = "{ConfigDir}/InputDirConf.toml"
Source = "OutputDirConf.toml"
Target = "{ConfigDir}/OutputDirConf.toml"
# Linux: a systemd user service.
# Linux with systemd: a systemd user service.
[[Templates]]
Platform = "Linux"
Source = "SaraSortd.service"
Target = "{SystemdUserDir}/SaraSortd.service"
# Windows, and Linux without systemd (runit, OpenRC, s6, ...): run from
# ConfigDir by ServiceRun.py, which restarts it if it fails, at login through
# a shortcut in the Startup folder / an XDG autostart entry (SaraSuite's
# Services.py). On Linux the same service has a Unit for systemd too.
[[Templates]]
Source = "../../ServiceRun.py"
Target = "{ProgramDir}/ServiceRun.py"
[[Services]]
Platform = "Linux"
Unit = "SaraSortd.service"
Name = "SaraSortd"
Script = "SaraSortd.py"
WorkingDir = "{ConfigDir}"
Option = "Autostart"
# Windows: run windowless from ConfigDir by ServiceRun.py, at login through a
# shortcut in the Startup folder (see SaraSuite's WindowsSupport.py).
[[Templates]]
Platform = "Windows"
Source = "../../ServiceRun.py"
Target = "{ProgramDir}/ServiceRun.py"
[[Services]]
Platform = "Windows"
Name = "SaraSortd"
+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
View File
@@ -6,5 +6,9 @@ export FLET_DESKTOP_FLAVOR=full
# 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 "$@"
+2 -1
View File
@@ -6,7 +6,8 @@ Description = "SaraView's desktop client: view and edit files in a local folder,
[Source]
Git = "{GitHost}/SaraView"
# Only the Flet client; the server (Backend/, Frontend/) runs in Docker on SpineS.
Include = ["Client/*.py", "Client/requirements.txt", "Client/assets/**"]
# Backend/VarEngine.py is the variable engine the client shares with it.
Include = ["Client/*.py", "Client/requirements.txt", "Client/assets/**", "Backend/VarEngine.py"]
Exclude = ["Client/Packaging"]
[Python]
+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}/Client" || exit 1
exec "{Python}" Main.py "$@"
+86 -15
View File
@@ -1,33 +1,104 @@
"""Runs a SaraSuite program as a background service on Windows, the stand-in
for a systemd --user service. Started windowless with pythonw.exe, both at
login (by the program's shortcut in the Startup folder) and when SaraSuite
or the program's own GUI (e.g. SaraSort for SaraSortd) starts it:
"""Runs a SaraSuite program as a background service where there's no systemd
--user to do it: on Windows (started windowless with pythonw.exe), and on
Linux with another init system (runit on Void, OpenRC, s6, dinit, ...).
Started at login (Windows: a shortcut in the Startup folder; Linux: an XDG
autostart entry) and when SaraSuite or the program's own GUI (e.g. SaraSort
for SaraSortd) starts it:
pythonw.exe ServiceRun.py <Name> <Script> (in the service's working folder)
python ServiceRun.py <Name> <Script> [<WorkingDir>]
It records its PID in %LOCALAPPDATA%\\SaraSuite\\Services\\<Name>.pid, so
anything can tell whether the service runs, and stop it (taskkill), without
a Windows service and without admin rights. Installed next to each program
that has a Windows service (Programs/<Name>/Manifest.toml, [[Services]]).
Like the systemd unit it stands in for: one copy at a time; restarted 10
seconds after it fails (an exception or a non-zero exit), not after a clean
exit or a stop; its output goes to <Name>.log. Its PID is in <Name>.pid, so
anything can tell whether it runs, and stop it, without admin rights. Both
files are in SaraSuite's Services folder: %LOCALAPPDATA%\\SaraSuite\\Services
on Windows, ~/.local/share/SaraSuite/Services on Linux.
Installed next to each program that has a service (Programs/<Name>/Manifest.toml).
"""
import os
import runpy
import signal
import sys
import time
import traceback
from datetime import datetime
from pathlib import Path
IsWindows = sys.platform == "win32"
RestartSeconds = 10 # the unit's RestartSec
MaxLogBytes = 1_000_000 # then the log starts over, the old one kept as <Name>.log.1
Stopping = False
def ServicesDir() -> Path:
if IsWindows:
Base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
else:
Base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
return Base / "SaraSuite" / "Services"
def _AlreadyRunning(PidFile: Path) -> bool:
"""Linux: whether the PID file's process is another ServiceRun.py still
running (not a reused PID). Windows relies on whoever starts it to check."""
if IsWindows:
return False
try:
Pid = int(PidFile.read_text().strip())
if Pid == os.getpid():
return False
return b"ServiceRun.py" in Path(f"/proc/{Pid}/cmdline").read_bytes()
except (OSError, ValueError):
return False
def _Stop(Signal, Frame) -> None:
global Stopping
Stopping = True
raise SystemExit(0)
def Main() -> None:
Name, Script = sys.argv[1], Path(sys.argv[2])
LocalAppData = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
PidFile = LocalAppData / "SaraSuite" / "Services" / f"{Name}.pid"
PidFile.parent.mkdir(parents=True, exist_ok=True)
if len(sys.argv) > 3:
Folder = Path(sys.argv[3])
Folder.mkdir(parents=True, exist_ok=True)
os.chdir(Folder)
Folder = ServicesDir()
Folder.mkdir(parents=True, exist_ok=True)
# Not an f-string: SaraSuite fills in braced names like that one when it places this file.
PidFile = Folder / (Name + ".pid")
if _AlreadyRunning(PidFile):
return
LogFile = Folder / (Name + ".log")
if LogFile.exists() and LogFile.stat().st_size > MaxLogBytes:
LogFile.replace(LogFile.with_suffix(".log.1"))
sys.stdout = sys.stderr = open(LogFile, "a", buffering=1, encoding="utf-8", errors="replace")
PidFile.write_text(str(os.getpid()))
if not IsWindows:
signal.signal(signal.SIGTERM, _Stop) # a stop: finish cleanly, don't restart
sys.path.insert(0, str(Script.parent))
try:
sys.argv = [str(Script)]
sys.path.insert(0, str(Script.parent))
runpy.run_path(str(Script), run_name="__main__")
while True:
print(f"== {datetime.now():%Y-%m-%d %H:%M:%S} starting {Script.name}")
try:
sys.argv = [str(Script)]
runpy.run_path(str(Script), run_name="__main__")
break
except SystemExit as Exit:
if Stopping or Exit.code in (None, 0):
break
print(f"== {Script.name} exited with {Exit.code}")
except Exception:
traceback.print_exc()
print(f"== restarting in {RestartSeconds} seconds")
time.sleep(RestartSeconds)
finally:
print(f"== {datetime.now():%Y-%m-%d %H:%M:%S} stopped")
# Only remove it if it's still ours (a newer start may have replaced it).
try:
if PidFile.read_text().strip() == str(os.getpid()):
+23 -7
View File
@@ -6,7 +6,8 @@ It's one AppImage file: download it, run it, pick a program.
- **Install**: downloads the program's latest commit from its git repo, copies it into
`~/.local/share/SaraSuite/Programs/<Name>/` with its own Python venv, places its configs in
`~/.config/SaraSuite/<Name>/`, and writes its desktop entries, systemd user units, D-Bus
services and so on.
services and so on. Without systemd (runit on Void, OpenRC, s6, dinit, ...), background
services run the way they do on Windows: see the table below.
- **Update**: shown when the repo has a newer commit. Program files and integration files are
replaced; configs are *merged*: your edits always win, defaults you never touched follow the
new version, new keys are added (see `ConfigMerge.py`).
@@ -90,6 +91,15 @@ places it. It reapplies the installed commit, so the program itself isn't update
| `~/.cache/...` | `%LOCALAPPDATA%\...` |
| desktop entry | Start Menu shortcut (`[[Shortcuts]]`) |
| systemd user service | `ServiceRun.py`, started at login by a shortcut in the Startup folder; its PID is in `%LOCALAPPDATA%\SaraSuite\Services\<Name>.pid` |
Linux without systemd (no `/run/systemd/system`: runit on Void, OpenRC, s6, dinit, ...) runs
services like Windows does (`Services.py`): `ServiceRun.py` runs them, started at login by an XDG
autostart entry, `~/.config/autostart/<Name>.desktop`, which desktop sessions start whatever the
init system is. It restarts a service 10 seconds after it fails, like the unit's
`Restart=on-failure`, and keeps its output in `~/.local/share/SaraSuite/Services/<Name>.log` (its
PID next to it, in `<Name>.pid`). Units aren't written there, and `RestartUnits` stops the process
instead (D-Bus starts `xdg-desktop-portal` again when it's needed). A window manager that doesn't
run XDG autostart entries needs to start them itself (e.g. `dex -a` in its startup config).
| system `python3` | the `py` launcher / `python.exe`; if there's none, SaraSuite installs Python for the user with winget |
| AppImage self-update (rename over it) | `.exe` self-update (the running one is renamed to `SaraSuite.old.exe`, deleted on the next start) |
@@ -142,7 +152,9 @@ Anything the manifest can't describe goes in an optional `Programs/<Name>/Hooks.
Targets, templates and `Set` values can use `{Variables}`: `Name`, `GitHost`, `Home`, `ConfigHome`,
`DataHome`, `CacheHome`, `ProgramDir`, `AppDir`, `Python` (the venv's python), `PythonW`
(Windows: its console-less pythonw.exe), `ConfigDir`, `DesktopName` (e.g. `COSMIC`),
`Desktop` (e.g. `cosmic`), plus every Question's `Id`. Linux only: `SystemdUserDir`,
`Desktop` (e.g. `cosmic`), plus every Question's `Id`. Linux only: `PortalsDir` (your own xdg-desktop-portal folder where it's 1.17+, else
`/usr/share/...`), `PortalsConf` (`<desktop>-portals.conf`, or `portals.conf` without a desktop name),
`DesktopPortal` (`<desktop>;` or nothing), `SystemdUserDir`,
`ApplicationsDir`, `DBusServicesDir`. Windows only: `StartMenuDir`, `StartupDir`.
Every list item (`[[Options]]`, `[[Templates]]`, `[[Services]]`, ...) can carry
@@ -190,16 +202,20 @@ Set = { "Paths.RootDir" = "{RootDir}" }
Source = "Example.service" # in Templates/
Target = "{SystemdUserDir}/Example.service"
Option = "Autostart" # optional: only while the option is ticked
Root = false # true: outside $HOME, written through pkexec
Root = false # true: outside $HOME, through pkexec; "Auto": only if Target is
# outside $HOME once rendered (e.g. {PortalsDir})
Executable = false
[[Services]] # Linux: systemd --user units, enabled + started
Platform = "Linux"
Unit = "Example.service"
[[Services]] # Linux: a systemd --user unit, enabled + started; without
Platform = "Linux" # systemd, Name/Script/WorkingDir run by ServiceRun.py instead,
Unit = "Example.service" # started at login by an autostart entry
Name = "Example"
Script = "Example.py"
WorkingDir = "{ConfigDir}"
Option = "Autostart"
[[Services]] # Windows: run by ServiceRun.py from a Startup-folder shortcut
Platform = "Windows" # (also add ../../ServiceRun.py as a Template to {ProgramDir})
Platform = "Windows" # (both: add ../../ServiceRun.py as a Template to {ProgramDir})
Name = "Example"
Script = "Example.py" # relative to the app folder
WorkingDir = "{ConfigDir}"
+123
View File
@@ -0,0 +1,123 @@
"""Background services SaraSuite runs itself, where there's no systemd --user
to do it: Windows, and Linux with another init system (runit on Void, OpenRC,
s6, dinit, ...). Each runs as Programs/ServiceRun.py <Name> <Script>
<WorkingDir>, which keeps it going (restarts it if it fails), logs its output
and records its PID, all in ServicesDir. At login it's started by
- Windows: a shortcut in the Startup folder;
- Linux: an XDG autostart entry (~/.config/autostart/<Name>.desktop), which
desktop sessions start whatever the init system is.
The installer places those (Installer._ApplyStandInServices); this starts,
stops and checks the services. SaraSort's Service.py does the same for SaraSortd.
"""
import os
import re
import signal
import subprocess
import time
from pathlib import Path
from typing import Callable, Optional
from Core import ChildEnvironment, IsWindows, ServicesDir
LogFn = Callable[[str], None]
# Windows: child processes of a windowed program would each flash a console window.
NoWindow = getattr(subprocess, "CREATE_NO_WINDOW", 0)
_Detached = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
def _PidFile(Name: str) -> Path:
return ServicesDir / f"{Name}.pid"
def LogFile(Name: str) -> Path:
return ServicesDir / f"{Name}.log"
def ServicePid(Name: str) -> Optional[int]:
"""The PID ServiceRun.py recorded, if that process is still it."""
try:
Pid = int(_PidFile(Name).read_text().strip())
except (OSError, ValueError):
return None
if not IsWindows:
try:
return Pid if b"ServiceRun.py" in Path(f"/proc/{Pid}/cmdline").read_bytes() else None
except OSError:
return None
try:
Output = subprocess.run(
["tasklist", "/FI", f"PID eq {Pid}", "/NH", "/FO", "CSV"],
capture_output=True, text=True, timeout=15, creationflags=NoWindow,
).stdout
except (OSError, subprocess.SubprocessError):
return None
return Pid if re.search(rf'"{Pid}"', Output) else None
def Command(Python: Path, Runner: Path, Name: str, Script: Path, WorkingDir: Path) -> list[str]:
"""What runs the service (Python: pythonw.exe on Windows)."""
return [str(Python), str(Runner), Name, str(Script), str(WorkingDir)]
def Start(Name: str, Python: Path, Runner: Path, Script: Path, WorkingDir: Path, Log: LogFn) -> None:
"""Start a service now (it also starts at every login, the same way)."""
if ServicePid(Name) is not None:
Log(f"{Name} is already running")
return
WorkingDir.mkdir(parents=True, exist_ok=True)
Extra = {"creationflags": _Detached | NoWindow} if IsWindows else {"start_new_session": True}
subprocess.Popen(
Command(Python, Runner, Name, Script, WorkingDir), cwd=str(WorkingDir), env=ChildEnvironment(),
close_fds=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **Extra,
)
Log(f"Started {Name}")
def Stop(Name: str, Log: LogFn) -> None:
Pid = ServicePid(Name)
if Pid is not None:
if IsWindows:
subprocess.run(["taskkill", "/PID", str(Pid), "/T", "/F"], capture_output=True, creationflags=NoWindow)
else:
# ServiceRun.py finishes cleanly on SIGTERM; one that hangs is killed.
os.kill(Pid, signal.SIGTERM)
for _ in range(50):
if ServicePid(Name) is None:
break
time.sleep(0.2)
else:
os.kill(Pid, signal.SIGKILL)
Log(f"Stopped {Name}")
_PidFile(Name).unlink(missing_ok=True)
def Restart(Name: str, Python: Path, Runner: Path, Script: Path, WorkingDir: Path, Log: LogFn) -> None:
Stop(Name, Log)
Start(Name, Python, Runner, Script, WorkingDir, Log)
def _ExecArgument(Text: str) -> str:
"""One argument for a desktop entry's Exec key (the spec's quoting)."""
Text = Text.replace("%", "%%")
if re.fullmatch(r"[\w@%+=:,./-]+", Text):
return Text
return '"' + re.sub(r'(["`$\\])', r"\\\1", Text) + '"'
def AutostartEntry(Name: str, Python: Path, Runner: Path, Script: Path, WorkingDir: Path) -> str:
"""Linux: the XDG autostart entry that starts the service at login."""
Exec = " ".join(_ExecArgument(Part) for Part in Command(Python, Runner, Name, Script, WorkingDir))
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={Exec}\n"
"Terminal=false\n"
"NoDisplay=true\n"
"X-GNOME-Autostart-enabled=true\n"
)
+4 -3
View File
@@ -8,8 +8,8 @@ from typing import Callable
import flet as ft
from Core import (
AppConfig, BundledFontFamily, ChildEnvironment, ChipButtonStyle, IsWindows, LoadSharedSettings, Palette,
SharedSettings, ThemesDir,
AppConfig, BundledFontFamily, ChildEnvironment, ChipButtonStyle, EnsureSharedThemes, IsWindows,
LoadSharedSettings, Palette, SharedSettings, ThemesDir,
)
from Installer import DefaultAnswers, DefaultOptions
from Manifests import Program
@@ -25,8 +25,9 @@ def _Note(Text: str) -> ft.Text:
def _Themes() -> list[str]:
EnsureSharedThemes()
Names = {Path.stem for Path in ThemesDir.glob("*.toml")} if ThemesDir.is_dir() else set()
Names.add("CatppuccinMocha") # built in, even before SaraFem seeds the folder
Names.add("CatppuccinMocha") # built in, even where the folder can't be written
return sorted(Names)
+2 -55
View File
@@ -3,7 +3,7 @@
Linux Windows
.desktop entry Start Menu shortcut (.lnk)
systemd --user service shortcut in the Startup folder, started and stopped
through Programs/ServiceRun.py (which records its PID)
through Programs/ServiceRun.py (Services.py)
system python3 the py launcher / python.exe, else winget installs it
fc-list the Fonts registry key
@@ -17,13 +17,12 @@ import subprocess
from pathlib import Path
from typing import Callable, Optional
from Core import DataHome, ServicesDir
from Core import DataHome
LogFn = Callable[[str], None]
# Child processes of a windowed program would each flash a console window.
NoWindow = getattr(subprocess, "CREATE_NO_WINDOW", 0)
_Detached = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
PythonVersion = "3.14" # what winget installs when there's no Python yet
_WingetId = f"Python.Python.{PythonVersion}"
@@ -115,58 +114,6 @@ def InstallPython(Log: LogFn) -> Optional[str]:
return FindPython()
# --- Background services (SaraSortd) ---------------------------------------------------
def _PidFile(Name: str) -> Path:
return ServicesDir / f"{Name}.pid"
def ServicePid(Name: str) -> Optional[int]:
"""The PID ServiceRun.py recorded, if that process is still alive."""
try:
Pid = int(_PidFile(Name).read_text().strip())
except (OSError, ValueError):
return None
try:
Output = subprocess.run(
["tasklist", "/FI", f"PID eq {Pid}", "/NH", "/FO", "CSV"],
capture_output=True, text=True, timeout=15, creationflags=NoWindow,
).stdout
except (OSError, subprocess.SubprocessError):
return None
return Pid if re.search(rf'"{Pid}"', Output) else None
def StartService(Name: str, PythonW: Path, Runner: Path, Script: Path, WorkingDir: Path, Log: LogFn) -> None:
"""Start a background service now (it's also started at every login by
its Startup shortcut, which runs the same command)."""
if ServicePid(Name) is not None:
Log(f"{Name} is already running")
return
WorkingDir.mkdir(parents=True, exist_ok=True)
subprocess.Popen(
[str(PythonW), str(Runner), Name, str(Script)], cwd=str(WorkingDir),
creationflags=_Detached | NoWindow, close_fds=True,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
Log(f"Started {Name}")
def StopService(Name: str, Log: LogFn) -> None:
Pid = ServicePid(Name)
if Pid is not None:
subprocess.run(["taskkill", "/PID", str(Pid), "/T", "/F"], capture_output=True, creationflags=NoWindow)
Log(f"Stopped {Name}")
_PidFile(Name).unlink(missing_ok=True)
def RestartService(Name: str, PythonW: Path, Runner: Path, Script: Path, WorkingDir: Path, Log: LogFn) -> None:
if ServicePid(Name) is not None:
StopService(Name, Log)
StartService(Name, PythonW, Runner, Script, WorkingDir, Log)
# --- Fonts -----------------------------------------------------------------------------------