10 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
27 changed files with 1102 additions and 155 deletions

No files matched your search

+87 -14
View File
@@ -1,11 +1,13 @@
"""Shared app-wide constants: paths, palette, theme.
Paths follow each system's own per-user conventions (no admin needed):
Linux Windows
~/.config %APPDATA% (settings; roams with the user)
~/.local/share %LOCALAPPDATA% (installed programs, SaraSuite's data)
~/.cache %LOCALAPPDATA% (caches, in a Cache/Downloads subfolder)
so the SaraSuite layout below them (SaraSuite/<Program>/...) is the same on both.
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
@@ -13,6 +15,7 @@ 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
@@ -24,10 +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.2.2"
IsFrozen = bool(getattr(sys, "frozen", False)) # running from the AppImage / .exe
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"
Platform = "Windows" if IsWindows else "Linux" # matches manifests' Platforms / Platform
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.
@@ -43,6 +52,10 @@ if IsWindows:
# 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")
@@ -70,8 +83,9 @@ ServicesDir = DataDir / "Services"
# Linux: whether systemd runs the system, and so --user services. Without it
# (runit on Void, OpenRC, s6, dinit, ...) services run the way they do on
# Windows (Services.py), started at login from AutostartDir.
HasSystemd = not IsWindows and Path("/run/systemd/system").is_dir() and shutil.which("systemctl") is not None
# 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
@@ -109,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
@@ -220,7 +273,7 @@ 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
@@ -229,6 +282,26 @@ def ChildEnvironment() -> dict[str, str]:
return Environment
def UserPortalsSupported() -> bool:
"""Whether xdg-desktop-portal reads portal files from
~/.local/share/xdg-desktop-portal/portals (1.17 and newer), so installing
one needs no password. True when it isn't installed either: then a
portal file can't be used anywhere, so it might as well not need root."""
import subprocess
for Candidate in ("/usr/libexec/xdg-desktop-portal", "/usr/lib/xdg-desktop-portal",
"/usr/lib/x86_64-linux-gnu/xdg-desktop-portal", "/usr/local/libexec/xdg-desktop-portal"):
if not Path(Candidate).is_file():
continue
try:
Output = subprocess.run([Candidate, "--version"], capture_output=True, text=True, timeout=10).stdout
except (OSError, subprocess.SubprocessError):
return False
Found = re.search(r"(\d+)\.(\d+)", Output)
return bool(Found) and (int(Found[1]), int(Found[2])) >= (1, 17)
return True
def CurrentDesktop() -> str:
"""First entry of $XDG_CURRENT_DESKTOP as-is (e.g. "COSMIC"), used for
portal descriptors' UseIn= and the <desktop>-portals.conf file name.
+152 -50
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
@@ -21,8 +23,8 @@ from typing import Callable, Optional
from ConfigMerge import ApplySet, MergeConfig, MergeResult
import Services
from Core import (
AutostartDir, BackupsDir, ChildEnvironment, DownloadsDir, HasSystemd, IsWindows, LoadConfig, PristineDir,
StartMenuDir, StartupDir,
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
@@ -45,11 +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(),
encoding="utf-8", errors="replace",
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), # no console flashes on Windows
)
try:
Process = subprocess.Popen(
Command, cwd=Cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=ChildEnvironment(),
encoding="utf-8", errors="replace",
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), # no console flashes on Windows
)
except FileNotFoundError:
# Not installed here (e.g. update-desktop-database without desktop-file-utils).
if Check:
raise InstallError(f"{Command[0]} isn't installed")
Log(f" (skipped: {Command[0]} isn't installed)")
return 127
for Line in Process.stdout:
Log(f" {Line.rstrip()}")
Code = Process.wait()
@@ -81,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:
@@ -305,14 +367,17 @@ def _RequirementsText(Program: Program) -> str:
def _SystemPython(Program: Program, Log: LogFn) -> str:
if not IsWindows:
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
import WindowsSupport
if IsMac:
import MacSupport as Support
else:
import WindowsSupport as Support
Python = WindowsSupport.FindPython() or WindowsSupport.InstallPython(Log)
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
@@ -343,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}")
@@ -361,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)
@@ -380,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)
@@ -475,27 +533,42 @@ def _PlaceShortcut(Program: Program, Link: Path, Arguments: str, WorkingDir: Pat
and isn't ours is moved aside like a template, and comes back on removal."""
import WindowsSupport
Previous = OldReceipt.FindFile(str(Link)) if OldReceipt is not None else None
Placed = PlacedFile(str(Link), "Shortcut", Displaced=Previous.Displaced if Previous else "")
if Previous is None and Link.exists():
Displaced = BackupsDir / Program.Name / "Displaced" / _BackupName(str(Link))
Displaced.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(Link, Displaced)
Placed.Displaced = str(Displaced)
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:
"""Windows Start Menu shortcuts ([[Shortcuts]]); the Startup ones for
services are placed by _ApplyServices."""
if not IsWindows:
"""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(
@@ -511,22 +584,34 @@ def _PlaceAutostart(Program: Program, Service, Command: tuple, OldReceipt: Optio
login. Recorded like a shortcut, so it goes when the service is turned off
or the program removed (and one that was already there comes back)."""
Entry = AutostartDir / f"{Service.Name}.desktop"
Previous = OldReceipt.FindFile(str(Entry)) if OldReceipt is not None else None
Placed = PlacedFile(str(Entry), "Shortcut", Displaced=Previous.Displaced if Previous else "")
if Previous is None and Entry.exists():
Displaced = BackupsDir / Program.Name / "Displaced" / _BackupName(str(Entry))
Displaced.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(Entry, Displaced)
Placed.Displaced = str(Displaced)
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; Linux without systemd): at
login from a Startup-folder shortcut / an autostart entry, through
"""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()
@@ -541,6 +626,8 @@ def _ApplyStandInServices(Program: Program, Context: dict, OldReceipt: Optional[
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
@@ -579,9 +666,23 @@ def _ApplyServices(Program: Program, OldReceipt: Optional[Receipt], NewReceipt:
_Systemctl(Log, "disable", "--now", Service.Unit)
def _StopProcesses(Name: str, Log: LogFn) -> None:
"""Linux: stop your own processes called Name (like pkill -x -u $USER,
which not every system has)."""
Stopped = 0
for Entry in Path("/proc").iterdir():
try:
if Entry.name.isdigit() and Entry.stat().st_uid == os.getuid() and (Entry / "comm").read_text().strip() == Name[:15]:
os.kill(int(Entry.name), signal.SIGTERM)
Stopped += 1
except (OSError, ValueError):
continue
Log(f"Stopped {Name} ({Stopped} running) so it starts again with the change" if Stopped else f"{Name} wasn't running")
def _AfterIntegrationChanges(Program: Program, Log: LogFn) -> None:
if IsWindows:
return # Start Menu shortcuts need no refresh
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)
@@ -591,7 +692,7 @@ def _AfterIntegrationChanges(Program: Program, Log: LogFn) -> None:
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).
_Run(["pkill", "-x", "-u", str(os.getuid()), Unit.removesuffix(".service")], Log, Check=False)
_StopProcesses(Unit.removesuffix(".service"), Log)
def Apply(
@@ -721,6 +822,7 @@ def PlanRemoval(Program: Program) -> RemovalPlan:
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:
@@ -780,7 +882,7 @@ def Remove(Program: Program, RemoveConfigs: bool, RemoveRuntime: bool, Log: LogF
_RemovePlacedTemplate(Placed, Log)
for Record in Current.IniEdits:
_RevertIni(Record, Log)
if not IsWindows and (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)
+23 -4
View File
@@ -14,7 +14,7 @@ import flet as ft
import SelfUpdate
from Catalog import UpdateCatalog
from Core import (
AppName, AppVersion, IsWindows, StartMenuDir, 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
@@ -26,7 +26,8 @@ from Settings import ShowSettingsDialog
_MaxLogLines = 2000
# When running packaged (AppImage / .exe), SaraSuite can put itself in the
# app menu: a desktop entry on Linux, a Start Menu shortcut on Windows.
# 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:
@@ -38,6 +39,23 @@ else:
_MenuLabel = "Start menu" if IsWindows else "app menu"
def _KeepFallbackLibraries() -> None:
"""Linux AppImage: copy the bundled libsecret (and what it needs) to
~/.local/share/SaraSuite/Libraries, for FletIdentity.py to give the Flet
client of every installed program on systems without it."""
Bundled = Path(getattr(sys, "_MEIPASS", "")) / "FallbackLibs"
if 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():
# Lives in Programs/ (bundled as data) because installed programs get a
# copy of the same file next to them.
@@ -269,7 +287,7 @@ def Main(Page: ft.Page) -> None:
MenuButton = ft.OutlinedButton(
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,
)
@@ -324,6 +342,7 @@ def Main(Page: ft.Page) -> None:
if __name__ == "__main__":
_KeepFallbackLibraries() # also before a command (an install from the command line)
if len(sys.argv) > 1:
if IsWindows and getattr(sys, "frozen", False):
# The .exe is built windowed (no console of its own); write the
@@ -337,7 +356,7 @@ if __name__ == "__main__":
sys.exit(Run(sys.argv[1:]))
SelfUpdate.CleanUpOldExe()
if not IsWindows:
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.
+31 -13
View File
@@ -3,8 +3,9 @@ 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" or "Windows" to only apply there (e.g. desktop
entries on Linux, Start Menu shortcuts on Windows); others are dropped at load."""
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
@@ -16,8 +17,8 @@ from typing import Optional
import tomlkit
from Core import (
AutostartDir, CacheHome, ConfigHome, CurrentDesktop, DataHome, HasSystemd, Home, IsWindows, LoadConfig,
Platform, ProgramsDir, SharedConfigDir, StartMenuDir, StartupDir,
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.
@@ -55,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
@@ -84,10 +87,11 @@ class IniEdit:
@dataclass
class Service:
"""A background service. Linux with systemd: a --user Unit (placed by a
Template). Windows, and Linux without systemd (runit, OpenRC, ...):
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 / an XDG autostart entry
(Services.py), so a Linux service gives both Unit and Name/Script."""
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
@@ -107,9 +111,12 @@ class Service:
@dataclass
class Shortcut:
"""Windows: a Start Menu shortcut running Script (relative to the app
folder) with the program's pythonw.exe, from the app folder, with Icon
(a PNG in the app folder, turned into an .ico)."""
"""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
@@ -233,12 +240,23 @@ class Program:
}
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
@@ -296,7 +314,7 @@ def LoadProgram(Folder: Path) -> Program:
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=[] if IsWindows else Data.get("RestartUnits", []),
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"]),
@@ -320,7 +338,7 @@ 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).
# 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 = []
+9
View File
@@ -18,8 +18,17 @@ podman run --rm --security-opt label=disable -v "$PWD:/src" -w /src \
quay.io/pypa/manylinux_2_28_x86_64 bash -euo pipefail -c '
uv venv --quiet --allow-existing --managed-python --python 3.14 build/Container/venv
uv pip install --quiet --python build/Container/venv -r requirements.txt flet-desktop pyinstaller
# The Flet window needs libsecret, which some systems lack: bundled as a
# fallback (RestoreLibraryPath.py uses only what the system lacks).
dnf -y -q install libsecret >/dev/null
mkdir -p build/FallbackLibs
for Library in libsecret-1.so.0 libgcrypt.so.20 libgpg-error.so.0; do cp -L /usr/lib64/$Library build/FallbackLibs/; done
# Each finds the others beside it, wherever they are put (a client lib folder).
patchelf --set-rpath "\$ORIGIN" build/FallbackLibs/libsecret-1.so.0 build/FallbackLibs/libgcrypt.so.20
build/Container/venv/bin/pyinstaller --noconfirm --clean --log-level WARN \
--onefile --windowed --name SaraSuite --collect-data flet \
--runtime-hook "$PWD/Packaging/RestoreLibraryPath.py" \
--add-data "$PWD/build/FallbackLibs:FallbackLibs" \
--add-data "$PWD/assets:assets" --add-data "$PWD/Programs:Programs" \
--distpath build/PyInstaller/dist --workpath build/PyInstaller/work --specpath build/PyInstaller \
"$PWD/Main.py"'
+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()
+6 -4
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env python3
"""Publish a SaraSuite release: build the AppImage (Linux) and SaraSuite.exe
(Windows, built under Wine by BuildExe.sh), create the Gitea release
v<AppVersion> (from Core.py) with both attached, and copy them 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
@@ -31,6 +32,7 @@ RepoDir = Path(__file__).resolve().parent.parent
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")
+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)
+15 -6
View File
@@ -1,6 +1,6 @@
Name = "SaraFem"
Description = "Desktop file manager that understands SaraTag Books, and (on Linux) can stand in as the system file picker."
Platforms = ["Linux", "Windows"]
Platforms = ["Linux", "Windows", "macOS"]
# The portal reads its descriptors and preferences once at startup.
RestartUnits = ["xdg-desktop-portal.service"]
@@ -18,7 +18,7 @@ RequirementsFile = "requirements.txt"
[[Options]]
Platform = "Linux"
Id = "FilePicker"
Label = "Use SaraFem as the system file picker (asks for your password: the portal file lives in /usr/share)"
Label = "Use SaraFem as the system file picker"
Default = true
[[Options]]
@@ -48,16 +48,18 @@ Option = "FilePicker"
[[Templates]]
Platform = "Linux"
Source = "SaraFem.portal"
Target = "/usr/share/xdg-desktop-portal/portals/SaraFem.portal"
# In your own folder where xdg-desktop-portal is new enough (1.17+); in
# /usr/share, through pkexec, on older systems.
Target = "{PortalsDir}/SaraFem.portal"
Option = "FilePicker"
Root = true
Root = "Auto"
[[IniEdits]]
Platform = "Linux"
File = "{ConfigHome}/xdg-desktop-portal/{Desktop}-portals.conf"
File = "{ConfigHome}/xdg-desktop-portal/{PortalsConf}"
Section = "preferred"
Key = "org.freedesktop.impl.portal.FileChooser"
Value = "SaraFem;{Desktop};gtk;"
Value = "SaraFem;{DesktopPortal}gtk;"
Option = "FilePicker"
[[IniEdits]]
@@ -75,6 +77,13 @@ 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"
+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 "$@"
+8 -1
View File
@@ -1,7 +1,7 @@
# Kept in sync between SaraSuite/Programs/SaraSort/ and SaraSort/Packaging/SaraSuite/.
Name = "SaraSort"
Parent = "SaraSortd" # a GUI for SaraSortd, listed under it
Platforms = ["Linux", "Windows"]
Platforms = ["Linux", "Windows", "macOS"]
Description = "A GUI for configuring SaraSortd: a flowchart of what goes where, its service and logs, configs and templates."
[Source]
@@ -31,6 +31,13 @@ Name = "SaraSort"
Script = "Main.py"
Icon = "assets/Icon.png"
# macOS: an app bundle, ~/Applications/SaraSort.app (python Main.py, from the app folder).
[[Shortcuts]]
Platform = "macOS"
Name = "SaraSort"
Script = "Main.py"
Icon = "assets/Icon.png"
# Made by SaraSort itself: Config.toml, Layout.toml, Templates/.
[[Leftovers]]
Path = "{ConfigDir}"
+4
View File
@@ -4,5 +4,9 @@
# matches the window to {Name}.desktop (StartupWMClass) instead of showing it
# as "com.appveyor.flet". Empty if that fails: Flet then uses its own client.
export FLET_VIEW_PATH="$("{Python}" "{ProgramDir}/FletIdentity.py" "srhtnin.{Name}")"
# Libraries Flet's window needs that this system lacks (libsecret), from
# SaraSuite: FletIdentity.py puts them beside the client copy.
Fallback="$FLET_VIEW_PATH/../fallback-libs"
[ -n "$FLET_VIEW_PATH" ] && [ -d "$Fallback" ] && export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}$Fallback"
cd "{AppDir}" || exit 1
exec "{Python}" Main.py "$@"
+13 -5
View File
@@ -1,6 +1,6 @@
Name = "SaraSortd"
Description = "Sorts files from input folders into output folders by the rules in its configs. Runs in the background."
Platforms = ["Linux", "Windows"]
Platforms = ["Linux", "Windows", "macOS"]
[Version]
File = "SaraSortd.py"
@@ -46,10 +46,11 @@ Platform = "Linux"
Source = "SaraSortd.service"
Target = "{SystemdUserDir}/SaraSortd.service"
# Windows, and Linux without systemd (runit, OpenRC, s6, ...): run from
# ConfigDir by ServiceRun.py, which restarts it if it fails, at login through
# a shortcut in the Startup folder / an XDG autostart entry (SaraSuite's
# Services.py). On Linux the same service has a Unit for systemd too.
# 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"
@@ -69,5 +70,12 @@ 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
# GlobalConf.toml by Hooks.py.
+8 -1
View File
@@ -1,6 +1,6 @@
# Kept in sync between SaraSuite/Programs/SaraTag/ and SaraTag/Packaging/SaraSuite/.
Name = "SaraTag"
Platforms = ["Linux", "Windows"]
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]
@@ -40,6 +40,13 @@ 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]]
+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 "$@"
+8 -1
View File
@@ -1,6 +1,6 @@
# Kept in sync between SaraSuite/Programs/SaraTune/ and SaraTune/Packaging/SaraSuite/.
Name = "SaraTune"
Platforms = ["Linux", "Windows"]
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]
@@ -30,6 +30,13 @@ 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}"
+4
View File
@@ -6,5 +6,9 @@ export FLET_DESKTOP_FLAVOR=full
# matches the window to {Name}.desktop (StartupWMClass) instead of showing it
# as "com.appveyor.flet". Empty if that fails: Flet then uses its own client.
export FLET_VIEW_PATH="$("{Python}" "{ProgramDir}/FletIdentity.py" "srhtnin.{Name}")"
# Libraries Flet's window needs that this system lacks (libsecret), from
# SaraSuite: FletIdentity.py puts them beside the client copy.
Fallback="$FLET_VIEW_PATH/../fallback-libs"
[ -n "$FLET_VIEW_PATH" ] && [ -d "$Fallback" ] && export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}$Fallback"
cd "{AppDir}" || exit 1
exec "{Python}" Main.py "$@"
+8 -1
View File
@@ -1,6 +1,6 @@
# Kept in sync between SaraSuite/Programs/SaraView/ and SaraView/Client/Packaging/SaraSuite/.
Name = "SaraView"
Platforms = ["Linux", "Windows"]
Platforms = ["Linux", "Windows", "macOS"]
Description = "SaraView's desktop client: view and edit files in a local folder, or on a SaraView server."
[Source]
@@ -32,6 +32,13 @@ 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}"
+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 "$@"
+17 -8
View File
@@ -1,8 +1,8 @@
"""Runs a SaraSuite program as a background service where there's no systemd
--user to do it: on Windows (started windowless with pythonw.exe), and on
Linux with another init system (runit on Void, OpenRC, s6, dinit, ...).
Started at login (Windows: a shortcut in the Startup folder; Linux: an XDG
autostart entry) and when SaraSuite or the program's own GUI (e.g. SaraSort
--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>]
@@ -12,7 +12,8 @@ seconds after it fails (an exception or a non-zero exit), not after a clean
exit or a stop; its output goes to <Name>.log. Its PID is in <Name>.pid, so
anything can tell whether it runs, and stop it, without admin rights. Both
files are in SaraSuite's Services folder: %LOCALAPPDATA%\\SaraSuite\\Services
on Windows, ~/.local/share/SaraSuite/Services on Linux.
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).
"""
@@ -20,6 +21,7 @@ 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
@@ -27,6 +29,7 @@ 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
@@ -35,22 +38,28 @@ 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: whether the PID file's process is another ServiceRun.py still
running (not a reused PID). Windows relies on whoever starts it to check."""
"""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):
except (OSError, ValueError, subprocess.SubprocessError):
return False
+53 -29
View File
@@ -79,34 +79,44 @@ 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
## Windows and macOS
`SaraSuite.exe` is the Windows version. Each build only lists the programs for its own system
(a manifest's `Platforms`), and does everything the Windows way:
`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 |
| --- | --- |
| `~/.config/SaraSuite/` (settings) | `%APPDATA%\SaraSuite\` |
| `~/.local/share/SaraSuite/` (programs, receipts) | `%LOCALAPPDATA%\SaraSuite\` |
| `~/.cache/...` | `%LOCALAPPDATA%\...` |
| desktop entry | Start Menu shortcut (`[[Shortcuts]]`) |
| systemd user service | `ServiceRun.py`, started at login by a shortcut in the Startup folder; its PID is in `%LOCALAPPDATA%\SaraSuite\Services\<Name>.pid` |
| Linux | 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 like Windows does (`Services.py`): `ServiceRun.py` runs them, started at login by an XDG
autostart entry, `~/.config/autostart/<Name>.desktop`, which desktop sessions start whatever the
init system is. It restarts a service 10 seconds after it fails, like the unit's
`Restart=on-failure`, and keeps its output in `~/.local/share/SaraSuite/Services/<Name>.log` (its
PID next to it, in `<Name>.pid`). Units aren't written there, and `RestartUnits` stops the process
instead (D-Bus starts `xdg-desktop-portal` again when it's needed). A window manager that doesn't
run XDG autostart entries needs to start them itself (e.g. `dex -a` in its startup config).
| system `python3` | the `py` launcher / `python.exe`; if there's none, SaraSuite installs Python for the user with winget |
| AppImage self-update (rename over it) | `.exe` self-update (the running one is renamed to `SaraSuite.old.exe`, deleted on the next start) |
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.
Linux-only features (SaraFem's file picker portal and default-file-manager option) simply
aren't offered there.
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
@@ -116,6 +126,13 @@ aren't offered there.
- 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:
@@ -131,7 +148,7 @@ aren't offered there.
## Where things live
On Linux (on Windows, the same layout under `%LOCALAPPDATA%` and `%APPDATA%`; see Windows above):
On Linux (on Windows and macOS, the same layout in their own folders; see Windows and macOS above):
| Path | What |
| --- | --- |
@@ -152,18 +169,21 @@ Anything the manifest can't describe goes in an optional `Programs/<Name>/Hooks.
Targets, templates and `Set` values can use `{Variables}`: `Name`, `GitHost`, `Home`, `ConfigHome`,
`DataHome`, `CacheHome`, `ProgramDir`, `AppDir`, `Python` (the venv's python), `PythonW`
(Windows: its console-less pythonw.exe), `ConfigDir`, `DesktopName` (e.g. `COSMIC`),
`Desktop` (e.g. `cosmic`), plus every Question's `Id`. Linux only: `SystemdUserDir`,
`ApplicationsDir`, `DBusServicesDir`. Windows only: `StartMenuDir`, `StartupDir`.
`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"` or `"Windows"` to only apply on that system; items without one apply on both.
`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"] # where it runs (default ["Linux"])
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
@@ -200,7 +220,8 @@ Set = { "Paths.RootDir" = "{RootDir}" }
Source = "Example.service" # in Templates/
Target = "{SystemdUserDir}/Example.service"
Option = "Autostart" # optional: only while the option is ticked
Root = false # true: outside $HOME, written through pkexec
Root = false # true: outside $HOME, through pkexec; "Auto": only if Target is
# outside $HOME once rendered (e.g. {PortalsDir})
Executable = false
[[Services]] # Linux: a systemd --user unit, enabled + started; without
@@ -212,7 +233,8 @@ WorkingDir = "{ConfigDir}"
Option = "Autostart"
[[Services]] # Windows: run by ServiceRun.py from a Startup-folder shortcut
Platform = "Windows" # (both: add ../../ServiceRun.py as a Template to {ProgramDir})
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}"
@@ -223,6 +245,8 @@ 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"
+44 -6
View File
@@ -1,27 +1,33 @@
"""SaraSuite updating itself from its Gitea releases.
Packaging/Release.py publishes a release (tag v<AppVersion>) with the
AppImage and SaraSuite.exe attached. Here SaraSuite asks Gitea for the latest
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, IsFrozen, IsWindows, LoadConfig
from Core import AppName, AppVersion, ChildEnvironment, IsFrozen, IsMac, IsWindows, LoadConfig, MacApp
from Sources import GetBytes, GetJson, SourceError
AssetName = f"{AppName}.exe" if IsWindows else 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
@@ -32,9 +38,12 @@ class Release:
def RunningAppImage() -> Optional[Path]:
"""The packaged SaraSuite file that's running: the AppImage on Linux, the
.exe on Windows. None when running from source."""
.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
@@ -46,6 +55,25 @@ def CleanUpOldExe() -> 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("."):
@@ -88,8 +116,15 @@ 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"MZ" if IsWindows else b"\x7fELF"):
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)
if IsWindows:
@@ -115,7 +150,10 @@ def Restart() -> None:
Environment = ChildEnvironment()
for Key in ("APPIMAGE", "APPDIR", "ARGV0", "OWD", "_MEIPASS2", "_PYI_APPLICATION_HOME_DIR"):
Environment.pop(Key, None) # the new one sets its own
if IsWindows:
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))
+9 -2
View File
@@ -1,9 +1,10 @@
"""Background services SaraSuite runs itself, where there's no systemd --user
to do it: Windows, and Linux with another init system (runit on Void, OpenRC,
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,
@@ -18,7 +19,7 @@ import time
from pathlib import Path
from typing import Callable, Optional
from Core import ChildEnvironment, IsWindows, ServicesDir
from Core import ChildEnvironment, IsMac, IsWindows, ServicesDir
LogFn = Callable[[str], None]
@@ -41,6 +42,12 @@ def ServicePid(Name: str) -> Optional[int]:
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
+13 -9
View File
@@ -8,8 +8,8 @@ from typing import Callable
import flet as ft
from Core import (
AppConfig, BundledFontFamily, ChildEnvironment, ChipButtonStyle, IsWindows, LoadSharedSettings, Palette,
SharedSettings, ThemesDir,
AppConfig, BundledFontFamily, ChildEnvironment, ChipButtonStyle, EnsureSharedThemes, IsMac, IsWindows,
LoadSharedSettings, Palette, SharedConfigPath, SharedSettings, ThemesDir,
)
from Installer import DefaultAnswers, DefaultOptions
from Manifests import Program
@@ -25,18 +25,22 @@ 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, or the registry on Windows), with
the bundled font first."""
if IsWindows:
import WindowsSupport
"""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 = WindowsSupport.FontFamilies()
Families = Support.FontFamilies()
Families.discard(BundledFontFamily)
return [BundledFontFamily] + sorted(Families, key=str.lower)
try:
@@ -97,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,
+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"