mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-15 02:33:17 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1feb6d4336 | ||
|
|
768757a062 | ||
|
|
ce62249487 |
No files matched your search
@@ -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.4"
|
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
|
||||||
@@ -268,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.
|
||||||
|
|||||||
+64
-12
@@ -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)}")
|
||||||
Process = subprocess.Popen(
|
try:
|
||||||
Command, cwd=Cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=ChildEnvironment(),
|
Process = subprocess.Popen(
|
||||||
encoding="utf-8", errors="replace",
|
Command, cwd=Cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=ChildEnvironment(),
|
||||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), # no console flashes on Windows
|
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:
|
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
@@ -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
|
||||||
|
|||||||
@@ -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]]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user