24 Commits
Author SHA1 Message Date
sarah 873a2d9ba0 SaraSuite.app for macOS, built on Linux; 1.3.0 2026-10-14 13:38:03 +02:00
sarah ca04dcd4a7 macOS support (code only, no build yet) 2026-10-14 13:18:20 +02:00
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
sarahandClaude Opus 5.5 2ebef97be1 Version 1.2.1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 09:23:39 +02:00
sarahandClaude Opus 5.5 d7db153c24 Build AppImages for older glibc
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 09:13:49 +02:00
sarah 4e662beca2 Added SaraTune. 2026-10-09 15:38:56 +02:00
sarah 9f99adc519 Release both builds; Windows docs 2026-10-09 14:33:35 +02:00
sarah 6fe282f686 SaraFem on Windows 2026-10-09 14:32:37 +02:00
sarah 51efb4a1f0 SaraView client on Windows 2026-10-09 14:26:41 +02:00
sarah 28513fa486 SaraTag on Windows 2026-10-09 14:23:06 +02:00
sarah 5e6d4d0a11 SaraSort on Windows 2026-10-09 14:19:23 +02:00
sarah 34457f6236 SaraSortd on Windows 2026-10-09 14:11:53 +02:00
sarah 92033f3bf6 Windows support in SaraSuite 2026-10-09 13:59:08 +02:00
sarah 3998dfde74 Self-update over HTTPS only 2026-10-09 10:40:13 +02:00
32 changed files with 2077 additions and 147 deletions

No files matched your search

