5 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
6 changed files with 151 additions and 23 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.3" 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(
+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
+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]]
+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)