mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-14 02:28:11 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1feb6d4336 | ||
|
|
768757a062 | ||
|
|
ce62249487 | ||
|
|
70c9730017 | ||
|
|
2061babb43 |
No files matched your search
@@ -13,6 +13,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,7 +25,7 @@ import tomlkit
|
||||
AppName = "SaraSuite"
|
||||
# Compared with the latest Gitea release to offer self-updates (SelfUpdate.py).
|
||||
# Bump it, then run Packaging/Release.py, to publish a new SaraSuite.
|
||||
AppVersion = "1.2.3"
|
||||
AppVersion = "1.2.5"
|
||||
IsFrozen = bool(getattr(sys, "frozen", False)) # running from the AppImage / .exe
|
||||
IsWindows = sys.platform == "win32"
|
||||
Platform = "Windows" if IsWindows else "Linux" # matches manifests' Platforms / Platform
|
||||
@@ -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:
|
||||
"""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
|
||||
@@ -229,6 +269,26 @@ def ChildEnvironment() -> dict[str, str]:
|
||||
return Environment
|
||||
|
||||
|
||||
def UserPortalsSupported() -> bool:
|
||||
"""Whether xdg-desktop-portal reads portal files from
|
||||
~/.local/share/xdg-desktop-portal/portals (1.17 and newer), so installing
|
||||
one needs no password. True when it isn't installed either: then a
|
||||
portal file can't be used anywhere, so it might as well not need root."""
|
||||
import subprocess
|
||||
|
||||
for Candidate in ("/usr/libexec/xdg-desktop-portal", "/usr/lib/xdg-desktop-portal",
|
||||
"/usr/lib/x86_64-linux-gnu/xdg-desktop-portal", "/usr/local/libexec/xdg-desktop-portal"):
|
||||
if not Path(Candidate).is_file():
|
||||
continue
|
||||
try:
|
||||
Output = subprocess.run([Candidate, "--version"], capture_output=True, text=True, timeout=10).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
Found = re.search(r"(\d+)\.(\d+)", Output)
|
||||
return bool(Found) and (int(Found[1]), int(Found[2])) >= (1, 17)
|
||||
return True
|
||||
|
||||
|
||||
def CurrentDesktop() -> str:
|
||||
"""First entry of $XDG_CURRENT_DESKTOP as-is (e.g. "COSMIC"), used for
|
||||
portal descriptors' UseIn= and the <desktop>-portals.conf file name.
|
||||
|
||||
+64
-12
@@ -11,6 +11,7 @@ import hashlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
@@ -21,8 +22,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, IsWindows, LoadConfig,
|
||||
PristineDir, StartMenuDir, StartupDir,
|
||||
)
|
||||
from Manifests import Leftover, Program, Render
|
||||
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:
|
||||
"""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 +89,47 @@ def _BackupName(Target: str) -> str:
|
||||
return Target.strip("/").replace("/", "__")
|
||||
|
||||
|
||||
def _Pkexec(Command: list[str], Log: LogFn, Check: bool = True) -> int:
|
||||
"""Run Command as root through pkexec, saying what went wrong in words."""
|
||||
if shutil.which("pkexec") is None:
|
||||
Code = 127
|
||||
else:
|
||||
Code = _Run(["pkexec", *Command], Log, Check=False)
|
||||
if Code == 0:
|
||||
return 0
|
||||
if Code == 126:
|
||||
Problem = "the password prompt was cancelled"
|
||||
elif shutil.which("pkexec") is None:
|
||||
Problem = "pkexec (polkit) isn't installed"
|
||||
else:
|
||||
Problem = ("no password prompt could be shown: polkit needs an authentication agent running "
|
||||
"in your session (e.g. polkit-gnome or lxqt-policykit), which window managers don't start themselves")
|
||||
Message = f"Couldn't do this as root ({Problem}). The command was: sudo {' '.join(Command)}"
|
||||
if Check:
|
||||
raise InstallError(Message)
|
||||
Log(f"Warning: {Message}")
|
||||
return Code
|
||||
|
||||
|
||||
def _NeedsRoot(Template, Target: str) -> bool:
|
||||
if Template.Root == "Auto":
|
||||
return not (Target == str(Home) or Target.startswith(f"{Home}{os.sep}"))
|
||||
return bool(Template.Root)
|
||||
|
||||
|
||||
def _WriteRoot(Target: str, Text: str, Log: LogFn) -> None:
|
||||
with tempfile.NamedTemporaryFile("w", delete=False, suffix=".sarasuite") as Temporary:
|
||||
Temporary.write(Text)
|
||||
os.chmod(Temporary.name, 0o644)
|
||||
try:
|
||||
_Run(["pkexec", "install", "-D", "-m", "644", Temporary.name, Target], Log)
|
||||
_Pkexec(["install", "-D", "-m", "644", Temporary.name, Target], Log)
|
||||
finally:
|
||||
os.unlink(Temporary.name)
|
||||
|
||||
|
||||
def _RemoveRoot(Target: str, Log: LogFn) -> None:
|
||||
_Run(["pkexec", "rm", "-f", Target], Log)
|
||||
# A leftover outside $HOME isn't worth failing an install or removal over.
|
||||
_Pkexec(["rm", "-f", Target], Log, Check=False)
|
||||
|
||||
|
||||
def _ReadIni(FilePath: Path) -> configparser.ConfigParser:
|
||||
@@ -381,7 +418,8 @@ def _ApplyTemplates(Program: Program, Context: dict, OldReceipt: Optional[Receip
|
||||
Target = Render(Template.Target, Context)
|
||||
Text = Render((Program.Folder / "Templates" / Template.Source).read_text(), Context)
|
||||
Previous = OldReceipt.FindFile(Target) if OldReceipt is not None else None
|
||||
Placed = PlacedFile(Target, "Template", Root=Template.Root, Displaced=Previous.Displaced if Previous else "")
|
||||
Root = _NeedsRoot(Template, Target)
|
||||
Placed = PlacedFile(Target, "Template", Root=Root, Displaced=Previous.Displaced if Previous else "")
|
||||
|
||||
# A file that's already there but wasn't placed by SaraSuite (e.g. a
|
||||
# hand-made desktop entry from development) is moved aside, not lost.
|
||||
@@ -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)")
|
||||
|
||||
NewReceipt.Files.append(Placed)
|
||||
if Template.Root:
|
||||
if Root:
|
||||
if Path(Target).exists() and Path(Target).read_text() == Text:
|
||||
continue
|
||||
_WriteRoot(Target, Text, Log)
|
||||
@@ -579,6 +617,20 @@ def _ApplyServices(Program: Program, OldReceipt: Optional[Receipt], NewReceipt:
|
||||
_Systemctl(Log, "disable", "--now", Service.Unit)
|
||||
|
||||
|
||||
def _StopProcesses(Name: str, Log: LogFn) -> None:
|
||||
"""Linux: stop your own processes called Name (like pkill -x -u $USER,
|
||||
which not every system has)."""
|
||||
Stopped = 0
|
||||
for Entry in Path("/proc").iterdir():
|
||||
try:
|
||||
if Entry.name.isdigit() and Entry.stat().st_uid == os.getuid() and (Entry / "comm").read_text().strip() == Name[:15]:
|
||||
os.kill(int(Entry.name), signal.SIGTERM)
|
||||
Stopped += 1
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
Log(f"Stopped {Name} ({Stopped} running) so it starts again with the change" if Stopped else f"{Name} wasn't running")
|
||||
|
||||
|
||||
def _AfterIntegrationChanges(Program: Program, Log: LogFn) -> None:
|
||||
if IsWindows:
|
||||
return # Start Menu shortcuts need no refresh
|
||||
@@ -591,7 +643,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(
|
||||
|
||||
+12
-2
@@ -17,7 +17,7 @@ import tomlkit
|
||||
|
||||
from Core import (
|
||||
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.
|
||||
@@ -55,7 +55,9 @@ class TemplateFile:
|
||||
Source: str # relative to the program's Templates/ folder
|
||||
Target: str
|
||||
Option: str = ""
|
||||
Root: bool = False # lives outside $HOME, written through pkexec
|
||||
# True: lives outside $HOME, written through pkexec. "Auto": only when its
|
||||
# Target turns out to be outside $HOME (e.g. {PortalsDir} on an old system).
|
||||
Root: bool | str = False
|
||||
Executable: bool = False
|
||||
|
||||
|
||||
@@ -239,6 +241,14 @@ class Program:
|
||||
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
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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`,
|
||||
`DataHome`, `CacheHome`, `ProgramDir`, `AppDir`, `Python` (the venv's python), `PythonW`
|
||||
(Windows: its console-less pythonw.exe), `ConfigDir`, `DesktopName` (e.g. `COSMIC`),
|
||||
`Desktop` (e.g. `cosmic`), plus every Question's `Id`. Linux only: `SystemdUserDir`,
|
||||
`Desktop` (e.g. `cosmic`), plus every Question's `Id`. Linux only: `PortalsDir` (your own xdg-desktop-portal folder where it's 1.17+, else
|
||||
`/usr/share/...`), `PortalsConf` (`<desktop>-portals.conf`, or `portals.conf` without a desktop name),
|
||||
`DesktopPortal` (`<desktop>;` or nothing), `SystemdUserDir`,
|
||||
`ApplicationsDir`, `DBusServicesDir`. Windows only: `StartMenuDir`, `StartupDir`.
|
||||
|
||||
Every list item (`[[Options]]`, `[[Templates]]`, `[[Services]]`, ...) can carry
|
||||
@@ -200,7 +202,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
|
||||
|
||||
+4
-3
@@ -8,8 +8,8 @@ from typing import Callable
|
||||
import flet as ft
|
||||
|
||||
from Core import (
|
||||
AppConfig, BundledFontFamily, ChildEnvironment, ChipButtonStyle, IsWindows, LoadSharedSettings, Palette,
|
||||
SharedSettings, ThemesDir,
|
||||
AppConfig, BundledFontFamily, ChildEnvironment, ChipButtonStyle, EnsureSharedThemes, IsWindows,
|
||||
LoadSharedSettings, Palette, SharedSettings, ThemesDir,
|
||||
)
|
||||
from Installer import DefaultAnswers, DefaultOptions
|
||||
from Manifests import Program
|
||||
@@ -25,8 +25,9 @@ def _Note(Text: str) -> ft.Text:
|
||||
|
||||
|
||||
def _Themes() -> list[str]:
|
||||
EnsureSharedThemes()
|
||||
Names = {Path.stem for Path in ThemesDir.glob("*.toml")} if ThemesDir.is_dir() else set()
|
||||
Names.add("CatppuccinMocha") # built in, even before SaraFem seeds the folder
|
||||
Names.add("CatppuccinMocha") # built in, even where the folder can't be written
|
||||
return sorted(Names)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user