+116 -10
View File
@@ -1,11 +1,22 @@
"""Shared app-wide constants: paths, palette, theme.
Paths follow each system's own per-user conventions (no admin needed):
Linux Windows macOS
~/.config %APPDATA% ~/Library/Application Support (settings)
~/.local/share %LOCALAPPDATA% ~/Library/Application Support (programs, SaraSuite's data)
~/.cache %LOCALAPPDATA% ~/Library/Caches (caches)
so the SaraSuite layout below them (SaraSuite/<Program>/...) is the same on all
three. (On macOS settings and data share one folder, as Mac apps' do: the
programs' config folders and SaraSuite's Programs, Receipts, ... sit side by side.)
The palette and theming mirror SaraFem's Core.py (which mirrors SaraView's),
and read the same shared ~/.config/SaraSuite/Themes/*.toml files, so a theme
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
@@ -16,8 +27,16 @@ 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.1.0"
IsFrozen = bool(getattr(sys, "frozen", False)) # running from the AppImage
AppVersion = "1.3.0"
# SaraSuite.app's launcher sets this to the app's path (Packaging/BuildMacApp.py).
MacApp = os.environ.get("SARASUITE_APP", "")
# Running packaged: from the AppImage / .exe (PyInstaller), or SaraSuite.app.
IsFrozen = bool(getattr(sys, "frozen", False)) or bool(MacApp)
IsWindows = sys.platform == "win32"
IsMac = sys.platform == "darwin"
IsLinux = not IsWindows and not IsMac
# Matches manifests' Platforms / Platform.
Platform = "Windows" if IsWindows else "macOS" if IsMac else "Linux"
# Bundled in assets/fonts and always registered, so it works as FontFamily
# without being installed. Any other FontFamily is looked up among the
# system's installed fonts.
@@ -25,9 +44,23 @@ BundledFontFamily = "Fixedsys Excelsior"
ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme
Home = Path.home()
ConfigHome = Home / ".config"
DataHome = Home / ".local" / "share"
CacheHome = Home / ".cache"
if IsWindows:
ConfigHome = Path(os.environ.get("APPDATA") or Home / "AppData" / "Roaming")
DataHome = Path(os.environ.get("LOCALAPPDATA") or Home / "AppData" / "Local")
CacheHome = DataHome
# Start Menu shortcuts, and the Startup folder (run at login: SaraSortd's
# stand-in for a systemd user service).
StartMenuDir = ConfigHome / "Microsoft" / "Windows" / "Start Menu" / "Programs"
StartupDir = StartMenuDir / "Startup"
elif IsMac:
ConfigHome = DataHome = Home / "Library" / "Application Support"
CacheHome = Home / "Library" / "Caches"
StartMenuDir = StartupDir = None
else:
ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Home / ".config")
DataHome = Path(os.environ.get("XDG_DATA_HOME") or Home / ".local" / "share")
CacheHome = Path(os.environ.get("XDG_CACHE_HOME") or Home / ".cache")
StartMenuDir = StartupDir = None
SharedConfigDir = ConfigHome / "SaraSuite"
ThemesDir = SharedConfigDir / "Themes"
@@ -45,6 +78,15 @@ 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
# <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. macOS runs them
# that way too, started at login by a LaunchAgent (MacSupport.py).
HasSystemd = IsLinux 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.
@@ -81,11 +123,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
@@ -192,14 +273,39 @@ def ChildEnvironment() -> dict[str, str]:
Environment["LD_LIBRARY_PATH"] = Original
else:
Environment.pop("LD_LIBRARY_PATH", None)
for Key in ("PYTHONHOME", "PYTHONPATH"):
for Key in ("PYTHONHOME", "PYTHONPATH", "SARASUITE_APP"):
Environment.pop(Key, None)
if IsWindows:
# Python on Windows otherwise reads/writes text in the old ANSI code
# page; every Sara program assumes UTF-8 (their TOML files).
Environment["PYTHONUTF8"] = "1"
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."""
portal descriptors' UseIn= and the <desktop>-portals.conf file name.
Empty on Windows."""
return os.environ.get("XDG_CURRENT_DESKTOP", "").split(":")[0]
+291 -41
View File
@@ -10,7 +10,9 @@ import datetime
import hashlib
import os
import re
import shlex
import shutil
import signal
import subprocess
import tempfile
from dataclasses import dataclass, field
@@ -19,7 +21,11 @@ from pathlib import Path, PurePosixPath
from typing import Callable, Optional
from ConfigMerge import ApplySet, MergeConfig, MergeResult
from Core import BackupsDir, ChildEnvironment, DownloadsDir, LoadConfig, PristineDir
import Services
from Core import (
AutostartDir, BackupsDir, ChildEnvironment, DownloadsDir, HasSystemd, Home, IsLinux, IsMac, 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,9 +47,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(),
)
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()
@@ -53,6 +68,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)
@@ -60,7 +77,12 @@ def _RemovePath(Target: Path) -> None:
if Target.is_symlink() or Target.is_file():
Target.unlink()
elif Target.is_dir():
shutil.rmtree(Target)
def ClearReadOnly(Function, FailedPath, _Error):
# Windows refuses to delete read-only files (e.g. in .git folders).
os.chmod(FailedPath, 0o700)
Function(FailedPath)
shutil.rmtree(Target, onexc=ClearReadOnly)
def _BackupName(Target: str) -> str:
@@ -68,18 +90,71 @@ def _BackupName(Target: str) -> str:
return Target.strip("/").replace("/", "__")
def _CopyPath(Source: Path, Destination: Path) -> None:
"""A file, or a folder (a macOS .app bundle) with everything in it."""
_RemovePath(Destination)
if Source.is_dir() and not Source.is_symlink():
shutil.copytree(Source, Destination, symlinks=True)
else:
shutil.copy2(Source, Destination)
def _Displace(Program: Program, Target: Path, Kind: str, OldReceipt: Optional["Receipt"]) -> PlacedFile:
"""The receipt entry for a file SaraSuite is about to place. One that's
already there but wasn't placed by SaraSuite (e.g. a hand-made desktop
entry from development) is moved aside, not lost: it comes back when the
program is removed."""
Previous = OldReceipt.FindFile(str(Target)) if OldReceipt is not None else None
Placed = PlacedFile(str(Target), Kind, Displaced=Previous.Displaced if Previous else "")
if Previous is None and (Target.exists() or Target.is_symlink()):
Displaced = BackupsDir / Program.Name / "Displaced" / _BackupName(str(Target))
Displaced.parent.mkdir(parents=True, exist_ok=True)
_CopyPath(Target, Displaced)
Placed.Displaced = str(Displaced)
return Placed
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:
@@ -291,23 +366,37 @@ def _RequirementsText(Program: Program) -> str:
return "\n".join(Program.Requirements)
def _SystemPython(Program: Program, Log: LogFn) -> str:
if IsLinux:
Python = shutil.which("python3")
if Python is None:
raise InstallError(f"{Program.Name} needs python3, and it isn't installed (sudo dnf install python3)")
return Python
if IsMac:
import MacSupport as Support
else:
import WindowsSupport as Support
Python = Support.FindPython() or Support.InstallPython(Log)
if Python is None:
raise InstallError(f"{Program.Name} needs Python, and it couldn't be installed. Get it from https://www.python.org/downloads/")
return Python
def _SetUpVenv(Program: Program, OldHash: str, Log: LogFn) -> str:
"""(Re)install requirements only when they changed. Returns the new hash."""
if not Program.UsesPython:
return ""
Venv = Program.ProgramDir / ".venv"
NewHash = hashlib.sha256(_RequirementsText(Program).encode()).hexdigest()
if (Venv / "bin" / "python").exists() and NewHash == OldHash:
if Program.Python.exists() and NewHash == OldHash:
Log("Python requirements unchanged, skipping pip")
return NewHash
if not (Venv / "bin" / "python").exists():
# Always the system python: inside the AppImage sys.executable is the
# AppImage itself, and the venv has to outlive SaraSuite anyway.
Python = shutil.which("python3")
if Python is None:
raise InstallError(f"{Program.Name} needs python3, and it isn't installed (sudo dnf install python3)")
_Run([Python, "-m", "venv", str(Venv)], Log)
Pip = [str(Venv / "bin" / "python"), "-m", "pip", "install", "--disable-pip-version-check"]
if not Program.Python.exists():
# Always the system python: inside the AppImage/.exe sys.executable is
# SaraSuite itself, and the venv has to outlive SaraSuite anyway.
_Run([_SystemPython(Program, Log), "-m", "venv", str(Venv)], Log)
Pip = [str(Program.Python), "-m", "pip", "install", "--disable-pip-version-check"]
if Program.RequirementsFile:
_Run([*Pip, "-r", str(Program.AppDir / Program.RequirementsFile)], Log)
else:
@@ -319,13 +408,13 @@ def _PutBackDisplaced(Placed: PlacedFile, Log: LogFn) -> None:
if not Placed.Displaced or not Path(Placed.Displaced).exists():
return
Current = Path(Placed.Path)
if Current.exists() and Current.read_bytes() == Path(Placed.Displaced).read_bytes():
if Current.is_file() and Current.read_bytes() == Path(Placed.Displaced).read_bytes():
pass # already identical (typical for a root file that was never removed)
elif Placed.Root:
_WriteRoot(Placed.Path, Path(Placed.Displaced).read_text(), Log)
else:
shutil.copy2(Placed.Displaced, Placed.Path)
Path(Placed.Displaced).unlink()
_CopyPath(Path(Placed.Displaced), Current)
_RemovePath(Path(Placed.Displaced))
Log(f"Restored your previous {Placed.Path}")
@@ -337,7 +426,7 @@ def _RemovePlacedTemplate(Placed: PlacedFile, Log: LogFn) -> None:
_RemoveRoot(Placed.Path, Log)
Log(f"Removed {Placed.Path}")
else:
Path(Placed.Path).unlink(missing_ok=True)
_RemovePath(Path(Placed.Path)) # a folder for a macOS app bundle
Log(f"Removed {Placed.Path}")
_PutBackDisplaced(Placed, Log)
@@ -356,20 +445,13 @@ def _ApplyTemplates(Program: Program, Context: dict, OldReceipt: Optional[Receip
for Template in Wanted:
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 "")
# 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.
if Previous is None and Path(Target).exists():
Displaced = BackupsDir / Program.Name / "Displaced" / _BackupName(Target)
Displaced.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(Target, Displaced)
Placed.Displaced = str(Displaced)
Placed = _Displace(Program, Path(Target), "Template", OldReceipt)
Placed.Root = Root = _NeedsRoot(Template, Target)
if Placed.Displaced and (OldReceipt is None or OldReceipt.FindFile(Target) is None):
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)
@@ -381,6 +463,16 @@ def _ApplyTemplates(Program: Program, Context: dict, OldReceipt: Optional[Receip
Log(f"Wrote {Target}")
def _RemoveStaleShortcuts(OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None:
"""Shortcuts the old install made that this one no longer wants (an
option was unticked, a service disabled, or the manifest dropped it)."""
if OldReceipt is None:
return
for Placed in OldReceipt.Files:
if Placed.Kind == "Shortcut" and NewReceipt.FindFile(Placed.Path) is None:
_RemovePlacedTemplate(Placed, Log)
def _RevertIni(Record: IniRecord, Log: LogFn) -> None:
FilePath = Path(Record.File)
Parser = _ReadIni(FilePath)
@@ -434,14 +526,138 @@ def _ApplyIniEdits(Program: Program, Context: dict, OldReceipt: Optional[Receipt
Log(f"Set {Key}={Value} in {File}")
def _ApplyServices(Program: Program, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None:
def _PlaceShortcut(Program: Program, Link: Path, Arguments: str, WorkingDir: Path, IconPng: Optional[Path],
Description: str, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None:
"""Windows: a .lnk running the program's pythonw.exe (in UTF-8 mode, which
every Sara program assumes) with Arguments. A link that was already there
and isn't ours is moved aside like a template, and comes back on removal."""
import WindowsSupport
Placed = _Displace(Program, Link, "Shortcut", OldReceipt)
Icon = WindowsSupport.MakeIcon(IconPng, Program.ProgramDir / f"{Link.stem}.ico") if IconPng and IconPng.is_file() else None
WindowsSupport.CreateShortcut(Link, Program.PythonW, f"-X utf8 {Arguments}", WorkingDir, Icon, Description)
NewReceipt.Files.append(Placed)
Log(f"Wrote {Link}")
def _PlaceAppBundle(Program: Program, Link, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt,
Log: LogFn) -> None:
"""macOS: ~/Applications/<Name>.app, running Script with the program's
Python from the app folder (MacSupport.CreateAppBundle)."""
import MacSupport
Bundle = MacSupport.ApplicationsDir / f"{Link.Name}.app"
Placed = _Displace(Program, Bundle, "Shortcut", OldReceipt)
Command = [str(Program.Python), str(Program.AppDir / Link.Script), *shlex.split(Render(Link.Arguments, Context))]
MacSupport.CreateAppBundle(
Bundle, Link.Name, f"srhtnin.{Link.Name}", Command, Program.AppDir,
Program.AppDir / Link.Icon if Link.Icon else None, Link.Description or Program.Description,
)
NewReceipt.Files.append(Placed)
Log(f"Wrote {Bundle}")
def _ApplyShortcuts(Program: Program, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None:
"""Start Menu shortcuts (Windows) and app bundles (macOS), from
[[Shortcuts]]; the ones starting services at login are placed by
_ApplyServices."""
if IsLinux:
return
for Link in Program.Shortcuts:
if not Program.OptionEnabled(Link.Option, NewReceipt.Options):
continue
if IsMac:
_PlaceAppBundle(Program, Link, Context, OldReceipt, NewReceipt, Log)
continue
Script = Program.AppDir / Link.Script
Arguments = f'"{Script}" {Render(Link.Arguments, Context)}'.strip()
_PlaceShortcut(
Program, StartMenuDir / f"{Link.Name}.lnk", Arguments, Program.AppDir,
Program.AppDir / Link.Icon if Link.Icon else None, Link.Description or Program.Description,
OldReceipt, NewReceipt, Log,
)
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"
Placed = _Displace(Program, Entry, "Shortcut", OldReceipt)
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 _PlaceLaunchAgent(Program: Program, Service, Command: tuple, OldReceipt: Optional[Receipt],
NewReceipt: Receipt, Log: LogFn) -> None:
"""macOS: the LaunchAgent that starts a service at login, recorded like
the autostart entry above."""
import MacSupport
Agent = MacSupport.LaunchAgentPath(Service.Name)
Placed = _Displace(Program, Agent, "Shortcut", OldReceipt)
Python, Runner, Script, WorkingDir = Command
Agent.parent.mkdir(parents=True, exist_ok=True)
MacSupport.LogsDir.mkdir(parents=True, exist_ok=True)
Agent.write_bytes(MacSupport.LaunchAgent(
Service.Name, Services.Command(Python, Runner, Service.Name, Script, WorkingDir), WorkingDir,
))
NewReceipt.Files.append(Placed)
Log(f"Wrote {Agent}")
def _ApplyStandInServices(Program: Program, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None:
"""Services SaraSuite runs itself (Windows; macOS; Linux without systemd):
at login from a Startup-folder shortcut / a LaunchAgent / 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:
Script = Program.AppDir / Service.Script
WorkingDir = Path(Render(Service.WorkingDir, Context)) if Service.WorkingDir else Program.AppDir
if Program.OptionEnabled(Service.Option, NewReceipt.Options):
NewReceipt.EnabledServices.append(Service.Id)
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,
)
elif IsMac:
_PlaceLaunchAgent(Program, Service, (Program.Python, Runner, Script, WorkingDir), 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:
Services.Stop(Service.Name, Log)
def _StopWindowsServices(Program: Program, Receipt: Optional[Receipt], Log: LogFn) -> None:
"""Windows locks a running program's files, so its services stop before
its files or venv are replaced or removed."""
if not IsWindows or Receipt is None:
return
for Name in Receipt.EnabledServices:
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 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()
for Service in Program.Services:
if Program.OptionEnabled(Service.Option, NewReceipt.Options):
NewReceipt.EnabledServices.append(Service.Unit)
NewReceipt.EnabledServices.append(Service.Id)
if Service.Unit in WasEnabled:
_Systemctl(Log, "try-restart", Service.Unit)
else:
@@ -450,12 +666,33 @@ 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 not IsLinux:
return # Start Menu shortcuts and app bundles need no refresh
Applications = Path(Program.Context()["ApplicationsDir"])
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(
@@ -508,9 +745,11 @@ def Apply(
try:
Program.ProgramDir.mkdir(parents=True, exist_ok=True)
_StopWindowsServices(Program, OldReceipt, Log)
_CopyProgramFiles(Program, SourceDir, Log)
NewReceipt.RequirementsHash = _SetUpVenv(Program, OldReceipt.RequirementsHash if OldReceipt else "", Log)
_ApplyTemplates(Program, Context, OldReceipt, NewReceipt, Log)
_ApplyShortcuts(Program, Context, OldReceipt, NewReceipt, Log)
_ApplyConfigs(Program, ConfigPlans, NewReceipt, Log)
# .new files from an earlier update that are still around stay tracked.
if OldReceipt is not None:
@@ -518,7 +757,8 @@ def Apply(
if Placed.Kind == "ConfigNew" and Path(Placed.Path).exists() and NewReceipt.FindFile(Placed.Path) is None:
NewReceipt.Files.append(Placed)
_ApplyIniEdits(Program, Context, OldReceipt, NewReceipt, Log)
_ApplyServices(Program, OldReceipt, NewReceipt, Log)
_ApplyServices(Program, OldReceipt, NewReceipt, Log, Context)
_RemoveStaleShortcuts(OldReceipt, NewReceipt, Log)
_AfterIntegrationChanges(Program, Log)
NewReceipt.Complete = True
ClearDownloads(Program.Name)
@@ -581,10 +821,14 @@ 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 "background service, stopped and its LaunchAgent removed" if IsMac
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", "systemd user service, stopped and disabled"))
Plan.Items.append(RemovalItem(Unit, "Integration", ServiceNote))
for Placed in Current.Files:
if Placed.Kind == "Template":
if Placed.Kind in ("Template", "Shortcut"):
Note = "put back to your previous version" if Placed.Displaced else ""
if Placed.Root:
Note = ", ".join(Part for Part in ("needs your password", Note) if Part)
@@ -625,14 +869,20 @@ def Remove(Program: Program, RemoveConfigs: bool, RemoveRuntime: bool, Log: LogF
Current = LoadReceipt(Program.Name)
if Current is not None:
for Unit in Current.EnabledServices:
_Systemctl(Log, "disable", "--now", Unit)
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)
for Placed in Current.Files:
if Placed.Kind == "Template":
if Placed.Kind in ("Template", "Shortcut"):
_RemovePlacedTemplate(Placed, Log)
for Record in Current.IniEdits:
_RevertIni(Record, Log)
if Current.EnabledServices or any("/systemd/" in Placed.Path for Placed in Current.Files):
if HasSystemd and (Current.EnabledServices or any("/systemd/" in Placed.Path for Placed in Current.Files)):
_Systemctl(Log, "daemon-reload")
Groups = {"Program"}
+257
View File
@@ -0,0 +1,257 @@
"""macOS stand-ins for what SaraSuite uses on Linux:
Linux macOS
.desktop entry a small app bundle in ~/Applications (<Name>.app)
systemd --user service a LaunchAgent (~/Library/LaunchAgents) that starts it
at login, through Programs/ServiceRun.py (Services.py)
system python3 a standalone Python installed by uv into SaraSuite's
own folder (no admin, no Homebrew, no Xcode tools)
fc-list the font files' own names
xdg-open open
Only used on macOS, but nothing in here needs macOS to import.
"""
import plistlib
import shlex
import shutil
import subprocess
import tarfile
import tempfile
import urllib.request
from pathlib import Path
from typing import Callable, Optional
from Core import CacheHome, ChildEnvironment, DataDir, Home
LogFn = Callable[[str], None]
PythonVersion = "3.14" # what uv installs
# Apple Silicon only. uv's own release file, and so the Python it installs.
UvTarget = "aarch64-apple-darwin"
UvUrl = f"https://github.com/astral-sh/uv/releases/latest/download/uv-{UvTarget}.tar.gz"
ToolsDir = DataDir / "Tools" # uv itself
PythonsDir = DataDir / "Python" # the Pythons it installs (program venvs are built from these)
ApplicationsDir = Home / "Applications"
LaunchAgentsDir = Home / "Library" / "LaunchAgents"
LogsDir = Home / "Library" / "Logs"
# --- Python -------------------------------------------------------------------------------
def _UvEnvironment() -> dict[str, str]:
Environment = ChildEnvironment()
Environment.update(
UV_PYTHON_INSTALL_DIR=str(PythonsDir),
UV_PYTHON_PREFERENCE="only-managed", # never the Xcode or Homebrew one
UV_CACHE_DIR=str(CacheHome / "SaraSuite" / "uv"),
UV_NO_MODIFY_PATH="1",
)
return Environment
def _Uv() -> Path:
return ToolsDir / "uv"
def FindPython() -> Optional[str]:
"""The Python uv installed for SaraSuite earlier, if any."""
if not _Uv().is_file():
return None
try:
Result = subprocess.run(
[str(_Uv()), "python", "find", PythonVersion], capture_output=True, text=True, timeout=60,
env=_UvEnvironment(),
)
except (OSError, subprocess.SubprocessError):
return None
Found = Result.stdout.strip()
return Found if Result.returncode == 0 and Found and Path(Found).is_file() else None
def _Stream(Command: list[str], Log: LogFn) -> int:
Log(f"$ {' '.join(Command)}")
Process = subprocess.Popen(Command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
errors="replace", env=_UvEnvironment())
for Line in Process.stdout:
if Line.strip():
Log(f" {Line.rstrip()}")
return Process.wait()
def _InstallUv(Log: LogFn) -> bool:
Log(f"Downloading uv ({UvUrl})")
try:
with urllib.request.urlopen(UvUrl, timeout=120) as Response, tempfile.TemporaryDirectory() as Temporary:
Archive = Path(Temporary) / "uv.tar.gz"
Archive.write_bytes(Response.read())
with tarfile.open(Archive) as Tar:
Member = next((Item for Item in Tar.getmembers() if Item.isfile() and Path(Item.name).name == "uv"), None)
if Member is None:
Log("The uv download has no uv in it.")
return False
Tar.extract(Member, Temporary, filter="data")
ToolsDir.mkdir(parents=True, exist_ok=True)
shutil.move(Path(Temporary) / Member.name, _Uv())
_Uv().chmod(0o755)
return True
except (OSError, tarfile.TarError) as Error:
Log(f"Couldn't download uv: {Error}")
return False
def InstallPython(Log: LogFn) -> Optional[str]:
"""Download uv, and with it a standalone Python, into SaraSuite's own
folder. Nothing is installed system-wide and no password is asked."""
Log(f"Python not found; installing Python {PythonVersion} with uv (just for SaraSuite, no admin)")
if not _Uv().is_file() and not _InstallUv(Log):
return None
# --no-bin: no python3.14 command in ~/.local/bin; only SaraSuite uses this one.
if _Stream([str(_Uv()), "python", "install", "--no-bin", PythonVersion], Log) != 0:
return None
return FindPython()
# --- App bundles and icons -------------------------------------------------------------------
def MakeIcns(Png: Path, Icns: Path) -> Optional[Path]:
"""An .icns from one of the Sara icons (32x32 pixel art stored at 10x),
each size a whole multiple of the 32x32 grid (nearest-neighbour), so
the art stays crisp. Needs Pillow; None without it."""
try:
from PIL import Image
Grid = Image.open(Png).convert("RGBA").resize((32, 32), Image.NEAREST)
Sizes = [Grid.resize((Size, Size), Image.NEAREST) for Size in (16, 32, 64, 128, 256, 512, 1024)]
Icns.parent.mkdir(parents=True, exist_ok=True)
Sizes[-1].save(Icns, format="ICNS", append_images=Sizes[:-1])
return Icns
except Exception:
return None
def CreateAppBundle(
Bundle: Path, Name: str, Identifier: str, Command: list[str], WorkingDir: Path,
IconPng: Optional[Path] = None, Description: str = "",
) -> None:
"""~/Applications/<Name>.app: Launchpad, Spotlight and the Finder list it
like any app. Its executable is a shell script that runs Command from
WorkingDir. Written fresh each time (the old bundle is replaced)."""
if Bundle.exists():
shutil.rmtree(Bundle)
Contents = Bundle / "Contents"
(Contents / "MacOS").mkdir(parents=True)
(Contents / "Resources").mkdir()
Executable = Contents / "MacOS" / Name
Executable.write_text(
"#!/bin/sh\n"
"# Written by SaraSuite; replaced on every update.\n"
f"cd {shlex.quote(str(WorkingDir))} || exit 1\n"
f'exec {" ".join(shlex.quote(Part) for Part in Command)} "$@"\n'
)
Executable.chmod(0o755)
Info = {
"CFBundleName": Name,
"CFBundleDisplayName": Name,
"CFBundleIdentifier": Identifier,
"CFBundleExecutable": Name,
"CFBundlePackageType": "APPL",
"CFBundleInfoDictionaryVersion": "6.0",
"CFBundleShortVersionString": "1.0",
"LSMinimumSystemVersion": "11.0",
"NSHighResolutionCapable": True,
"NSHumanReadableCopyright": Description,
}
if IconPng is not None and MakeIcns(IconPng, Contents / "Resources" / f"{Name}.icns") is not None:
Info["CFBundleIconFile"] = f"{Name}.icns"
with (Contents / "Info.plist").open("wb") as File:
plistlib.dump(Info, File)
# The Finder and Launchpad cache bundles; tell them this one changed.
Register = Path("/System/Library/Frameworks/CoreServices.framework/Frameworks/"
"LaunchServices.framework/Support/lsregister")
if Register.is_file():
subprocess.run([str(Register), "-f", str(Bundle)], capture_output=True, timeout=30)
# --- Services ----------------------------------------------------------------------------------
def LaunchAgentPath(Name: str) -> Path:
return LaunchAgentsDir / f"srhtnin.{Name}.plist"
def LaunchAgent(Name: str, Command: list[str], WorkingDir: Path) -> bytes:
"""The LaunchAgent that starts a service at login (macOS's equivalent of
the Startup folder / an XDG autostart entry). Only that: ServiceRun.py
keeps it running and logs it, like on the other systems, so SaraSuite
and SaraSort can start and stop it the same way everywhere."""
return plistlib.dumps({
"Label": f"srhtnin.{Name}",
"ProgramArguments": Command,
"WorkingDirectory": str(WorkingDir),
"RunAtLoad": True,
"ProcessType": "Background",
# Only what fails before ServiceRun.py has its own log (e.g. a missing venv).
"StandardErrorPath": str(LogsDir / f"{Name}.launchd.log"),
})
# --- Fonts and files ----------------------------------------------------------------------------
def _FontFamilies(FilePath: Path) -> set[str]:
"""Family names in one font file's name table (every font of a .ttc).
The typographic family (name ID 16) where there is one, else ID 1."""
import struct
Families: set[str] = set()
try:
Data = FilePath.read_bytes()
except OSError:
return Families
Offsets = [0]
if Data[:4] == b"ttcf":
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 FontFamilies() -> set[str]:
"""Installed font families: the system's, the shared and your own Fonts folders."""
Families: set[str] = set()
for Folder in (Path("/System/Library/Fonts"), Path("/Library/Fonts"), Home / "Library" / "Fonts"):
if Folder.is_dir():
for FilePath in Folder.rglob("*"):
if FilePath.suffix.lower() in (".ttf", ".otf", ".ttc"):
Families |= _FontFamilies(FilePath)
return Families
def OpenPath(Target: Path) -> None:
subprocess.Popen(["open", str(Target)], env=ChildEnvironment(), start_new_session=True)
+62 -12
View File
@@ -6,6 +6,7 @@ import os
import shutil
import sys
import traceback
from pathlib import Path
from typing import Callable
import flet as ft
@@ -13,7 +14,7 @@ import flet as ft
import SelfUpdate
from Catalog import UpdateCatalog
from Core import (
AppName, AppVersion, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataDir,
AppName, AppVersion, IsLinux, IsMac, IsWindows, StartMenuDir, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataDir,
BundledManifestsDir, DataHome, EffectiveSettings, LoadConfig, Palette,
)
from Dialogs import ShowInstallDialog, ShowOptionsDialog, ShowRemoveDialog, ShowUpdateDialog
@@ -24,10 +25,35 @@ from Settings import ShowSettingsDialog
_MaxLogLines = 2000
# When running as an AppImage, SaraSuite can put itself in the app menu.
_AppImage = os.environ.get("APPIMAGE", "")
_MenuEntry = DataHome / "applications" / "SaraSuite.desktop"
_MenuIcon = DataDir / "SaraSuite.png"
# When running packaged (AppImage / .exe), SaraSuite can put itself in the
# app menu: a desktop entry on Linux, a Start Menu shortcut on Windows. On
# macOS SaraSuite.app already is an app wherever it's put (e.g. /Applications).
_Packaged = SelfUpdate.RunningAppImage()
_AppImage = str(_Packaged) if _Packaged else ""
if IsWindows:
_MenuEntry = StartMenuDir / "SaraSuite.lnk"
_MenuIcon = DataDir / "SaraSuite.ico"
else:
_MenuEntry = DataHome / "applications" / "SaraSuite.desktop"
_MenuIcon = DataDir / "SaraSuite.png"
_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 not IsLinux 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():
@@ -44,6 +70,12 @@ def _SetMenuEntry(Enabled: bool) -> None:
_MenuEntry.unlink(missing_ok=True)
_MenuIcon.unlink(missing_ok=True)
return
if IsWindows:
import WindowsSupport
Icon = WindowsSupport.MakeIcon(AssetsDir / "Icon.png", _MenuIcon)
WindowsSupport.CreateShortcut(_MenuEntry, Path(_AppImage), Icon=Icon, Description="Install, update and remove Sara programs")
return
_MenuIcon.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(AssetsDir / "Icon.png", _MenuIcon)
_MenuEntry.parent.mkdir(parents=True, exist_ok=True)
@@ -70,6 +102,13 @@ def Main(Page: ft.Page) -> None:
Page.bgcolor = Palette.Base
Page.window.width = 960
Page.window.height = 720
if IsWindows:
# The taskbar/title bar icon (on Linux it comes from the desktop entry).
import WindowsSupport
Icon = WindowsSupport.MakeIcon(AssetsDir / "Icon.png", DataDir / "SaraSuite.ico")
if Icon is not None:
Page.window.icon = str(Icon)
LogView = ft.ListView(expand=True, auto_scroll=True, spacing=0, padding=8)
@@ -242,13 +281,13 @@ def Main(Page: ft.Page) -> None:
def ToggleMenuEntry(e) -> None:
_SetMenuEntry(not _MenuEntry.exists())
MenuButton.content = "Remove from app menu" if _MenuEntry.exists() else "Add to app menu"
MenuButton.content = f"Remove from {_MenuLabel}" if _MenuEntry.exists() else f"Add to {_MenuLabel}"
MenuButton.update()
MenuButton = ft.OutlinedButton(
content="Remove from app menu" if _MenuEntry.exists() else "Add to app menu",
content=f"Remove from {_MenuLabel}" if _MenuEntry.exists() else f"Add to {_MenuLabel}",
style=ChipButtonStyle(),
visible=bool(_AppImage),
visible=bool(_AppImage) and not IsMac,
on_click=ToggleMenuEntry,
)
@@ -303,12 +342,23 @@ 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
# command-line mode's output to the console it was started from.
import ctypes
if ctypes.windll.kernel32.AttachConsole(-1):
sys.stdout = open("CONOUT$", "w", encoding="utf-8", buffering=1)
sys.stderr = sys.stdout
from Cli import Run
sys.exit(Run(sys.argv[1:]))
# A Flet client copy with the window app ID srhtnin.SaraSuite, so the
# desktop matches the window to SaraSuite.desktop (StartupWMClass) instead
# of showing it as "com.appveyor.flet". See Programs/FletIdentity.py.
os.environ.setdefault("FLET_VIEW_PATH", _LoadFletIdentity().ClientFor(f"srhtnin.{AppName}"))
SelfUpdate.CleanUpOldExe()
if IsLinux:
# A Flet client copy with the window app ID srhtnin.SaraSuite, so the
# desktop matches the window to SaraSuite.desktop (StartupWMClass) instead
# of showing it as "com.appveyor.flet". See Programs/FletIdentity.py.
os.environ.setdefault("FLET_VIEW_PATH", _LoadFletIdentity().ClientFor(f"srhtnin.{AppName}"))
ft.run(Main, assets_dir=str(AssetsDir))
+115 -21
View File
@@ -1,5 +1,11 @@
"""Program manifests: Programs/<Name>/Manifest.toml, plus optional Templates/
and Hooks.py next to it. See README.md for the full manifest format."""
and Hooks.py next to it. See README.md for the full manifest format.
A manifest's Platforms (default ["Linux"]) says where the program runs; each
SaraSuite only lists the programs for its own system. Any list item can also
carry Platform = "Linux", "Windows" or "macOS" to only apply there (e.g.
desktop entries on Linux, Start Menu shortcuts on Windows, app bundles on
macOS); others are dropped at load."""
import importlib.util
import re
@@ -11,8 +17,8 @@ from typing import Optional
import tomlkit
from Core import (
CacheHome, ConfigHome, CurrentDesktop, DataHome, Home, LoadConfig, ProgramsDir,
SharedConfigDir,
AutostartDir, CacheHome, ConfigHome, CurrentDesktop, DataHome, HasSystemd, Home, IsLinux, IsMac, IsWindows, LoadConfig,
Platform, ProgramsDir, SharedConfigDir, StartMenuDir, StartupDir, UserPortalsSupported,
)
# Never worth copying out of a source checkout, whatever Include says.
@@ -50,7 +56,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
@@ -78,8 +86,44 @@ class IniEdit:
@dataclass
class Service:
Unit: str # a systemd --user unit, placed by a Template
"""A background service. Linux with systemd: a --user Unit (placed by a
Template). Windows, macOS, 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 / a LaunchAgent / 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 = "" # 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:
"""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
class Shortcut:
"""A launcher in the system's app list running Script (relative to the
app folder) with the program's Python, from the app folder, with Icon (a
PNG in the app folder):
- Windows: a Start Menu shortcut (pythonw.exe; the icon becomes an .ico);
- macOS: an app bundle, ~/Applications/<Name>.app (the icon becomes an .icns).
Linux uses a desktop entry Template instead."""
Name: str
Script: str
Arguments: str = ""
Icon: str = ""
Description: str = ""
Option: str = ""
@dataclass
@@ -119,6 +163,8 @@ class Program:
Services: list[Service] = field(default_factory=list)
RestartUnits: list[str] = field(default_factory=list)
Leftovers: list[Leftover] = field(default_factory=list)
Shortcuts: list[Shortcut] = field(default_factory=list)
Platforms: list[str] = field(default_factory=lambda: ["Linux"])
Hooks: Optional[ModuleType] = None
@property
@@ -132,9 +178,13 @@ class Program:
@property
def IconPath(self) -> Optional[Path]:
"""The installed copy's own icon if it's there (always current; the
Icon= line of the program's desktop entry template says where), otherwise
the copy bundled next to the manifest (Programs/<Name>/Icon.png), so the
card has an icon before installing too."""
Icon= line of the program's desktop entry template, or its Windows
Shortcut's Icon, says where), otherwise the copy bundled next to the
manifest (Programs/<Name>/Icon.png), so the card has an icon before
installing too."""
for Link in self.Shortcuts:
if Link.Icon and (self.AppDir / Link.Icon).is_file():
return self.AppDir / Link.Icon
for Template in self.Templates:
if Template.Target.endswith(".desktop"):
for Line in (self.Folder / "Templates" / Template.Source).read_text().splitlines():
@@ -153,6 +203,16 @@ class Program:
def GitUrl(self) -> str:
return Render(self.Git, self.Context())
@property
def Python(self) -> Path:
Venv = self.ProgramDir / ".venv"
return Venv / "Scripts" / "python.exe" if IsWindows else Venv / "bin" / "python"
@property
def PythonW(self) -> Path:
"""Windows: the venv's console-less python, for GUI programs and services."""
return self.Python.with_name("pythonw.exe") if IsWindows else self.Python
@property
def UsesPython(self) -> bool:
return bool(self.Requirements or self.RequirementsFile)
@@ -170,16 +230,34 @@ class Program:
"ConfigHome": str(ConfigHome),
"DataHome": str(DataHome),
"CacheHome": str(CacheHome),
"SystemdUserDir": str(ConfigHome / "systemd" / "user"),
"ApplicationsDir": str(DataHome / "applications"),
"DBusServicesDir": str(DataHome / "dbus-1" / "services"),
"ProgramDir": str(self.ProgramDir),
"AppDir": str(self.AppDir),
"Python": str(self.ProgramDir / ".venv" / "bin" / "python"),
"Python": str(self.Python),
"PythonW": str(self.PythonW),
"ConfigDir": str(self.ConfigDir),
"DesktopName": Desktop,
"Desktop": Desktop.lower(),
}
if IsWindows:
Context.update(StartMenuDir=str(StartMenuDir), StartupDir=str(StartupDir))
elif IsMac:
Context.update(ApplicationsDir=str(Home / "Applications"),
LaunchAgentsDir=str(Home / "Library" / "LaunchAgents"))
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
@@ -200,8 +278,18 @@ def _LoadHooks(Folder: Path, Name: str) -> Optional[ModuleType]:
return Module
def _ForPlatform(Items: list[dict]) -> list[dict]:
"""Drop the items meant for the other system (their Platform key)."""
Kept = []
for Item in Items:
Item = dict(Item)
if Item.pop("Platform", Platform) == Platform:
Kept.append(Item)
return Kept
def LoadProgram(Folder: Path) -> Program:
Data = tomlkit.parse((Folder / "Manifest.toml").read_text()).unwrap()
Data = tomlkit.parse((Folder / "Manifest.toml").read_text(encoding="utf-8")).unwrap()
Source = Data.get("Source", {})
Version = Data.get("Version", {})
Python = Data.get("Python", {})
@@ -218,14 +306,18 @@ def LoadProgram(Folder: Path) -> Program:
VersionPattern=Version.get("Pattern", ""),
Requirements=Python.get("Requirements", []),
RequirementsFile=Python.get("RequirementsFile", ""),
Options=[Option(**Item) for Item in Data.get("Options", [])],
Questions=[Question(**Item) for Item in Data.get("Questions", [])],
Templates=[TemplateFile(**Item) for Item in Data.get("Templates", [])],
Configs=[ConfigFile(**Item) for Item in Data.get("Configs", [])],
IniEdits=[IniEdit(**Item) for Item in Data.get("IniEdits", [])],
Services=[Service(**Item) for Item in Data.get("Services", [])],
RestartUnits=Data.get("RestartUnits", []),
Leftovers=[Leftover(**Item) for Item in Data.get("Leftovers", [])],
Options=[Option(**Item) for Item in _ForPlatform(Data.get("Options", []))],
Questions=[Question(**Item) for Item in _ForPlatform(Data.get("Questions", []))],
# 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", []))],
RestartUnits=Data.get("RestartUnits", []) if IsLinux else [],
Leftovers=[Leftover(**Item) for Item in _ForPlatform(Data.get("Leftovers", []))],
Shortcuts=[Shortcut(**Item) for Item in _ForPlatform(Data.get("Shortcuts", []))],
Platforms=Data.get("Platforms", ["Linux"]),
Hooks=_LoadHooks(Folder, Data["Name"]),
)
@@ -246,6 +338,8 @@ def LoadPrograms() -> list[Program]:
Loaded = _LoadFrom(ManifestsDir())
except Exception:
Loaded = _LoadFrom(BundledManifestsDir)
# Only the programs that run on this system (the .exe lists Windows ones, the Mac app macOS ones).
Loaded = [Current for Current in Loaded if Platform in Current.Platforms]
Names = {Current.Name for Current in Loaded}
Ordered = []
for Current in Loaded:
+26 -7
View File
@@ -1,18 +1,37 @@
#!/bin/bash
# Builds dist/SaraSuite-x86_64.AppImage: a PyInstaller one-file build (with
# the program manifests, fonts and icon bundled) wrapped by appimagetool,
# the same way SaraFem's AppImage is made. Needs appimagetool on PATH.
# the same way SaraFem's AppImage is made. Needs podman and appimagetool
# on PATH.
set -euo pipefail
cd "$(dirname "$(readlink -f "$0")")/.."
.venv/bin/pip install --quiet --disable-pip-version-check pyinstaller
find Programs -name __pycache__ -type d -prune -exec rm -rf {} +
.venv/bin/pyinstaller --noconfirm --clean --log-level WARN \
--onefile --windowed --name SaraSuite --collect-data flet \
--add-data "$PWD/assets:assets" --add-data "$PWD/Programs:Programs" \
--distpath build/PyInstaller/dist --workpath build/PyInstaller/work --specpath build/PyInstaller \
"$PWD/Main.py"
# PyInstaller runs in a manylinux_2_28 container (glibc 2.28) with uv's
# 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.
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 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"'
AppDir=build/AppDir
rm -rf "$AppDir"
+48
View File
@@ -0,0 +1,48 @@
#!/bin/bash
# Builds dist/SaraSuite.exe, the Windows version: the same PyInstaller
# one-file recipe as the AppImage, run with the Windows Python inside a Wine
# prefix (build/WinBuild/Prefix; PyInstaller can't build for Windows from
# Linux directly). The first run sets that prefix up: it downloads the
# official Windows Python and installs the build requirements into it.
set -euo pipefail
cd "$(dirname "$(readlink -f "$0")")/.."
PythonVersion=3.14.8
export WINEPREFIX="$PWD/build/WinBuild/Prefix" WINEDEBUG=-all WINEARCH=win64
WinPython='C:\Python314\python.exe'
if [ ! -f "$WINEPREFIX/drive_c/Python314/python.exe" ]; then
echo "== Setting up the Windows build environment (one-time)"
mkdir -p build/WinBuild
Installer="build/WinBuild/python-$PythonVersion-amd64.exe"
[ -f "$Installer" ] || curl -sfL -o "$Installer" "https://www.python.org/ftp/python/$PythonVersion/python-$PythonVersion-amd64.exe"
wineboot -i >/dev/null 2>&1 || true
wine "$Installer" /quiet InstallAllUsers=0 PrependPath=1 Include_test=0 TargetDir='C:\Python314'
fi
wine "$WinPython" -m pip install --quiet --disable-pip-version-check -r requirements.txt pyinstaller pylnk3 pillow
find Programs -name __pycache__ -type d -prune -exec rm -rf {} +
Win() { winepath -w "$1"; }
# The window/taskbar/Explorer icon, from the same pixel art at whole multiples of its grid.
wine "$WinPython" -c "
from PIL import Image
Grid = Image.open(r'$(Win assets/Icon.png)').convert('RGBA').resize((32, 32), Image.NEAREST)
Grid.resize((256, 256), Image.NEAREST).save(r'$(Win build/WinBuild/Icon.ico)', sizes=[(S, S) for S in (256, 128, 64, 32, 16)])
"
# --python-option "X utf8": Python's UTF-8 mode, since on Windows it would
# otherwise read and write every TOML file in the old ANSI code page.
wine "$WinPython" -m PyInstaller --noconfirm --clean --log-level WARN \
--onefile --windowed --name SaraSuite --collect-data flet \
--python-option "X utf8" \
--icon "$(Win build/WinBuild/Icon.ico)" \
--add-data "$(Win assets);assets" --add-data "$(Win Programs);Programs" \
--distpath "$(Win build/WinBuild/dist)" --workpath "$(Win build/WinBuild/work)" --specpath "$(Win build/WinBuild)" \
"$(Win Main.py)"
mkdir -p dist
# Renamed into place, like the AppImage, in case one is running from dist/.
cp build/WinBuild/dist/SaraSuite.exe dist/SaraSuite.exe.new
mv -f dist/SaraSuite.exe.new dist/SaraSuite.exe
echo "Built dist/SaraSuite.exe"
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""Build dist/SaraSuite-macOS.zip (SaraSuite.app, Apple Silicon) on Linux.
PyInstaller can only build a Mac app on a Mac, so this puts the app together
from parts that need no compiling:
- a standalone Python for macOS (python-build-standalone, the Pythons uv installs);
- SaraSuite's requirements as macOS wheels (pip download for the Mac platform);
- the Flet desktop client for macOS, bundled where flet-desktop looks for it,
so the first start needs no download;
- SaraSuite's own code, assets and Programs/;
- Contents/MacOS/SaraSuite, a shell script that runs Main.py with that Python.
The app isn't signed by an Apple developer account, so the first start needs
System Settings > Privacy & Security > Open Anyway (see the website's
instructions). Its launcher then clears the download quarantine from the
whole app, so the Python and Flet inside it run too.
Packaging/BuildMacApp.py # also run by Packaging/Release.py
"""
import io
import json
import os
import plistlib
import re
import shutil
import stat
import subprocess
import sys
import tarfile
import time
import urllib.request
import zipfile
from pathlib import Path
RepoDir = Path(__file__).resolve().parent.parent
BuildDir = RepoDir / "build" / "MacBuild"
DownloadsDir = BuildDir / "Downloads" # kept between builds
BuildVenv = BuildDir / "Venv" # Pillow, for the .icns
Output = RepoDir / "dist" / "SaraSuite-macOS.zip"
PythonVersion = "3.14"
MacPlatform = "macosx_11_0_arm64"
PythonTarget = "aarch64-apple-darwin"
StandaloneReleases = "https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest"
AppIdentifier = "srhtnin.SaraSuite"
UserAgent = "SaraSuite-Build (Python)"
# The launcher won't run from the read-only copy macOS makes of a quarantined
# app that wasn't moved out of Downloads (App Translocation): neither clearing
# the quarantine nor self-updating would work there.
Launcher = '''#!/bin/sh
# SaraSuite.app's launcher (written by Packaging/BuildMacApp.py).
Contents="$(cd "$(dirname "$0")/.." && pwd -P)"
App="$(dirname "$Contents")"
case "$App" in
*/AppTranslocation/*)
/usr/bin/osascript -e 'display alert "Move SaraSuite to Applications first" message "Drag SaraSuite from your Downloads folder into Applications, then open it from there."' >/dev/null 2>&1
exit 1 ;;
esac
# Downloaded apps are quarantined, the Python and Flet inside included. Once
# macOS has let SaraSuite itself open, the rest of it may run too.
/usr/bin/xattr -dr com.apple.quarantine "$App" 2>/dev/null
export SARASUITE_APP="$App"
exec "$Contents/Resources/Python/bin/python3" "$Contents/Resources/SaraSuite/Main.py" "$@"
'''
def Download(Url: str, Name: str) -> Path:
"""Into DownloadsDir, once."""
Target = DownloadsDir / Name
if Target.is_file():
return Target
print(f"Downloading {Url}")
DownloadsDir.mkdir(parents=True, exist_ok=True)
Request = urllib.request.Request(Url, headers={"User-Agent": UserAgent})
with urllib.request.urlopen(Request, timeout=600) as Response:
Partial = Target.with_suffix(".part")
Partial.write_bytes(Response.read())
Partial.replace(Target)
return Target
def StandalonePython() -> Path:
"""The latest python-build-standalone release of PythonVersion for Apple Silicon."""
Request = urllib.request.Request(StandaloneReleases, headers={"User-Agent": UserAgent})
with urllib.request.urlopen(Request, timeout=60) as Response:
Assets = json.load(Response)["assets"]
Pattern = re.compile(rf"cpython-{re.escape(PythonVersion)}\.\d+\+\d+-{PythonTarget}-install_only_stripped\.tar\.gz")
Asset = next((Item for Item in Assets if Pattern.fullmatch(Item["name"])), None)
if Asset is None:
sys.exit(f"BuildMacApp.py: no Python {PythonVersion} for {PythonTarget} in the latest python-build-standalone release")
return Download(Asset["browser_download_url"], Asset["name"])
def MakeIcns(Png: Path, Icns: Path) -> None:
"""Same as MacSupport.MakeIcns: whole multiples of the 32x32 art grid."""
from PIL import Image
Grid = Image.open(Png).convert("RGBA").resize((32, 32), Image.NEAREST)
Sizes = [Grid.resize((Size, Size), Image.NEAREST) for Size in (16, 32, 64, 128, 256, 512, 1024)]
Sizes[-1].save(Icns, format="ICNS", append_images=Sizes[:-1])
def CopyCode(Destination: Path) -> None:
Ignore = shutil.ignore_patterns("__pycache__", "*.pyc")
Destination.mkdir(parents=True)
for Source in sorted(RepoDir.glob("*.py")):
shutil.copy2(Source, Destination / Source.name)
shutil.copytree(RepoDir / "assets", Destination / "assets", ignore=Ignore)
shutil.copytree(RepoDir / "Programs", Destination / "Programs", ignore=Ignore)
def Zip(Bundle: Path, Target: Path) -> None:
"""Zip the app keeping what a Mac app needs: execute bits and symlinks
(Python's bin/python3 is one), the way Finder's Archive Utility and
ditto read them back."""
Target.parent.mkdir(parents=True, exist_ok=True)
Partial = Target.with_suffix(".zip.new")
with zipfile.ZipFile(Partial, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as Archive:
for Current in [Bundle, *sorted(Bundle.rglob("*"))]:
Name = Current.relative_to(Bundle.parent).as_posix()
Mode = Current.lstat().st_mode
Stamp = time.localtime(max(Current.lstat().st_mtime, 315532800))[:6] # zip dates start in 1980
if Current.is_symlink():
Info = zipfile.ZipInfo(Name, Stamp)
Info.create_system = 3 # Unix, so the mode below counts
Info.external_attr = (stat.S_IFLNK | 0o755) << 16
Archive.writestr(Info, os.readlink(Current))
elif Current.is_dir():
Info = zipfile.ZipInfo(Name + "/", Stamp)
Info.create_system = 3
Info.external_attr = (stat.S_IFDIR | 0o755) << 16 | 0x10
Archive.writestr(Info, b"")
else:
Info = zipfile.ZipInfo.from_file(Current, Name)
Info.create_system = 3
Info.external_attr = (stat.S_IFREG | (0o755 if Mode & 0o111 else 0o644)) << 16
Info.compress_type = zipfile.ZIP_DEFLATED
Archive.writestr(Info, Current.read_bytes())
Partial.replace(Target)
def Main() -> None:
try:
import PIL # noqa: F401
except ImportError:
# Pillow for the icon: run this script again from a build venv that has it.
if not (BuildVenv / "bin" / "python").exists():
subprocess.run([sys.executable, "-m", "venv", str(BuildVenv)], check=True)
subprocess.run([str(BuildVenv / "bin" / "python"), "-m", "pip", "install", "--quiet", "pillow"], check=True)
os.execv(BuildVenv / "bin" / "python", [str(BuildVenv / "bin" / "python"), __file__, *sys.argv[1:]])
Version = re.search(r'^AppVersion = "([^"]+)"', (RepoDir / "Core.py").read_text(), re.MULTILINE)[1]
Stage = BuildDir / "Stage"
shutil.rmtree(Stage, ignore_errors=True)
Bundle = Stage / "SaraSuite.app"
Contents = Bundle / "Contents"
Resources = Contents / "Resources"
(Contents / "MacOS").mkdir(parents=True)
Resources.mkdir()
# Python, unpacked as Resources/Python.
with tarfile.open(StandalonePython()) as Tar:
Tar.extractall(Resources, filter="tar")
(Resources / "python").rename(Resources / "Python")
SitePackages = next((Resources / "Python" / "lib").glob("python3.*")) / "site-packages"
Minor = SitePackages.parent.name.removeprefix("python")
# SaraSuite's requirements as macOS wheels, straight into that Python.
# (Pillow and flet-desktop are only for macOS, so they're named here: pip
# judges requirements.txt's markers by this Linux machine.)
Requirements = [Line for Line in (RepoDir / "requirements.txt").read_text().splitlines()
if Line.strip() and not Line.startswith("#") and "sys_platform" not in Line]
print("Installing the macOS wheels")
subprocess.run([
sys.executable, "-m", "pip", "install", "--quiet", "--disable-pip-version-check", "--no-compile",
"--target", str(SitePackages), "--platform", MacPlatform, "--python-version", Minor,
"--implementation", "cp", "--abi", f"cp{Minor.replace('.', '')}", "--only-binary=:all:",
*Requirements, "pillow", "flet-desktop",
], check=True)
FletVersion = re.search(r'version = "([^"]+)"', (SitePackages / "flet_desktop" / "version.py").read_text())[1]
Wanted = re.search(r"^Version: (.+)$", next(SitePackages.glob("flet-*.dist-info/METADATA")).read_text(), re.MULTILINE)[1]
if FletVersion != Wanted:
sys.exit(f"BuildMacApp.py: flet {Wanted} but flet-desktop {FletVersion}")
# The Flet client, where flet-desktop looks for a bundled one first.
Client = Download(f"https://github.com/flet-dev/flet/releases/download/v{FletVersion}/flet-macos.tar.gz",
f"flet-macos-{FletVersion}.tar.gz")
(SitePackages / "flet_desktop" / "app").mkdir(exist_ok=True)
shutil.copy2(Client, SitePackages / "flet_desktop" / "app" / "flet-macos.tar.gz")
CopyCode(Resources / "SaraSuite")
MakeIcns(RepoDir / "assets" / "Icon.png", Resources / "SaraSuite.icns")
Executable = Contents / "MacOS" / "SaraSuite"
Executable.write_text(Launcher)
Executable.chmod(0o755)
with (Contents / "Info.plist").open("wb") as File:
plistlib.dump({
"CFBundleName": "SaraSuite",
"CFBundleDisplayName": "SaraSuite",
"CFBundleIdentifier": AppIdentifier,
"CFBundleExecutable": "SaraSuite",
"CFBundleIconFile": "SaraSuite.icns",
"CFBundlePackageType": "APPL",
"CFBundleInfoDictionaryVersion": "6.0",
"CFBundleShortVersionString": Version,
"CFBundleVersion": Version,
"LSMinimumSystemVersion": "11.0",
"LSArchitecturePriority": ["arm64"],
"NSHighResolutionCapable": True,
"NSHumanReadableCopyright": "Install, update and remove Sara programs",
}, File)
Zip(Bundle, Output)
print(f"Built {Output.relative_to(RepoDir)} ({Output.stat().st_size // 1_000_000} MB)")
if __name__ == "__main__":
Main()
+27 -16
View File
@@ -1,7 +1,10 @@
#!/usr/bin/env python3
"""Publish a SaraSuite release: build the AppImage, create the Gitea release
v<AppVersion> (from Core.py) with it attached, and copy it to the Shared
folder. Running SaraSuites then offer to update themselves (SelfUpdate.py).
"""Publish a SaraSuite release: build the AppImage (Linux), SaraSuite.exe
(Windows, built under Wine by BuildExe.sh) and SaraSuite-macOS.zip (Apple
Silicon, put together by BuildMacApp.py), create the Gitea release
v<AppVersion> (from Core.py) with all three attached, and copy them to the
Shared folder. Running SaraSuites then offer to update themselves (SelfUpdate.py),
each to its own platform's file.
Packaging/Release.py # after bumping AppVersion in Core.py
Packaging/Release.py --notes "What changed"
@@ -25,7 +28,12 @@ from pathlib import Path
from urllib.parse import urlsplit
RepoDir = Path(__file__).resolve().parent.parent
AppImage = RepoDir / "dist" / "SaraSuite-x86_64.AppImage"
# Each running SaraSuite downloads the one for its own platform (SelfUpdate.AssetName).
Builds = [
("Packaging/BuildAppImage.sh", RepoDir / "dist" / "SaraSuite-x86_64.AppImage"),
("Packaging/BuildExe.sh", RepoDir / "dist" / "SaraSuite.exe"),
("Packaging/BuildMacApp.py", RepoDir / "dist" / "SaraSuite-macOS.zip"),
]
SharedDir = Path("/mnt/files/Shared/SaraSuite")
@@ -93,26 +101,29 @@ def Main() -> None:
Range = f"{Previous[0]}..HEAD" if Previous else "HEAD"
Notes = Git("log", "--format=- %s", Range) or "- First release with self-updates."
print(f"== Building SaraSuite {Version}")
subprocess.run(["Packaging/BuildAppImage.sh"], cwd=RepoDir, check=True,
env={**os.environ, "PATH": f"{Path.home()}/.local/bin:{os.environ['PATH']}"})
for Script, Output in Builds:
print(f"== Building {Output.name} {Version}")
subprocess.run([Script], cwd=RepoDir, check=True,
env={**os.environ, "PATH": f"{Path.home()}/.local/bin:{os.environ['PATH']}"})
print(f"== Creating release {Tag}")
Created = Api("POST", f"{ApiBase}/releases", TokenValue, json.dumps({
"tag_name": Tag, "target_commitish": Head, "name": f"SaraSuite {Version}", "body": Notes,
}).encode())
print(f"== Uploading {AppImage.name} ({AppImage.stat().st_size // 1_000_000} MB)")
Boundary = uuid.uuid4().hex
Body = (
f"--{Boundary}\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"{AppImage.name}\"\r\n"
f"Content-Type: application/octet-stream\r\n\r\n"
).encode() + AppImage.read_bytes() + f"\r\n--{Boundary}--\r\n".encode()
Api("POST", f"{ApiBase}/releases/{Created['id']}/assets?name={AppImage.name}", TokenValue, Body,
f"multipart/form-data; boundary={Boundary}")
for _Script, Output in Builds:
print(f"== Uploading {Output.name} ({Output.stat().st_size // 1_000_000} MB)")
Boundary = uuid.uuid4().hex
Body = (
f"--{Boundary}\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"{Output.name}\"\r\n"
f"Content-Type: application/octet-stream\r\n\r\n"
).encode() + Output.read_bytes() + f"\r\n--{Boundary}--\r\n".encode()
Api("POST", f"{ApiBase}/releases/{Created['id']}/assets?name={Output.name}", TokenValue, Body,
f"multipart/form-data; boundary={Boundary}")
if SharedDir.is_dir():
shutil.copy2(AppImage, SharedDir / AppImage.name)
for _Script, Output in Builds:
shutil.copy2(Output, SharedDir / Output.name)
print(f"== Copied to {SharedDir}")
Git("fetch", "--quiet", "--tags", "origin") # the new tag, for the next release's notes
print(f"== Released {Tag}: {Origin.scheme}://{Origin.netloc}/{Owner}/{Repo}/releases/tag/{Tag}")
+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)
+36 -6
View File
@@ -1,5 +1,6 @@
Name = "SaraFem"
Description = "Desktop file manager that understands SaraTag Books, and can stand in as the system file picker."
Description = "Desktop file manager that understands SaraTag Books, and (on Linux) can stand in as the system file picker."
Platforms = ["Linux", "Windows", "macOS"]
# The portal reads its descriptors and preferences once at startup.
RestartUnits = ["xdg-desktop-portal.service"]
@@ -12,61 +13,90 @@ Exclude = ["Packaging"]
[Python]
RequirementsFile = "requirements.txt"
# The file picker (xdg-desktop-portal) and default file manager (mimeapps.list)
# are Linux things; Windows has no equivalent SaraFem can plug into.
[[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]]
Platform = "Linux"
Id = "DefaultFileManager"
Label = "Make SaraFem the default file manager"
Default = false
# Linux: a launcher, a desktop entry, and the file picker's portal service.
[[Templates]]
Platform = "Linux"
Source = "Launch.sh"
Target = "{AppDir}/Packaging/Launch.sh" # where PortalService.py looks for it
Executable = true
[[Templates]]
Platform = "Linux"
Source = "SaraFem.desktop"
Target = "{ApplicationsDir}/SaraFem.desktop"
[[Templates]]
Platform = "Linux"
Source = "PortalService.service"
Target = "{DBusServicesDir}/org.freedesktop.impl.portal.desktop.SaraFem.service"
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]]
File = "{ConfigHome}/xdg-desktop-portal/{Desktop}-portals.conf"
Platform = "Linux"
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]]
Platform = "Linux"
File = "{ConfigHome}/mimeapps.list"
Section = "Default Applications"
Key = "inode/directory"
Value = "SaraFem.desktop"
Option = "DefaultFileManager"
# Windows: a Start Menu shortcut (pythonw.exe Main.py, from the app folder).
[[Shortcuts]]
Platform = "Windows"
Name = "SaraFem"
Script = "Main.py"
Icon = "assets/Icon.png"
# macOS: an app bundle, ~/Applications/SaraFem.app (python Main.py, from the app folder).
[[Shortcuts]]
Platform = "macOS"
Name = "SaraFem"
Script = "Main.py"
Icon = "assets/Icon.png"
[[Leftovers]]
Path = "{CacheHome}/SaraFem"
Kind = "Runtime"
Note = "thumbnail cache"
[[Leftovers]]
Platform = "Linux"
Path = "{ConfigHome}/SaraFem"
Kind = "Config"
Note = "old pre-SaraSuite config location"
# Shared with the other Flet programs; see Programs/FletIdentity.py.
[[Templates]]
Platform = "Linux"
Source = "../../FletIdentity.py"
Target = "{ProgramDir}/FletIdentity.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 "$@"
+19
View File
@@ -1,6 +1,7 @@
# Kept in sync between SaraSuite/Programs/SaraSort/ and SaraSort/Packaging/SaraSuite/.
Name = "SaraSort"
Parent = "SaraSortd" # a GUI for SaraSortd, listed under it
Platforms = ["Linux", "Windows", "macOS"]
Description = "A GUI for configuring SaraSortd: a flowchart of what goes where, its service and logs, configs and templates."
[Source]
@@ -11,15 +12,32 @@ Exclude = ["Packaging"]
[Python]
RequirementsFile = "requirements.txt"
# Linux: a launcher and a desktop entry.
[[Templates]]
Platform = "Linux"
Source = "Launch.sh"
Target = "{AppDir}/Packaging/Launch.sh"
Executable = true
[[Templates]]
Platform = "Linux"
Source = "SaraSort.desktop"
Target = "{ApplicationsDir}/SaraSort.desktop"
# Windows: a Start Menu shortcut (pythonw.exe Main.py, from the app folder).
[[Shortcuts]]
Platform = "Windows"
Name = "SaraSort"
Script = "Main.py"
Icon = "assets/Icon.png"
# macOS: an app bundle, ~/Applications/SaraSort.app (python Main.py, from the app folder).
[[Shortcuts]]
Platform = "macOS"
Name = "SaraSort"
Script = "Main.py"
Icon = "assets/Icon.png"
# Made by SaraSort itself: Config.toml, Layout.toml, Templates/.
[[Leftovers]]
Path = "{ConfigDir}"
@@ -28,5 +46,6 @@ Note = "SaraSort's settings, flowchart layout and your templates"
# Shared with the other Flet programs; see Programs/FletIdentity.py.
[[Templates]]
Platform = "Linux"
Source = "../../FletIdentity.py"
Target = "{ProgramDir}/FletIdentity.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 "$@"
+31 -1
View File
@@ -1,5 +1,6 @@
Name = "SaraSortd"
Description = "Sorts files from input folders into output folders by the rules in its configs. Runs in the background as a systemd user service."
Description = "Sorts files from input folders into output folders by the rules in its configs. Runs in the background."
Platforms = ["Linux", "Windows", "macOS"]
[Version]
File = "SaraSortd.py"
@@ -39,12 +40,41 @@ Target = "{ConfigDir}/InputDirConf.toml"
Source = "OutputDirConf.toml"
Target = "{ConfigDir}/OutputDirConf.toml"
# Linux with systemd: a systemd user service.
[[Templates]]
Platform = "Linux"
Source = "SaraSortd.service"
Target = "{SystemdUserDir}/SaraSortd.service"
# Windows, macOS, 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 / a LaunchAgent / 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"
[[Services]]
Platform = "Windows"
Name = "SaraSortd"
Script = "SaraSortd.py"
WorkingDir = "{ConfigDir}"
Option = "Autostart"
[[Services]]
Platform = "macOS"
Name = "SaraSortd"
Script = "SaraSortd.py"
WorkingDir = "{ConfigDir}"
Option = "Autostart"
# Everything SaraSortd makes inside the sorting folders is worked out from
+19
View File
@@ -1,5 +1,6 @@
# Kept in sync between SaraSuite/Programs/SaraTag/ and SaraTag/Packaging/SaraSuite/.
Name = "SaraTag"
Platforms = ["Linux", "Windows", "macOS"]
Description = "Tag files with Books (sidecars) defined by Lexicons: browse, filter and sort by tag, edit tags on many files at once."
[Version]
@@ -20,15 +21,32 @@ RequirementsFile = "requirements.txt"
Source = "Location.toml"
Target = "{AppDir}/.saratag/Location.toml" # tells .saratag/Core.py where the data lives
# Linux: a launcher and a desktop entry.
[[Templates]]
Platform = "Linux"
Source = "Launch.sh"
Target = "{AppDir}/Packaging/Launch.sh"
Executable = true
[[Templates]]
Platform = "Linux"
Source = "SaraTag.desktop"
Target = "{ApplicationsDir}/SaraTag.desktop"
# Windows: a Start Menu shortcut (pythonw.exe Main.py, from the app folder).
[[Shortcuts]]
Platform = "Windows"
Name = "SaraTag"
Script = "Main.py"
Icon = "assets/Icon.png"
# macOS: an app bundle, ~/Applications/SaraTag.app (python Main.py, from the app folder).
[[Shortcuts]]
Platform = "macOS"
Name = "SaraTag"
Script = "Main.py"
Icon = "assets/Icon.png"
# Made by SaraTag itself. The Lexicons are your tag definitions: only tick
# "remove configs" if you don't need them any more.
[[Leftovers]]
@@ -38,5 +56,6 @@ Note = "SaraTag's settings, Records, Libraries, history and Lexicons (Books next
# Shared with the other Flet programs; see Programs/FletIdentity.py.
[[Templates]]
Platform = "Linux"
Source = "../../FletIdentity.py"
Target = "{ProgramDir}/FletIdentity.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 "$@"
+2 -1
View File
@@ -1,3 +1,4 @@
# Written by SaraSuite. Where SaraTag keeps SaraTagConf.toml, its Records,
# history and Lexicons (see .saratag/Core.py).
DataDir = "{ConfigDir}"
# A literal ('...') string: Windows paths are full of backslashes.
DataDir = '{ConfigDir}'
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+55
View File
@@ -0,0 +1,55 @@
# Kept in sync between SaraSuite/Programs/SaraTune/ and SaraTune/Packaging/SaraSuite/.
Name = "SaraTune"
Platforms = ["Linux", "Windows", "macOS"]
Description = "A music player with two modes: Audio to open a file or folder and listen right away, Music for your library, a queue and saved playlists. Linux needs libmpv (Fedora: mpv-libs)."
[Source]
Git = "{GitHost}/SaraTune"
Include = ["*.py", "requirements.txt", "assets/**"]
Exclude = ["Packaging"]
[Python]
RequirementsFile = "requirements.txt"
# Linux: a launcher and a desktop entry (also offered for opening audio files).
[[Templates]]
Platform = "Linux"
Source = "Launch.sh"
Target = "{AppDir}/Packaging/Launch.sh"
Executable = true
[[Templates]]
Platform = "Linux"
Source = "SaraTune.desktop"
Target = "{ApplicationsDir}/SaraTune.desktop"
# Windows: a Start Menu shortcut (pythonw.exe Main.py, from the app folder).
[[Shortcuts]]
Platform = "Windows"
Name = "SaraTune"
Script = "Main.py"
Icon = "assets/Icon.png"
# macOS: an app bundle, ~/Applications/SaraTune.app (python Main.py, from the app folder).
[[Shortcuts]]
Platform = "macOS"
Name = "SaraTune"
Script = "Main.py"
Icon = "assets/Icon.png"
# Made by SaraTune itself.
[[Leftovers]]
Path = "{ConfigDir}"
Kind = "Config"
Note = "SaraTune's settings, saved queue and your playlists"
[[Leftovers]]
Path = "{CacheHome}/SaraSuite/SaraTune"
Kind = "Runtime"
Note = "library tag cache and cover thumbnails"
# Shared with the other Flet programs; see Programs/FletIdentity.py.
[[Templates]]
Platform = "Linux"
Source = "../../FletIdentity.py"
Target = "{ProgramDir}/FletIdentity.py"
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# Written by SaraSuite. Runs from the app folder so Flet finds assets/.
# Flet's audio player is only in its "full" desktop client.
export FLET_DESKTOP_FLAVOR=full
# A Flet client copy with the window app ID srhtnin.{Name}, so the desktop
# 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 "$@"
@@ -0,0 +1,11 @@
[Desktop Entry]
Type=Application
Name=SaraTune
Comment=Play audio files and your music library
Exec={AppDir}/Packaging/Launch.sh %F
Icon={AppDir}/assets/Icon.png
Terminal=false
Categories=AudioVideo;Audio;Player;
MimeType=audio/mpeg;audio/flac;audio/x-flac;audio/ogg;audio/x-vorbis+ogg;audio/opus;audio/x-opus+ogg;audio/wav;audio/x-wav;audio/mp4;audio/x-m4a;audio/aac;audio/x-aiff;audio/x-ms-wma;audio/x-matroska;audio/webm;
StartupNotify=true
StartupWMClass=srhtnin.{Name}
+21 -1
View File
@@ -1,25 +1,44 @@
# Kept in sync between SaraSuite/Programs/SaraView/ and SaraView/Client/Packaging/SaraSuite/.
Name = "SaraView"
Platforms = ["Linux", "Windows", "macOS"]
Description = "SaraView's desktop client: view and edit files in a local folder, or on a SaraView server."
[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]
RequirementsFile = "Client/requirements.txt"
# Linux: a launcher and a desktop entry.
[[Templates]]
Platform = "Linux"
Source = "Launch.sh"
Target = "{AppDir}/Client/Packaging/Launch.sh"
Executable = true
[[Templates]]
Platform = "Linux"
Source = "SaraView.desktop"
Target = "{ApplicationsDir}/SaraView.desktop"
# Windows: a Start Menu shortcut (pythonw.exe Client/Main.py, from the app folder).
[[Shortcuts]]
Platform = "Windows"
Name = "SaraView"
Script = "Client/Main.py"
Icon = "Client/assets/Icon.png"
# macOS: an app bundle, ~/Applications/SaraView.app (python Client/Main.py, from the app folder).
[[Shortcuts]]
Platform = "macOS"
Name = "SaraView"
Script = "Client/Main.py"
Icon = "Client/assets/Icon.png"
# Made by SaraView itself: Config.toml and Session.toml (your saved login).
[[Leftovers]]
Path = "{ConfigDir}"
@@ -33,5 +52,6 @@ Note = "server files downloaded to open in other programs"
# Shared with the other Flet programs; see Programs/FletIdentity.py.
[[Templates]]
Platform = "Linux"
Source = "../../FletIdentity.py"
Target = "{ProgramDir}/FletIdentity.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}/Client" || exit 1
exec "{Python}" Main.py "$@"
+120
View File
@@ -0,0 +1,120 @@
"""Runs a SaraSuite program as a background service where there's no systemd
--user to do it: on Windows (started windowless with pythonw.exe), on macOS,
and on Linux with another init system (runit on Void, OpenRC, s6, dinit, ...).
Started at login (Windows: a shortcut in the Startup folder; macOS: a
LaunchAgent; Linux: an XDG autostart entry) and when SaraSuite or the program's own GUI (e.g. SaraSort
for SaraSortd) starts it:
python ServiceRun.py <Name> <Script> [<WorkingDir>]
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, ~/Library/Application Support/SaraSuite/Services on macOS,
~/.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 subprocess
import sys
import time
import traceback
from datetime import datetime
from pathlib import Path
IsWindows = sys.platform == "win32"
IsMac = sys.platform == "darwin"
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")
elif IsMac:
Base = Path.home() / "Library" / "Application Support"
else:
Base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
return Base / "SaraSuite" / "Services"
def _AlreadyRunning(PidFile: Path) -> bool:
"""Linux and macOS: 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
if IsMac: # no /proc
Output = subprocess.run(["ps", "-o", "command=", "-p", str(Pid)], capture_output=True, text=True, timeout=15).stdout
return "ServiceRun.py" in Output
return b"ServiceRun.py" in Path(f"/proc/{Pid}/cmdline").read_bytes()
except (OSError, ValueError, subprocess.SubprocessError):
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])
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:
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()):
PidFile.unlink()
except OSError:
pass
if __name__ == "__main__":
Main()
+90 -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`).
@@ -78,11 +79,60 @@ an option off takes out what it placed and puts back any file it had replaced; t
places it. It reapplies the installed commit, so the program itself isn't updated. The
**Update** button only shows up when there's an update (or a broken install to repair).
## Windows and macOS
`SaraSuite.exe` is the Windows version, `SaraSuite-macOS.zip` (SaraSuite.app) the macOS one.
Each one only lists the programs for its own system (a manifest's
`Platforms`), and does everything that system's way:
| Linux | Windows | macOS |
| --- | --- | --- |
| `~/.config/SaraSuite/` (settings) | `%APPDATA%\SaraSuite\` | `~/Library/Application Support/SaraSuite/` |
| `~/.local/share/SaraSuite/` (programs, receipts) | `%LOCALAPPDATA%\SaraSuite\` | the same folder as the settings, as Mac apps do |
| `~/.cache/...` | `%LOCALAPPDATA%\...` | `~/Library/Caches/...` |
| desktop entry | Start Menu shortcut (`[[Shortcuts]]`) | `~/Applications/<Name>.app`, a small app bundle (`[[Shortcuts]]`) |
| systemd user service | `ServiceRun.py`, started at login by a shortcut in the Startup folder | `ServiceRun.py`, started at login by a LaunchAgent, `~/Library/LaunchAgents/srhtnin.<Name>.plist` |
| system `python3` | the `py` launcher / `python.exe`; if there's none, SaraSuite installs Python for the user with winget | a standalone Python that uv installs into SaraSuite's own folder (`Python/`, uv itself in `Tools/`); no admin, Homebrew or Xcode tools |
| AppImage self-update (rename over it) | `.exe` self-update (the running one is renamed to `SaraSuite.old.exe`, deleted on the next start) | `SaraSuite-macOS.zip` self-update (unpacked next to `SaraSuite.app`, then swapped in) |
A service run by `ServiceRun.py` restarts 10 seconds after it fails, like the unit's
`Restart=on-failure`, and keeps its output in SaraSuite's `Services/<Name>.log`, its PID next to it
in `<Name>.pid` (that's how SaraSuite and SaraSort tell it runs, and stop it).
Linux without systemd (no `/run/systemd/system`: runit on Void, OpenRC, s6, dinit, ...) runs
services the same way (`Services.py`), started at login by an XDG autostart entry,
`~/.config/autostart/<Name>.desktop`, which desktop sessions start whatever the init system is.
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).
Programs run on Windows with `pythonw.exe -X utf8` (no console window; UTF-8 mode, since Python
on Windows otherwise reads text files in the old ANSI code page). Nothing needs admin rights on
either system. Linux-only features (SaraFem's file picker portal and default-file-manager option)
simply aren't offered there, and on macOS SaraTune has no media keys (see its README).
macOS is written but not tested on a real Mac yet (Apple Silicon only). Things to know there:
- SaraSortd sorting `~/Downloads`, `~/Documents` or `~/Desktop` needs permission: macOS asks the
first time, or the Python in `~/Library/Application Support/SaraSuite/Python/` can be given
Full Disk Access in System Settings > Privacy & Security.
- A program's Dock icon is the Flet client's, not its own; its app bundle in `~/Applications`
(what Launchpad and Spotlight show) has the right one.
## Running and building
- AppImage: `Packaging/BuildAppImage.sh` builds `dist/SaraSuite-x86_64.AppImage`
(PyInstaller one-file build + `appimagetool`, like SaraFem's). When run as an AppImage,
the header has an "Add to app menu" button.
- Windows: `Packaging/BuildExe.sh` builds `dist/SaraSuite.exe` with the Windows Python inside a
Wine prefix (`build/WinBuild/`, set up on its first run), since PyInstaller can't build for
Windows from Linux directly. The .exe's header has an "Add to Start menu" button.
- macOS: `Packaging/BuildMacApp.py` puts `dist/SaraSuite-macOS.zip` together on Linux, since
PyInstaller can only build a Mac app on a Mac: a standalone Mac Python
(python-build-standalone), SaraSuite's requirements as macOS wheels, the Flet client for macOS
and SaraSuite's code, run by a shell-script launcher (which sets `SARASUITE_APP`, so SaraSuite
knows it's packaged). It isn't signed with an Apple developer account, so the first start needs
System Settings > Privacy & Security > Open Anyway; the launcher then clears the download
quarantine from the whole app. It has to be moved out of Downloads (into Applications) first.
- From source: `python3 -m venv .venv && .venv/bin/pip install -r requirements.txt`, then
`Packaging/Launch.sh`.
- Without a desktop (e.g. a server), the same file works from a terminal:
@@ -98,6 +148,8 @@ places it. It reapplies the installed commit, so the program itself isn't update
## Where things live
On Linux (on Windows and macOS, the same layout in their own folders; see Windows and macOS above):
| Path | What |
| --- | --- |
| `~/.local/share/SaraSuite/Programs/<Name>/` | installed program (`App/`) and its `.venv/` |
@@ -115,13 +167,23 @@ Manifests are bundled into the AppImage, so a new or changed manifest means rebu
Anything the manifest can't describe goes in an optional `Programs/<Name>/Hooks.py`.
Targets, templates and `Set` values can use `{Variables}`: `Name`, `GitHost`, `Home`, `ConfigHome`,
`DataHome`, `CacheHome`, `SystemdUserDir`, `ApplicationsDir`, `DBusServicesDir`, `ProgramDir`,
`AppDir`, `Python` (the venv's python), `ConfigDir`, `DesktopName` (e.g. `COSMIC`),
`Desktop` (e.g. `cosmic`), plus every Question's `Id`.
`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: `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`. macOS only:
`ApplicationsDir` (`~/Applications`), `LaunchAgentsDir`.
Every list item (`[[Options]]`, `[[Templates]]`, `[[Services]]`, ...) can carry
`Platform = "Linux"`, `"Windows"` or `"macOS"` to only apply on that system; items without one apply everywhere.
In TOML templates, write paths as literal strings (`'{ConfigDir}'`): Windows paths are full of
backslashes, which a `"..."` string would read as escapes.
```toml
Name = "Example"
Description = "Shown on the program's card."
Platforms = ["Linux", "Windows", "macOS"] # where it runs (default ["Linux"])
Parent = "SaraSortd" # optional: listed (indented) under that program
RestartUnits = ["xdg-desktop-portal.service"] # try-restarted after install/update/remove
@@ -158,13 +220,34 @@ 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]] # systemd --user units, enabled + started
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" # (macOS: the same with Platform = "macOS", from a LaunchAgent;
# both: add ../../ServiceRun.py as a Template to {ProgramDir})
Name = "Example"
Script = "Example.py" # relative to the app folder
WorkingDir = "{ConfigDir}"
Option = "Autostart"
[[Shortcuts]] # Windows: a Start Menu shortcut (pythonw.exe -X utf8 Script)
Platform = "Windows"
Name = "Example"
Script = "Main.py" # relative to the app folder, which is also its working folder
Icon = "assets/Icon.png" # turned into an .ico
# macOS: the same with Platform = "macOS" makes ~/Applications/<Name>.app
# (the venv's python Script; the icon becomes an .icns)
[[IniEdits]] # one key in a file SaraSuite doesn't own; restored on removal
File = "{ConfigHome}/mimeapps.list"
Section = "Default Applications"
+87 -18
View File
@@ -1,24 +1,33 @@
"""SaraSuite updating itself from its Gitea releases.
Packaging/Release.py publishes a release (tag v<AppVersion>) with the
AppImage attached. Here SaraSuite asks Gitea for the latest release, and if
it's newer than AppVersion and SaraSuite runs as an AppImage, downloads it
next to the running one and renames it into place (allowed while it's
running), then restarts into it. The AppImage keeps its path, so its app
menu entry keeps working.
AppImage and SaraSuite.exe attached (and SaraSuite-macOS.zip, once there's
a Mac build). Here SaraSuite asks Gitea for the latest
release, and if it's newer than AppVersion and SaraSuite runs packaged
(AppImage / .exe), downloads its own platform's file and swaps it in, then
restarts into it. The file keeps its path, so its menu entry keeps working.
- Linux: the download is renamed over the running AppImage (allowed).
- Windows: a running .exe can't be overwritten but can be renamed, so the
old one becomes SaraSuite.old.exe (deleted on the next start).
- macOS: SaraSuite.app (a folder, Packaging/BuildMacApp.py) comes zipped; it's
unpacked next to the running one, which is renamed aside and deleted once
the new one is in place.
"""
import os
import shutil
import subprocess
import tempfile
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
from urllib.parse import urlsplit
from Core import AppName, AppVersion, ChildEnvironment, LoadConfig
from Core import AppName, AppVersion, ChildEnvironment, IsFrozen, IsMac, IsWindows, LoadConfig, MacApp
from Sources import GetBytes, GetJson, SourceError
AssetName = f"{AppName}-x86_64.AppImage"
AssetName = f"{AppName}.exe" if IsWindows else f"{AppName}-macOS.zip" if IsMac else f"{AppName}-x86_64.AppImage"
@dataclass
@@ -28,10 +37,43 @@ class Release:
def RunningAppImage() -> Optional[Path]:
"""The packaged SaraSuite file that's running: the AppImage on Linux, the
.exe on Windows, the SaraSuite.app bundle on macOS. None when running
from source."""
if IsWindows:
return Path(sys.executable) if IsFrozen else None
if IsMac:
return Path(MacApp) if MacApp.endswith(".app") else None
Value = os.environ.get("APPIMAGE", "")
return Path(Value) if Value else None
def CleanUpOldExe() -> None:
"""Windows: delete the .exe a previous update renamed aside."""
Running = RunningAppImage()
if IsWindows and Running is not None:
Running.with_name(f"{Running.stem}.old.exe").unlink(missing_ok=True)
def _InstallMacBundle(Target: Path, Data: bytes) -> None:
"""Unpack the zipped SaraSuite.app next to the running one (same volume,
so the renames are instant), then swap the two. ditto keeps what a Mac
app needs: permissions, symlinks inside its frameworks, its signature."""
with tempfile.TemporaryDirectory(dir=Target.parent, prefix=".SaraSuite-update-") as Temporary:
Archive = Path(Temporary) / AssetName
Archive.write_bytes(Data)
Unpacked = Path(Temporary) / "Unpacked"
subprocess.run(["ditto", "-x", "-k", str(Archive), str(Unpacked)], check=True, capture_output=True)
New = next(Unpacked.glob("*.app"), None)
if New is None:
raise OSError(f"{AssetName} has no app in it; leaving the current one alone.")
Old = Target.with_name(f"{Target.stem}.old.app")
shutil.rmtree(Old, ignore_errors=True)
Target.rename(Old)
New.rename(Target)
shutil.rmtree(Old, ignore_errors=True)
def _VersionTuple(Version: str) -> tuple[int, ...]:
Parts = []
for Part in Version.strip().lstrip("vV").split("."):
@@ -56,7 +98,12 @@ def LatestRelease() -> Optional[Release]:
except SourceError:
return None
Asset = next((Item for Item in Data.get("assets", []) if Item.get("name") == AssetName), None)
return Release(Data.get("tag_name", ""), Asset.get("browser_download_url", "") if Asset else "")
AssetUrl = Asset.get("browser_download_url", "") if Asset else ""
# Gitea can hand out http:// download links; never fetch an executable
# over plain HTTP when the git host itself is HTTPS.
if Parts.scheme == "https" and AssetUrl.startswith("http://"):
AssetUrl = "https://" + AssetUrl.removeprefix("http://")
return Release(Data.get("tag_name", ""), AssetUrl)
def Install(Latest: Release, Log: Callable[[str], None]) -> Path:
@@ -69,14 +116,28 @@ def Install(Latest: Release, Log: Callable[[str], None]) -> Path:
Log(f"== Updating {AppName} {AppVersion} -> {Latest.Version.lstrip('v')}")
Log(f"Downloading {Latest.AssetUrl}")
Data = GetBytes(Latest.AssetUrl)
if not Data.startswith(b"\x7fELF"):
raise OSError("The download isn't an AppImage; leaving the current one alone.")
# Same folder, so the rename is atomic; renaming over a running AppImage
# is fine (overwriting it in place isn't: "Text file busy").
if not Data.startswith(b"MZ" if IsWindows else b"PK" if IsMac else b"\x7fELF"):
raise OSError(f"The download isn't a {AssetName}; leaving the current one alone.")
if IsMac:
try:
_InstallMacBundle(Target, Data)
except subprocess.CalledProcessError as Error:
raise OSError(f"Couldn't unpack {AssetName}: {Error.stderr.decode(errors='replace').strip()}") from Error
Log(f"== {AppName} updated; it's the new version from the next start")
return Target
Temporary = Target.with_name(Target.name + ".new")
Temporary.write_bytes(Data)
Temporary.chmod(0o755)
Temporary.replace(Target)
if IsWindows:
# A running .exe can't be replaced, but it can be renamed out of the way.
Old = Target.with_name(f"{Target.stem}.old.exe")
Old.unlink(missing_ok=True)
Target.rename(Old)
Temporary.rename(Target)
else:
# Same folder, so the rename is atomic; renaming over a running AppImage
# is fine (overwriting it in place isn't: "Text file busy").
Temporary.chmod(0o755)
Temporary.replace(Target)
Log(f"== {AppName} updated; it's the new version from the next start")
return Target
@@ -87,7 +148,15 @@ def Restart() -> None:
if Target is None:
return
Environment = ChildEnvironment()
for Key in ("APPIMAGE", "APPDIR", "ARGV0", "OWD"): # the new AppImage sets its own
Environment.pop(Key, None)
subprocess.Popen([str(Target)], env=Environment, start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for Key in ("APPIMAGE", "APPDIR", "ARGV0", "OWD", "_MEIPASS2", "_PYI_APPLICATION_HOME_DIR"):
Environment.pop(Key, None) # the new one sets its own
if IsMac:
# -n: a new instance, even though this one is still running.
subprocess.Popen(["open", "-n", str(Target)], env=Environment, start_new_session=True)
elif IsWindows:
Environment["PYINSTALLER_RESET_ENVIRONMENT"] = "1" # a fresh onefile unpack, not ours
subprocess.Popen([str(Target)], env=Environment, close_fds=True,
creationflags=getattr(subprocess, "DETACHED_PROCESS", 0))
else:
subprocess.Popen([str(Target)], env=Environment, start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+130
View File
@@ -0,0 +1,130 @@
"""Background services SaraSuite runs itself, where there's no systemd --user
to do it: Windows, macOS, 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;
- macOS: a LaunchAgent (~/Library/LaunchAgents/srhtnin.<Name>.plist);
- 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, IsMac, 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 IsMac: # no /proc: ask ps for its command line
try:
Output = subprocess.run(["ps", "-o", "command=", "-p", str(Pid)], capture_output=True, text=True, timeout=15).stdout
except (OSError, subprocess.SubprocessError):
return None
return Pid if "ServiceRun.py" in Output else 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"
)
+16 -5
View File
@@ -8,8 +8,8 @@ from typing import Callable
import flet as ft
from Core import (
AppConfig, BundledFontFamily, ChildEnvironment, ChipButtonStyle, LoadSharedSettings, Palette,
SharedSettings, ThemesDir,
AppConfig, BundledFontFamily, ChildEnvironment, ChipButtonStyle, EnsureSharedThemes, IsMac, IsWindows,
LoadSharedSettings, Palette, SharedConfigPath, SharedSettings, ThemesDir,
)
from Installer import DefaultAnswers, DefaultOptions
from Manifests import Program
@@ -25,13 +25,24 @@ 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)
def _Fonts() -> list[str]:
"""Installed font families (fontconfig), with the bundled font first."""
"""Installed font families (fontconfig, the registry on Windows, the font
files themselves on macOS), with the bundled font first."""
if IsWindows or IsMac:
if IsMac:
import MacSupport as Support
else:
import WindowsSupport as Support
Families = Support.FontFamilies()
Families.discard(BundledFontFamily)
return [BundledFontFamily] + sorted(Families, key=str.lower)
try:
Result = subprocess.run(
["fc-list", ":", "family"], capture_output=True, text=True, timeout=5, env=ChildEnvironment(),
@@ -90,7 +101,7 @@ def ShowSettingsDialog(Page: ft.Page, Config: AppConfig, Programs: list[Program]
Categories: dict[str, ft.Control] = {
"Shared": ft.Column(
[
_Note("Shared by every SaraSuite program (~/.config/SaraSuite/Config.toml)."),
_Note(f"Shared by every SaraSuite program ({SharedConfigPath})."),
UseSharedSwitch,
ft.Divider(height=1, color=Palette.Surface1),
SharedTheme,
+147
View File
@@ -0,0 +1,147 @@
"""Windows stand-ins for what SaraSuite uses on Linux:
Linux Windows
.desktop entry Start Menu shortcut (.lnk)
systemd --user service shortcut in the Startup folder, started and stopped
through Programs/ServiceRun.py (Services.py)
system python3 the py launcher / python.exe, else winget installs it
fc-list the Fonts registry key
Only imported on Windows (pylnk3, winreg).
"""
import os
import re
import shutil
import subprocess
from pathlib import Path
from typing import Callable, Optional
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)
PythonVersion = "3.14" # what winget installs when there's no Python yet
_WingetId = f"Python.Python.{PythonVersion}"
# --- Shortcuts and icons ---------------------------------------------------------------
def MakeIcon(Png: Path, Ico: Path) -> Optional[Path]:
"""A .ico from one of the Sara icons (32x32 pixel art stored at 10x).
Each size is a whole multiple of the 32x32 grid, so every art pixel
stays square and crisp (same reasoning as the cards' 128px icons)."""
try:
from PIL import Image
Source = Image.open(Png).convert("RGBA")
Grid = Source.resize((32, 32), Image.NEAREST)
Sizes = [(Size, Size) for Size in (256, 128, 64, 32, 16)]
Ico.parent.mkdir(parents=True, exist_ok=True)
Grid.resize((256, 256), Image.NEAREST).save(Ico, format="ICO", sizes=Sizes)
return Ico
except Exception:
return None
def CreateShortcut(
Link: Path, Target: Path, Arguments: str = "", WorkingDir: Optional[Path] = None,
Icon: Optional[Path] = None, Description: str = "",
) -> None:
import pylnk3
Link.parent.mkdir(parents=True, exist_ok=True)
pylnk3.for_file(
str(Target), str(Link), arguments=Arguments or None, description=Description or None,
icon_file=str(Icon) if Icon else None, icon_index=0,
work_dir=str(WorkingDir) if WorkingDir else None,
)
# --- Python -------------------------------------------------------------------------------
def _Usable(Candidate: str) -> bool:
# The "python.exe" in WindowsApps is only a stub that opens the Microsoft Store.
return bool(Candidate) and "WindowsApps" not in Candidate and Path(Candidate).is_file()
def FindPython() -> Optional[str]:
"""A real Python 3 to build program venvs from."""
Launcher = shutil.which("py")
if Launcher:
try:
Result = subprocess.run(
[Launcher, "-3", "-c", "import sys; print(sys.executable)"],
capture_output=True, text=True, timeout=30, creationflags=NoWindow,
)
if _Usable(Result.stdout.strip()):
return Result.stdout.strip()
except (OSError, subprocess.SubprocessError):
pass
for Name in ("python3", "python"):
Found = shutil.which(Name) or ""
if _Usable(Found):
return Found
# Per-user installs from python.org or winget, newest first.
Installed = sorted((DataHome / "Programs" / "Python").glob("Python3*/python.exe"), reverse=True)
return str(Installed[0]) if Installed else None
def InstallPython(Log: LogFn) -> Optional[str]:
"""Install the official Python for the current user with winget (built
into Windows 10/11; no admin needed with --scope user)."""
Winget = shutil.which("winget")
if Winget is None:
Log("winget isn't available. Install Python from https://www.python.org/downloads/ and try again.")
return None
Log(f"Python not found; installing Python {PythonVersion} with winget (just for you, no admin)")
Command = [
Winget, "install", "--id", _WingetId, "--exact", "--scope", "user", "--silent",
"--accept-package-agreements", "--accept-source-agreements",
]
Log(f"$ {' '.join(Command)}")
Process = subprocess.Popen(Command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
creationflags=NoWindow, errors="replace")
for Line in Process.stdout:
if Line.strip():
Log(f" {Line.rstrip()}")
Process.wait()
return FindPython()
# --- Fonts -----------------------------------------------------------------------------------
def FontFamilies() -> set[str]:
"""Installed font families, from the system and per-user Fonts keys
("Arial Bold (TrueType)" -> "Arial")."""
import winreg
Families: set[str] = set()
Key = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts"
for Root in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
try:
with winreg.OpenKey(Root, Key) as Fonts:
Index = 0
while True:
try:
Name = winreg.EnumValue(Fonts, Index)[0]
except OSError:
break
Index += 1
Name = re.sub(r"\s*\(.*\)$", "", Name)
Name = re.sub(r"\s+(Bold|Italic|Light|Semibold|SemiBold|Black|Regular|Medium|Thin)\b.*$", "", Name)
if Name and "&" not in Name:
Families.add(Name.strip())
except OSError:
continue
return Families
def OpenPath(Target: Path) -> None:
os.startfile(str(Target)) # type: ignore[attr-defined] # Windows only
+2
View File
@@ -1,2 +1,4 @@
flet>=1.0,<2.0
tomlkit
# macOS: app bundle icons (.icns); the Windows build bundles it for .ico.
pillow; sys_platform == "darwin"