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

No files matched your search

+64 -4
View File
@@ -13,6 +13,7 @@ picked for one Sara program looks the same in all of them.
""" """
import os import os
import re
import shutil import shutil
import sys import sys
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
@@ -24,7 +25,7 @@ import tomlkit
AppName = "SaraSuite" AppName = "SaraSuite"
# Compared with the latest Gitea release to offer self-updates (SelfUpdate.py). # Compared with the latest Gitea release to offer self-updates (SelfUpdate.py).
# Bump it, then run Packaging/Release.py, to publish a new SaraSuite. # Bump it, then run Packaging/Release.py, to publish a new SaraSuite.
AppVersion = "1.2.2" AppVersion = "1.2.5"
IsFrozen = bool(getattr(sys, "frozen", False)) # running from the AppImage / .exe IsFrozen = bool(getattr(sys, "frozen", False)) # running from the AppImage / .exe
IsWindows = sys.platform == "win32" IsWindows = sys.platform == "win32"
Platform = "Windows" if IsWindows else "Linux" # matches manifests' Platforms / Platform Platform = "Windows" if IsWindows else "Linux" # matches manifests' Platforms / Platform
@@ -109,11 +110,50 @@ _ThemeRoleMap = {
} }
# The themes every SaraSuite program seeds into ThemesDir if they're missing
# (same values as SaraFem's and SaraView's), so whichever program is installed
# first, the folder and these three are there to choose from.
BuiltinThemes: dict[str, dict[str, str]] = {
"CatppuccinMocha": {
"Background": "#1e1e2e", "Surface": "#181825", "SurfaceAlt": "#313244", "Border": "#45475a",
"Text": "#cdd6f4", "TextMuted": "#6c7086", "Black": "#11111b", "White": "#cdd6f4",
"Red": "#f38ba8", "Orange": "#fab387", "Yellow": "#f9e2af", "Green": "#a6e3a1",
"Blue": "#89b4fa", "Purple": "#cba6f7",
},
"Forest": {
"Background": "#1b2420", "Surface": "#141b17", "SurfaceAlt": "#243029", "Border": "#334539",
"Text": "#d6e5d0", "TextMuted": "#7c9184", "Black": "#0d120f", "White": "#eef5ea",
"Red": "#e0685a", "Orange": "#e0a458", "Yellow": "#e0c458", "Green": "#7fb069",
"Blue": "#5c9e8f", "Purple": "#9b7fb0",
},
"Gruvbox": {
"Background": "#282828", "Surface": "#1d2021", "SurfaceAlt": "#3c3836", "Border": "#504945",
"Text": "#ebdbb2", "TextMuted": "#a89984", "Black": "#1d2021", "White": "#fbf1c7",
"Red": "#fb4934", "Orange": "#fe8019", "Yellow": "#fabd2f", "Green": "#b8bb26",
"Blue": "#83a598", "Purple": "#d3869b",
},
}
def EnsureSharedThemes() -> None:
"""Create ThemesDir with the built-in themes that aren't there yet
(never overwriting one: it may have been edited)."""
try:
ThemesDir.mkdir(parents=True, exist_ok=True)
for Name, RoleValues in BuiltinThemes.items():
ThemeFile = ThemesDir / f"{Name}.toml"
if not ThemeFile.exists():
ThemeFile.write_text(tomlkit.dumps({"Palette": RoleValues}))
except OSError:
pass # read-only or full: the built-in Catppuccin Mocha values still apply
def ApplyTheme(ThemeName: str) -> None: def ApplyTheme(ThemeName: str) -> None:
"""Mutate Palette in place from ~/.config/SaraSuite/Themes/<ThemeName>.toml. """Mutate Palette in place from ~/.config/SaraSuite/Themes/<ThemeName>.toml.
Falls back to the built-in Catppuccin Mocha values if the file is missing Seeds the built-in themes first (EnsureSharedThemes); a missing file keeps
(SaraFem seeds that directory; SaraSuite only reads it). Must run before the built-in Catppuccin Mocha values. Must run before any control reads
any control reads Palette.X.""" Palette.X."""
EnsureSharedThemes()
ThemeFile = ThemesDir / f"{ThemeName}.toml" ThemeFile = ThemesDir / f"{ThemeName}.toml"
if not ThemeFile.exists(): if not ThemeFile.exists():
return return
@@ -229,6 +269,26 @@ def ChildEnvironment() -> dict[str, str]:
return Environment 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: def CurrentDesktop() -> str:
"""First entry of $XDG_CURRENT_DESKTOP as-is (e.g. "COSMIC"), used for """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.
+59 -7
View File
@@ -11,6 +11,7 @@ import hashlib
import os import os
import re import re
import shutil import shutil
import signal
import subprocess import subprocess
import tempfile import tempfile
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -21,8 +22,8 @@ from typing import Callable, Optional
from ConfigMerge import ApplySet, MergeConfig, MergeResult from ConfigMerge import ApplySet, MergeConfig, MergeResult
import Services import Services
from Core import ( from Core import (
AutostartDir, BackupsDir, ChildEnvironment, DownloadsDir, HasSystemd, IsWindows, LoadConfig, PristineDir, AutostartDir, BackupsDir, ChildEnvironment, DownloadsDir, HasSystemd, Home, IsWindows, LoadConfig,
StartMenuDir, StartupDir, PristineDir, StartMenuDir, StartupDir,
) )
from Manifests import Leftover, Program, Render from Manifests import Leftover, Program, Render
from Receipts import IniRecord, LoadReceipt, PlacedFile, Receipt from Receipts import IniRecord, LoadReceipt, PlacedFile, Receipt
@@ -45,11 +46,18 @@ def _Now() -> str:
def _Run(Command: list[str], Log: LogFn, Check: bool = True, Cwd: Optional[Path] = None) -> int: def _Run(Command: list[str], Log: LogFn, Check: bool = True, Cwd: Optional[Path] = None) -> int:
"""Run a command, streaming its output into the log.""" """Run a command, streaming its output into the log."""
Log(f"$ {' '.join(Command)}") Log(f"$ {' '.join(Command)}")
try:
Process = subprocess.Popen( Process = subprocess.Popen(
Command, cwd=Cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=ChildEnvironment(), Command, cwd=Cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=ChildEnvironment(),
encoding="utf-8", errors="replace", encoding="utf-8", errors="replace",
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), # no console flashes on Windows 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: for Line in Process.stdout:
Log(f" {Line.rstrip()}") Log(f" {Line.rstrip()}")
Code = Process.wait() Code = Process.wait()
@@ -81,18 +89,47 @@ def _BackupName(Target: str) -> str:
return Target.strip("/").replace("/", "__") return Target.strip("/").replace("/", "__")
def _Pkexec(Command: list[str], Log: LogFn, Check: bool = True) -> int:
"""Run Command as root through pkexec, saying what went wrong in words."""
if shutil.which("pkexec") is None:
Code = 127
else:
Code = _Run(["pkexec", *Command], Log, Check=False)
if Code == 0:
return 0
if Code == 126:
Problem = "the password prompt was cancelled"
elif shutil.which("pkexec") is None:
Problem = "pkexec (polkit) isn't installed"
else:
Problem = ("no password prompt could be shown: polkit needs an authentication agent running "
"in your session (e.g. polkit-gnome or lxqt-policykit), which window managers don't start themselves")
Message = f"Couldn't do this as root ({Problem}). The command was: sudo {' '.join(Command)}"
if Check:
raise InstallError(Message)
Log(f"Warning: {Message}")
return Code
def _NeedsRoot(Template, Target: str) -> bool:
if Template.Root == "Auto":
return not (Target == str(Home) or Target.startswith(f"{Home}{os.sep}"))
return bool(Template.Root)
def _WriteRoot(Target: str, Text: str, Log: LogFn) -> None: def _WriteRoot(Target: str, Text: str, Log: LogFn) -> None:
with tempfile.NamedTemporaryFile("w", delete=False, suffix=".sarasuite") as Temporary: with tempfile.NamedTemporaryFile("w", delete=False, suffix=".sarasuite") as Temporary:
Temporary.write(Text) Temporary.write(Text)
os.chmod(Temporary.name, 0o644) os.chmod(Temporary.name, 0o644)
try: try:
_Run(["pkexec", "install", "-D", "-m", "644", Temporary.name, Target], Log) _Pkexec(["install", "-D", "-m", "644", Temporary.name, Target], Log)
finally: finally:
os.unlink(Temporary.name) os.unlink(Temporary.name)
def _RemoveRoot(Target: str, Log: LogFn) -> None: 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: def _ReadIni(FilePath: Path) -> configparser.ConfigParser:
@@ -381,7 +418,8 @@ def _ApplyTemplates(Program: Program, Context: dict, OldReceipt: Optional[Receip
Target = Render(Template.Target, Context) Target = Render(Template.Target, Context)
Text = Render((Program.Folder / "Templates" / Template.Source).read_text(), Context) Text = Render((Program.Folder / "Templates" / Template.Source).read_text(), Context)
Previous = OldReceipt.FindFile(Target) if OldReceipt is not None else None Previous = OldReceipt.FindFile(Target) if OldReceipt is not None else None
Placed = PlacedFile(Target, "Template", Root=Template.Root, Displaced=Previous.Displaced if Previous else "") Root = _NeedsRoot(Template, Target)
Placed = PlacedFile(Target, "Template", Root=Root, Displaced=Previous.Displaced if Previous else "")
# A file that's already there but wasn't placed by SaraSuite (e.g. a # 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. # hand-made desktop entry from development) is moved aside, not lost.
@@ -393,7 +431,7 @@ def _ApplyTemplates(Program: Program, Context: dict, OldReceipt: Optional[Receip
Log(f"Moved your existing {Target} aside (it comes back if {Program.Name} is removed)") Log(f"Moved your existing {Target} aside (it comes back if {Program.Name} is removed)")
NewReceipt.Files.append(Placed) NewReceipt.Files.append(Placed)
if Template.Root: if Root:
if Path(Target).exists() and Path(Target).read_text() == Text: if Path(Target).exists() and Path(Target).read_text() == Text:
continue continue
_WriteRoot(Target, Text, Log) _WriteRoot(Target, Text, Log)
@@ -579,6 +617,20 @@ def _ApplyServices(Program: Program, OldReceipt: Optional[Receipt], NewReceipt:
_Systemctl(Log, "disable", "--now", Service.Unit) _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: def _AfterIntegrationChanges(Program: Program, Log: LogFn) -> None:
if IsWindows: if IsWindows:
return # Start Menu shortcuts need no refresh return # Start Menu shortcuts need no refresh
@@ -591,7 +643,7 @@ def _AfterIntegrationChanges(Program: Program, Log: LogFn) -> None:
else: else:
# No systemd to restart it: stop it, and D-Bus starts it again # 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). # 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( def Apply(
+18
View File
@@ -38,6 +38,23 @@ else:
_MenuLabel = "Start menu" if IsWindows else "app menu" _MenuLabel = "Start menu" if IsWindows else "app menu"
def _KeepFallbackLibraries() -> None:
"""Linux AppImage: copy the bundled libsecret (and what it needs) to
~/.local/share/SaraSuite/Libraries, for FletIdentity.py to give the Flet
client of every installed program on systems without it."""
Bundled = Path(getattr(sys, "_MEIPASS", "")) / "FallbackLibs"
if IsWindows or not Bundled.is_dir():
return
Kept = DataDir / "Libraries"
try:
Kept.mkdir(parents=True, exist_ok=True)
for Library in Bundled.iterdir():
if not (Kept / Library.name).exists() or (Kept / Library.name).stat().st_size != Library.stat().st_size:
shutil.copy2(Library, Kept / Library.name)
except OSError:
pass # only a fallback
def _LoadFletIdentity(): def _LoadFletIdentity():
# Lives in Programs/ (bundled as data) because installed programs get a # Lives in Programs/ (bundled as data) because installed programs get a
# copy of the same file next to them. # copy of the same file next to them.
@@ -324,6 +341,7 @@ def Main(Page: ft.Page) -> None:
if __name__ == "__main__": if __name__ == "__main__":
_KeepFallbackLibraries() # also before a command (an install from the command line)
if len(sys.argv) > 1: if len(sys.argv) > 1:
if IsWindows and getattr(sys, "frozen", False): if IsWindows and getattr(sys, "frozen", False):
# The .exe is built windowed (no console of its own); write the # The .exe is built windowed (no console of its own); write the
+12 -2
View File
@@ -17,7 +17,7 @@ import tomlkit
from Core import ( from Core import (
AutostartDir, CacheHome, ConfigHome, CurrentDesktop, DataHome, HasSystemd, Home, IsWindows, LoadConfig, AutostartDir, CacheHome, ConfigHome, CurrentDesktop, DataHome, HasSystemd, Home, IsWindows, LoadConfig,
Platform, ProgramsDir, SharedConfigDir, StartMenuDir, StartupDir, Platform, ProgramsDir, SharedConfigDir, StartMenuDir, StartupDir, UserPortalsSupported,
) )
# Never worth copying out of a source checkout, whatever Include says. # Never worth copying out of a source checkout, whatever Include says.
@@ -55,7 +55,9 @@ class TemplateFile:
Source: str # relative to the program's Templates/ folder Source: str # relative to the program's Templates/ folder
Target: str Target: str
Option: 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 Executable: bool = False
@@ -239,6 +241,14 @@ class Program:
AutostartDir=str(AutostartDir), AutostartDir=str(AutostartDir),
ApplicationsDir=str(DataHome / "applications"), ApplicationsDir=str(DataHome / "applications"),
DBusServicesDir=str(DataHome / "dbus-1" / "services"), 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 {}) Context.update(Answers or {})
return Context return Context
+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 ' 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 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 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 \ build/Container/venv/bin/pyinstaller --noconfirm --clean --log-level WARN \
--onefile --windowed --name SaraSuite --collect-data flet \ --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" \ --add-data "$PWD/assets:assets" --add-data "$PWD/Programs:Programs" \
--distpath build/PyInstaller/dist --workpath build/PyInstaller/work --specpath build/PyInstaller \ --distpath build/PyInstaller/dist --workpath build/PyInstaller/work --specpath build/PyInstaller \
"$PWD/Main.py"' "$PWD/Main.py"'
+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. case Flet just uses its normal client.
""" """
import os
import shutil
import sys import sys
from pathlib import Path from pathlib import Path
OriginalId = b"com.appveyor.flet" 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" 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: def ClientFor(AppId: str) -> str:
"""Folder of a client copy whose app ID is AppId ("" if not possible). """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 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: if Binary.count(b"\x00" + OriginalId + b"\x00") != 1:
return "" # not the client layout this was written for return "" # not the client layout this was written for
AddMissingLibraries(Copy.parent / "fallback-libs")
Target = Copy / "flet" Target = Copy / "flet"
if not (Target.exists() and Target.read_bytes() == Patched): if not (Target.exists() and Target.read_bytes() == Patched):
Copy.mkdir(parents=True, exist_ok=True) Copy.mkdir(parents=True, exist_ok=True)
@@ -64,4 +103,16 @@ def ClientFor(AppId: str) -> str:
if __name__ == "__main__": if __name__ == "__main__":
print(ClientFor(sys.argv[1]) if len(sys.argv) > 1 else "") # Only the folder goes to stdout, which the launcher reads: Flet's own
# messages ("Preparing Flet ... for the first use", written straight to
# the file descriptor) go to stderr.
sys.stdout.flush()
Stdout = os.dup(1)
os.dup2(2, 1)
try:
Folder = ClientFor(sys.argv[1]) if len(sys.argv) > 1 else ""
finally:
sys.stdout.flush()
os.dup2(Stdout, 1)
os.close(Stdout)
print(Folder)
+7 -5
View File
@@ -18,7 +18,7 @@ RequirementsFile = "requirements.txt"
[[Options]] [[Options]]
Platform = "Linux" Platform = "Linux"
Id = "FilePicker" 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 Default = true
[[Options]] [[Options]]
@@ -48,16 +48,18 @@ Option = "FilePicker"
[[Templates]] [[Templates]]
Platform = "Linux" Platform = "Linux"
Source = "SaraFem.portal" 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" Option = "FilePicker"
Root = true Root = "Auto"
[[IniEdits]] [[IniEdits]]
Platform = "Linux" Platform = "Linux"
File = "{ConfigHome}/xdg-desktop-portal/{Desktop}-portals.conf" File = "{ConfigHome}/xdg-desktop-portal/{PortalsConf}"
Section = "preferred" Section = "preferred"
Key = "org.freedesktop.impl.portal.FileChooser" Key = "org.freedesktop.impl.portal.FileChooser"
Value = "SaraFem;{Desktop};gtk;" Value = "SaraFem;{DesktopPortal}gtk;"
Option = "FilePicker" Option = "FilePicker"
[[IniEdits]] [[IniEdits]]
+4
View File
@@ -4,5 +4,9 @@
# matches the window to {Name}.desktop (StartupWMClass) instead of showing it # matches the window to {Name}.desktop (StartupWMClass) instead of showing it
# as "com.appveyor.flet". Empty if that fails: Flet then uses its own client. # as "com.appveyor.flet". Empty if that fails: Flet then uses its own client.
export FLET_VIEW_PATH="$("{Python}" "{ProgramDir}/FletIdentity.py" "srhtnin.{Name}")" export FLET_VIEW_PATH="$("{Python}" "{ProgramDir}/FletIdentity.py" "srhtnin.{Name}")"
# Libraries Flet's window needs that this system lacks (libsecret), from
# SaraSuite: FletIdentity.py puts them beside the client copy.
Fallback="$FLET_VIEW_PATH/../fallback-libs"
[ -n "$FLET_VIEW_PATH" ] && [ -d "$Fallback" ] && export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}$Fallback"
cd "{AppDir}" || exit 1 cd "{AppDir}" || exit 1
exec "{Python}" Main.py "$@" exec "{Python}" Main.py "$@"
+4
View File
@@ -4,5 +4,9 @@
# matches the window to {Name}.desktop (StartupWMClass) instead of showing it # matches the window to {Name}.desktop (StartupWMClass) instead of showing it
# as "com.appveyor.flet". Empty if that fails: Flet then uses its own client. # as "com.appveyor.flet". Empty if that fails: Flet then uses its own client.
export FLET_VIEW_PATH="$("{Python}" "{ProgramDir}/FletIdentity.py" "srhtnin.{Name}")" export FLET_VIEW_PATH="$("{Python}" "{ProgramDir}/FletIdentity.py" "srhtnin.{Name}")"
# Libraries Flet's window needs that this system lacks (libsecret), from
# SaraSuite: FletIdentity.py puts them beside the client copy.
Fallback="$FLET_VIEW_PATH/../fallback-libs"
[ -n "$FLET_VIEW_PATH" ] && [ -d "$Fallback" ] && export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}$Fallback"
cd "{AppDir}" || exit 1 cd "{AppDir}" || exit 1
exec "{Python}" Main.py "$@" exec "{Python}" Main.py "$@"
+4
View File
@@ -4,5 +4,9 @@
# matches the window to {Name}.desktop (StartupWMClass) instead of showing it # matches the window to {Name}.desktop (StartupWMClass) instead of showing it
# as "com.appveyor.flet". Empty if that fails: Flet then uses its own client. # as "com.appveyor.flet". Empty if that fails: Flet then uses its own client.
export FLET_VIEW_PATH="$("{Python}" "{ProgramDir}/FletIdentity.py" "srhtnin.{Name}")" export FLET_VIEW_PATH="$("{Python}" "{ProgramDir}/FletIdentity.py" "srhtnin.{Name}")"
# Libraries Flet's window needs that this system lacks (libsecret), from
# SaraSuite: FletIdentity.py puts them beside the client copy.
Fallback="$FLET_VIEW_PATH/../fallback-libs"
[ -n "$FLET_VIEW_PATH" ] && [ -d "$Fallback" ] && export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}$Fallback"
cd "{AppDir}" || exit 1 cd "{AppDir}" || exit 1
exec "{Python}" Main.py "$@" exec "{Python}" Main.py "$@"
+4
View File
@@ -6,5 +6,9 @@ export FLET_DESKTOP_FLAVOR=full
# matches the window to {Name}.desktop (StartupWMClass) instead of showing it # matches the window to {Name}.desktop (StartupWMClass) instead of showing it
# as "com.appveyor.flet". Empty if that fails: Flet then uses its own client. # as "com.appveyor.flet". Empty if that fails: Flet then uses its own client.
export FLET_VIEW_PATH="$("{Python}" "{ProgramDir}/FletIdentity.py" "srhtnin.{Name}")" export FLET_VIEW_PATH="$("{Python}" "{ProgramDir}/FletIdentity.py" "srhtnin.{Name}")"
# Libraries Flet's window needs that this system lacks (libsecret), from
# SaraSuite: FletIdentity.py puts them beside the client copy.
Fallback="$FLET_VIEW_PATH/../fallback-libs"
[ -n "$FLET_VIEW_PATH" ] && [ -d "$Fallback" ] && export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}$Fallback"
cd "{AppDir}" || exit 1 cd "{AppDir}" || exit 1
exec "{Python}" Main.py "$@" exec "{Python}" Main.py "$@"
+4
View File
@@ -4,5 +4,9 @@
# matches the window to {Name}.desktop (StartupWMClass) instead of showing it # matches the window to {Name}.desktop (StartupWMClass) instead of showing it
# as "com.appveyor.flet". Empty if that fails: Flet then uses its own client. # as "com.appveyor.flet". Empty if that fails: Flet then uses its own client.
export FLET_VIEW_PATH="$("{Python}" "{ProgramDir}/FletIdentity.py" "srhtnin.{Name}")" export FLET_VIEW_PATH="$("{Python}" "{ProgramDir}/FletIdentity.py" "srhtnin.{Name}")"
# Libraries Flet's window needs that this system lacks (libsecret), from
# SaraSuite: FletIdentity.py puts them beside the client copy.
Fallback="$FLET_VIEW_PATH/../fallback-libs"
[ -n "$FLET_VIEW_PATH" ] && [ -d "$Fallback" ] && export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}$Fallback"
cd "{AppDir}/Client" || exit 1 cd "{AppDir}/Client" || exit 1
exec "{Python}" Main.py "$@" exec "{Python}" Main.py "$@"
+5 -2
View File
@@ -152,7 +152,9 @@ Anything the manifest can't describe goes in an optional `Programs/<Name>/Hooks.
Targets, templates and `Set` values can use `{Variables}`: `Name`, `GitHost`, `Home`, `ConfigHome`, Targets, templates and `Set` values can use `{Variables}`: `Name`, `GitHost`, `Home`, `ConfigHome`,
`DataHome`, `CacheHome`, `ProgramDir`, `AppDir`, `Python` (the venv's python), `PythonW` `DataHome`, `CacheHome`, `ProgramDir`, `AppDir`, `Python` (the venv's python), `PythonW`
(Windows: its console-less pythonw.exe), `ConfigDir`, `DesktopName` (e.g. `COSMIC`), (Windows: its console-less pythonw.exe), `ConfigDir`, `DesktopName` (e.g. `COSMIC`),
`Desktop` (e.g. `cosmic`), plus every Question's `Id`. Linux only: `SystemdUserDir`, `Desktop` (e.g. `cosmic`), plus every Question's `Id`. Linux only: `PortalsDir` (your own xdg-desktop-portal folder where it's 1.17+, else
`/usr/share/...`), `PortalsConf` (`<desktop>-portals.conf`, or `portals.conf` without a desktop name),
`DesktopPortal` (`<desktop>;` or nothing), `SystemdUserDir`,
`ApplicationsDir`, `DBusServicesDir`. Windows only: `StartMenuDir`, `StartupDir`. `ApplicationsDir`, `DBusServicesDir`. Windows only: `StartMenuDir`, `StartupDir`.
Every list item (`[[Options]]`, `[[Templates]]`, `[[Services]]`, ...) can carry Every list item (`[[Options]]`, `[[Templates]]`, `[[Services]]`, ...) can carry
@@ -200,7 +202,8 @@ Set = { "Paths.RootDir" = "{RootDir}" }
Source = "Example.service" # in Templates/ Source = "Example.service" # in Templates/
Target = "{SystemdUserDir}/Example.service" Target = "{SystemdUserDir}/Example.service"
Option = "Autostart" # optional: only while the option is ticked 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 Executable = false
[[Services]] # Linux: a systemd --user unit, enabled + started; without [[Services]] # Linux: a systemd --user unit, enabled + started; without
+4 -3
View File
@@ -8,8 +8,8 @@ from typing import Callable
import flet as ft import flet as ft
from Core import ( from Core import (
AppConfig, BundledFontFamily, ChildEnvironment, ChipButtonStyle, IsWindows, LoadSharedSettings, Palette, AppConfig, BundledFontFamily, ChildEnvironment, ChipButtonStyle, EnsureSharedThemes, IsWindows,
SharedSettings, ThemesDir, LoadSharedSettings, Palette, SharedSettings, ThemesDir,
) )
from Installer import DefaultAnswers, DefaultOptions from Installer import DefaultAnswers, DefaultOptions
from Manifests import Program from Manifests import Program
@@ -25,8 +25,9 @@ def _Note(Text: str) -> ft.Text:
def _Themes() -> list[str]: def _Themes() -> list[str]:
EnsureSharedThemes()
Names = {Path.stem for Path in ThemesDir.glob("*.toml")} if ThemesDir.is_dir() else set() 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) return sorted(Names)