diff --git a/Core.py b/Core.py index b01f316..40a0f4e 100644 --- a/Core.py +++ b/Core.py @@ -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 @@ -268,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 -portals.conf file name. diff --git a/Installer.py b/Installer.py index fb23a53..5817bac 100644 --- a/Installer.py +++ b/Installer.py @@ -21,8 +21,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 @@ -81,18 +81,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 +410,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 +423,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) diff --git a/Manifests.py b/Manifests.py index 031c20b..433a008 100644 --- a/Manifests.py +++ b/Manifests.py @@ -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 diff --git a/Programs/SaraFem/Manifest.toml b/Programs/SaraFem/Manifest.toml index ff60a16..d3d531f 100644 --- a/Programs/SaraFem/Manifest.toml +++ b/Programs/SaraFem/Manifest.toml @@ -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]]