mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-15 10:33:17 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1feb6d4336 | ||
|
|
768757a062 | ||
|
|
ce62249487 | ||
|
|
70c9730017 | ||
|
|
2061babb43 | ||
|
|
21ae6379ac | ||
|
|
63e06eea40 | ||
|
|
5890b52da6 | ||
|
|
61c992f959 | ||
|
|
f63e708572 | ||
|
|
e7e233951f |
No files matched your search
@@ -13,6 +13,8 @@ picked for one Sara program looks the same in all of them.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -23,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.1"
|
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
|
||||||
@@ -64,7 +66,14 @@ ReceiptsDir = DataDir / "Receipts" # what each install placed, for exact remova
|
|||||||
PristineDir = DataDir / "Pristine" # the default configs as last installed (merge base)
|
PristineDir = DataDir / "Pristine" # the default configs as last installed (merge base)
|
||||||
BackupsDir = DataDir / "Backups" # displaced files and pre-merge config copies
|
BackupsDir = DataDir / "Backups" # displaced files and pre-merge config copies
|
||||||
DownloadsDir = CacheHome / "SaraSuite" / "Downloads" # extracted source archives
|
DownloadsDir = CacheHome / "SaraSuite" / "Downloads" # extracted source archives
|
||||||
ServicesDir = DataDir / "Services" # Windows: <Name>.pid of each running background service
|
# <Name>.pid and <Name>.log of each service SaraSuite runs itself (Services.py).
|
||||||
|
ServicesDir = DataDir / "Services"
|
||||||
|
|
||||||
|
# Linux: whether systemd runs the system, and so --user services. Without it
|
||||||
|
# (runit on Void, OpenRC, s6, dinit, ...) services run the way they do on
|
||||||
|
# Windows (Services.py), started at login from AutostartDir.
|
||||||
|
HasSystemd = not IsWindows and Path("/run/systemd/system").is_dir() and shutil.which("systemctl") is not None
|
||||||
|
AutostartDir = ConfigHome / "autostart"
|
||||||
|
|
||||||
# Bundled read-only files (program manifests, fonts). Inside a PyInstaller
|
# Bundled read-only files (program manifests, fonts). Inside a PyInstaller
|
||||||
# build they're unpacked to sys._MEIPASS rather than next to the code.
|
# build they're unpacked to sys._MEIPASS rather than next to the code.
|
||||||
@@ -101,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
|
||||||
@@ -221,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.
|
||||||
|
|||||||
+109
-18
@@ -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
|
||||||
@@ -19,7 +20,11 @@ from pathlib import Path, PurePosixPath
|
|||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from ConfigMerge import ApplySet, MergeConfig, MergeResult
|
from ConfigMerge import ApplySet, MergeConfig, MergeResult
|
||||||
from Core import BackupsDir, ChildEnvironment, DownloadsDir, IsWindows, LoadConfig, PristineDir, StartMenuDir, StartupDir
|
import Services
|
||||||
|
from Core import (
|
||||||
|
AutostartDir, BackupsDir, ChildEnvironment, DownloadsDir, HasSystemd, Home, IsWindows, LoadConfig,
|
||||||
|
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
|
||||||
from Sources import ClearDownloads, Download, LatestCommit, SourceError
|
from Sources import ClearDownloads, Download, LatestCommit, SourceError
|
||||||
@@ -41,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()
|
||||||
@@ -55,6 +67,8 @@ def _Run(Command: list[str], Log: LogFn, Check: bool = True, Cwd: Optional[Path]
|
|||||||
|
|
||||||
|
|
||||||
def _Systemctl(Log: LogFn, *Args: str, Check: bool = False) -> int:
|
def _Systemctl(Log: LogFn, *Args: str, Check: bool = False) -> int:
|
||||||
|
if not HasSystemd:
|
||||||
|
return 0 # services run through Services.py instead
|
||||||
return _Run(["systemctl", "--user", *Args], Log, Check=Check)
|
return _Run(["systemctl", "--user", *Args], Log, Check=Check)
|
||||||
|
|
||||||
|
|
||||||
@@ -75,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:
|
||||||
@@ -375,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.
|
||||||
@@ -387,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)
|
||||||
@@ -499,11 +543,30 @@ def _ApplyShortcuts(Program: Program, Context: dict, OldReceipt: Optional[Receip
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _ApplyWindowsServices(Program: Program, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None:
|
def _PlaceAutostart(Program: Program, Service, Command: tuple, OldReceipt: Optional[Receipt],
|
||||||
"""A Startup-folder shortcut runs the service at login (through
|
NewReceipt: Receipt, Log: LogFn) -> None:
|
||||||
ServiceRun.py, which records its PID); enabling also starts it now."""
|
"""Linux without systemd: the XDG autostart entry that starts a service at
|
||||||
import WindowsSupport
|
login. Recorded like a shortcut, so it goes when the service is turned off
|
||||||
|
or the program removed (and one that was already there comes back)."""
|
||||||
|
Entry = AutostartDir / f"{Service.Name}.desktop"
|
||||||
|
Previous = OldReceipt.FindFile(str(Entry)) if OldReceipt is not None else None
|
||||||
|
Placed = PlacedFile(str(Entry), "Shortcut", Displaced=Previous.Displaced if Previous else "")
|
||||||
|
if Previous is None and Entry.exists():
|
||||||
|
Displaced = BackupsDir / Program.Name / "Displaced" / _BackupName(str(Entry))
|
||||||
|
Displaced.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(Entry, Displaced)
|
||||||
|
Placed.Displaced = str(Displaced)
|
||||||
|
Entry.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
Entry.write_text(Services.AutostartEntry(Service.Name, *Command))
|
||||||
|
NewReceipt.Files.append(Placed)
|
||||||
|
Log(f"Wrote {Entry}")
|
||||||
|
|
||||||
|
|
||||||
|
def _ApplyStandInServices(Program: Program, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None:
|
||||||
|
"""Services SaraSuite runs itself (Windows; Linux without systemd): at
|
||||||
|
login from a Startup-folder shortcut / an autostart entry, through
|
||||||
|
ServiceRun.py; enabling also starts it now. An update restarts it, so
|
||||||
|
it runs the new code."""
|
||||||
WasEnabled = set(OldReceipt.EnabledServices) if OldReceipt else set()
|
WasEnabled = set(OldReceipt.EnabledServices) if OldReceipt else set()
|
||||||
Runner = Program.ProgramDir / "ServiceRun.py"
|
Runner = Program.ProgramDir / "ServiceRun.py"
|
||||||
for Service in Program.Services:
|
for Service in Program.Services:
|
||||||
@@ -511,13 +574,19 @@ def _ApplyWindowsServices(Program: Program, Context: dict, OldReceipt: Optional[
|
|||||||
WorkingDir = Path(Render(Service.WorkingDir, Context)) if Service.WorkingDir else Program.AppDir
|
WorkingDir = Path(Render(Service.WorkingDir, Context)) if Service.WorkingDir else Program.AppDir
|
||||||
if Program.OptionEnabled(Service.Option, NewReceipt.Options):
|
if Program.OptionEnabled(Service.Option, NewReceipt.Options):
|
||||||
NewReceipt.EnabledServices.append(Service.Id)
|
NewReceipt.EnabledServices.append(Service.Id)
|
||||||
|
if IsWindows:
|
||||||
_PlaceShortcut(
|
_PlaceShortcut(
|
||||||
Program, StartupDir / f"{Service.Name}.lnk", f'"{Runner}" {Service.Name} "{Script}"',
|
Program, StartupDir / f"{Service.Name}.lnk", f'"{Runner}" {Service.Name} "{Script}" "{WorkingDir}"',
|
||||||
WorkingDir, None, f"{Service.Name} (background service)", OldReceipt, NewReceipt, Log,
|
WorkingDir, None, f"{Service.Name} (background service)", OldReceipt, NewReceipt, Log,
|
||||||
)
|
)
|
||||||
WindowsSupport.StartService(Service.Name, Program.PythonW, Runner, Script, WorkingDir, Log)
|
else:
|
||||||
|
_PlaceAutostart(Program, Service, (Program.Python, Runner, Script, WorkingDir), OldReceipt, NewReceipt, Log)
|
||||||
|
if Service.Id in WasEnabled and not IsWindows: # Windows stopped it before the update
|
||||||
|
Services.Restart(Service.Name, Program.PythonW, Runner, Script, WorkingDir, Log)
|
||||||
|
else:
|
||||||
|
Services.Start(Service.Name, Program.PythonW, Runner, Script, WorkingDir, Log)
|
||||||
elif Service.Id in WasEnabled:
|
elif Service.Id in WasEnabled:
|
||||||
WindowsSupport.StopService(Service.Name, Log)
|
Services.Stop(Service.Name, Log)
|
||||||
|
|
||||||
|
|
||||||
def _StopWindowsServices(Program: Program, Receipt: Optional[Receipt], Log: LogFn) -> None:
|
def _StopWindowsServices(Program: Program, Receipt: Optional[Receipt], Log: LogFn) -> None:
|
||||||
@@ -525,17 +594,15 @@ def _StopWindowsServices(Program: Program, Receipt: Optional[Receipt], Log: LogF
|
|||||||
its files or venv are replaced or removed."""
|
its files or venv are replaced or removed."""
|
||||||
if not IsWindows or Receipt is None:
|
if not IsWindows or Receipt is None:
|
||||||
return
|
return
|
||||||
import WindowsSupport
|
|
||||||
|
|
||||||
for Name in Receipt.EnabledServices:
|
for Name in Receipt.EnabledServices:
|
||||||
WindowsSupport.StopService(Name, Log)
|
Services.Stop(Name, Log)
|
||||||
|
|
||||||
|
|
||||||
def _ApplyServices(Program: Program, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn, Context: Optional[dict] = None) -> None:
|
def _ApplyServices(Program: Program, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn, Context: Optional[dict] = None) -> None:
|
||||||
if not Program.Services and not (OldReceipt and OldReceipt.EnabledServices):
|
if not Program.Services and not (OldReceipt and OldReceipt.EnabledServices):
|
||||||
return
|
return
|
||||||
if IsWindows:
|
if IsWindows or not HasSystemd:
|
||||||
_ApplyWindowsServices(Program, Context or Program.Context(NewReceipt.Answers), OldReceipt, NewReceipt, Log)
|
_ApplyStandInServices(Program, Context or Program.Context(NewReceipt.Answers), OldReceipt, NewReceipt, Log)
|
||||||
return
|
return
|
||||||
_Systemctl(Log, "daemon-reload")
|
_Systemctl(Log, "daemon-reload")
|
||||||
WasEnabled = set(OldReceipt.EnabledServices) if OldReceipt else set()
|
WasEnabled = set(OldReceipt.EnabledServices) if OldReceipt else set()
|
||||||
@@ -550,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
|
||||||
@@ -557,7 +638,12 @@ def _AfterIntegrationChanges(Program: Program, Log: LogFn) -> None:
|
|||||||
if any(Template.Target.endswith(".desktop") for Template in Program.Templates) and Applications.exists():
|
if any(Template.Target.endswith(".desktop") for Template in Program.Templates) and Applications.exists():
|
||||||
_Run(["update-desktop-database", str(Applications)], Log, Check=False)
|
_Run(["update-desktop-database", str(Applications)], Log, Check=False)
|
||||||
for Unit in Program.RestartUnits:
|
for Unit in Program.RestartUnits:
|
||||||
|
if HasSystemd:
|
||||||
_Systemctl(Log, "try-restart", Unit)
|
_Systemctl(Log, "try-restart", Unit)
|
||||||
|
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).
|
||||||
|
_StopProcesses(Unit.removesuffix(".service"), Log)
|
||||||
|
|
||||||
|
|
||||||
def Apply(
|
def Apply(
|
||||||
@@ -686,7 +772,9 @@ def PlanRemoval(Program: Program) -> RemovalPlan:
|
|||||||
Plan.Items.append(RemovalItem(str(Downloaded), "Program", "downloaded source"))
|
Plan.Items.append(RemovalItem(str(Downloaded), "Program", "downloaded source"))
|
||||||
|
|
||||||
if Current is not None:
|
if Current is not None:
|
||||||
ServiceNote = "background service, stopped and taken out of Startup" if IsWindows else "systemd user service, stopped and disabled"
|
ServiceNote = ("background service, stopped and taken out of Startup" if IsWindows
|
||||||
|
else "systemd user service, stopped and disabled" if HasSystemd
|
||||||
|
else "background service, stopped and taken out of autostart")
|
||||||
for Unit in Current.EnabledServices:
|
for Unit in Current.EnabledServices:
|
||||||
Plan.Items.append(RemovalItem(Unit, "Integration", ServiceNote))
|
Plan.Items.append(RemovalItem(Unit, "Integration", ServiceNote))
|
||||||
for Placed in Current.Files:
|
for Placed in Current.Files:
|
||||||
@@ -733,6 +821,9 @@ def Remove(Program: Program, RemoveConfigs: bool, RemoveRuntime: bool, Log: LogF
|
|||||||
if Current is not None:
|
if Current is not None:
|
||||||
if IsWindows:
|
if IsWindows:
|
||||||
_StopWindowsServices(Program, Current, Log)
|
_StopWindowsServices(Program, Current, Log)
|
||||||
|
elif not HasSystemd:
|
||||||
|
for Name in Current.EnabledServices:
|
||||||
|
Services.Stop(Name, Log)
|
||||||
else:
|
else:
|
||||||
for Unit in Current.EnabledServices:
|
for Unit in Current.EnabledServices:
|
||||||
_Systemctl(Log, "disable", "--now", Unit)
|
_Systemctl(Log, "disable", "--now", Unit)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+29
-9
@@ -16,8 +16,8 @@ from typing import Optional
|
|||||||
import tomlkit
|
import tomlkit
|
||||||
|
|
||||||
from Core import (
|
from Core import (
|
||||||
CacheHome, ConfigHome, CurrentDesktop, DataHome, Home, IsWindows, LoadConfig, Platform,
|
AutostartDir, CacheHome, ConfigHome, CurrentDesktop, DataHome, HasSystemd, Home, IsWindows, LoadConfig,
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -83,19 +85,26 @@ class IniEdit:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Service:
|
class Service:
|
||||||
"""A background service. Linux: a systemd --user Unit (placed by a
|
"""A background service. Linux with systemd: a --user Unit (placed by a
|
||||||
Template). Windows: Script (relative to the app folder) run windowless by
|
Template). Windows, and Linux without systemd (runit, OpenRC, ...):
|
||||||
ServiceRun.py in WorkingDir, at login through a Startup-folder shortcut."""
|
Script (relative to the app folder) run by ServiceRun.py in WorkingDir,
|
||||||
|
at login through a Startup-folder shortcut / an XDG autostart entry
|
||||||
|
(Services.py), so a Linux service gives both Unit and Name/Script."""
|
||||||
|
|
||||||
Unit: str = ""
|
Unit: str = ""
|
||||||
Option: str = "" # enabled + started only while this option is ticked
|
Option: str = "" # enabled + started only while this option is ticked
|
||||||
Name: str = "" # Windows: the service's name (PID file, Startup shortcut)
|
Name: str = "" # run by SaraSuite: the service's name (PID file, log, autostart)
|
||||||
Script: str = ""
|
Script: str = ""
|
||||||
WorkingDir: str = ""
|
WorkingDir: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def RunBySaraSuite(self) -> bool:
|
||||||
|
return IsWindows or not HasSystemd
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def Id(self) -> str:
|
def Id(self) -> str:
|
||||||
return self.Unit or self.Name
|
"""What the receipt records: the unit under systemd, else the name."""
|
||||||
|
return (self.Name or self.Unit) if self.RunBySaraSuite else (self.Unit or self.Name)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -229,8 +238,17 @@ class Program:
|
|||||||
else:
|
else:
|
||||||
Context.update(
|
Context.update(
|
||||||
SystemdUserDir=str(ConfigHome / "systemd" / "user"),
|
SystemdUserDir=str(ConfigHome / "systemd" / "user"),
|
||||||
|
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
|
||||||
@@ -282,7 +300,9 @@ def LoadProgram(Folder: Path) -> Program:
|
|||||||
RequirementsFile=Python.get("RequirementsFile", ""),
|
RequirementsFile=Python.get("RequirementsFile", ""),
|
||||||
Options=[Option(**Item) for Item in _ForPlatform(Data.get("Options", []))],
|
Options=[Option(**Item) for Item in _ForPlatform(Data.get("Options", []))],
|
||||||
Questions=[Question(**Item) for Item in _ForPlatform(Data.get("Questions", []))],
|
Questions=[Question(**Item) for Item in _ForPlatform(Data.get("Questions", []))],
|
||||||
Templates=[TemplateFile(**Item) for Item in _ForPlatform(Data.get("Templates", []))],
|
# Without systemd, its unit files would just sit there unused.
|
||||||
|
Templates=[TemplateFile(**Item) for Item in _ForPlatform(Data.get("Templates", []))
|
||||||
|
if HasSystemd or "{SystemdUserDir}" not in Item.get("Target", "")],
|
||||||
Configs=[ConfigFile(**Item) for Item in _ForPlatform(Data.get("Configs", []))],
|
Configs=[ConfigFile(**Item) for Item in _ForPlatform(Data.get("Configs", []))],
|
||||||
IniEdits=[IniEdit(**Item) for Item in _ForPlatform(Data.get("IniEdits", []))],
|
IniEdits=[IniEdit(**Item) for Item in _ForPlatform(Data.get("IniEdits", []))],
|
||||||
Services=[Service(**Item) for Item in _ForPlatform(Data.get("Services", []))],
|
Services=[Service(**Item) for Item in _ForPlatform(Data.get("Services", []))],
|
||||||
|
|||||||
@@ -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"'
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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,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,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 "$@"
|
||||||
@@ -40,24 +40,28 @@ Target = "{ConfigDir}/InputDirConf.toml"
|
|||||||
Source = "OutputDirConf.toml"
|
Source = "OutputDirConf.toml"
|
||||||
Target = "{ConfigDir}/OutputDirConf.toml"
|
Target = "{ConfigDir}/OutputDirConf.toml"
|
||||||
|
|
||||||
# Linux: a systemd user service.
|
# Linux with systemd: a systemd user service.
|
||||||
[[Templates]]
|
[[Templates]]
|
||||||
Platform = "Linux"
|
Platform = "Linux"
|
||||||
Source = "SaraSortd.service"
|
Source = "SaraSortd.service"
|
||||||
Target = "{SystemdUserDir}/SaraSortd.service"
|
Target = "{SystemdUserDir}/SaraSortd.service"
|
||||||
|
|
||||||
|
# Windows, and Linux without systemd (runit, OpenRC, s6, ...): run from
|
||||||
|
# ConfigDir by ServiceRun.py, which restarts it if it fails, at login through
|
||||||
|
# a shortcut in the Startup folder / an XDG autostart entry (SaraSuite's
|
||||||
|
# Services.py). On Linux the same service has a Unit for systemd too.
|
||||||
|
[[Templates]]
|
||||||
|
Source = "../../ServiceRun.py"
|
||||||
|
Target = "{ProgramDir}/ServiceRun.py"
|
||||||
|
|
||||||
[[Services]]
|
[[Services]]
|
||||||
Platform = "Linux"
|
Platform = "Linux"
|
||||||
Unit = "SaraSortd.service"
|
Unit = "SaraSortd.service"
|
||||||
|
Name = "SaraSortd"
|
||||||
|
Script = "SaraSortd.py"
|
||||||
|
WorkingDir = "{ConfigDir}"
|
||||||
Option = "Autostart"
|
Option = "Autostart"
|
||||||
|
|
||||||
# Windows: run windowless from ConfigDir by ServiceRun.py, at login through a
|
|
||||||
# shortcut in the Startup folder (see SaraSuite's WindowsSupport.py).
|
|
||||||
[[Templates]]
|
|
||||||
Platform = "Windows"
|
|
||||||
Source = "../../ServiceRun.py"
|
|
||||||
Target = "{ProgramDir}/ServiceRun.py"
|
|
||||||
|
|
||||||
[[Services]]
|
[[Services]]
|
||||||
Platform = "Windows"
|
Platform = "Windows"
|
||||||
Name = "SaraSortd"
|
Name = "SaraSortd"
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
@@ -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 "$@"
|
||||||
@@ -6,7 +6,8 @@ Description = "SaraView's desktop client: view and edit files in a local folder,
|
|||||||
[Source]
|
[Source]
|
||||||
Git = "{GitHost}/SaraView"
|
Git = "{GitHost}/SaraView"
|
||||||
# Only the Flet client; the server (Backend/, Frontend/) runs in Docker on SpineS.
|
# Only the Flet client; the server (Backend/, Frontend/) runs in Docker on SpineS.
|
||||||
Include = ["Client/*.py", "Client/requirements.txt", "Client/assets/**"]
|
# Backend/VarEngine.py is the variable engine the client shares with it.
|
||||||
|
Include = ["Client/*.py", "Client/requirements.txt", "Client/assets/**", "Backend/VarEngine.py"]
|
||||||
Exclude = ["Client/Packaging"]
|
Exclude = ["Client/Packaging"]
|
||||||
|
|
||||||
[Python]
|
[Python]
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
+84
-13
@@ -1,33 +1,104 @@
|
|||||||
"""Runs a SaraSuite program as a background service on Windows, the stand-in
|
"""Runs a SaraSuite program as a background service where there's no systemd
|
||||||
for a systemd --user service. Started windowless with pythonw.exe, both at
|
--user to do it: on Windows (started windowless with pythonw.exe), and on
|
||||||
login (by the program's shortcut in the Startup folder) and when SaraSuite
|
Linux with another init system (runit on Void, OpenRC, s6, dinit, ...).
|
||||||
or the program's own GUI (e.g. SaraSort for SaraSortd) starts it:
|
Started at login (Windows: a shortcut in the Startup folder; Linux: an XDG
|
||||||
|
autostart entry) and when SaraSuite or the program's own GUI (e.g. SaraSort
|
||||||
|
for SaraSortd) starts it:
|
||||||
|
|
||||||
pythonw.exe ServiceRun.py <Name> <Script> (in the service's working folder)
|
python ServiceRun.py <Name> <Script> [<WorkingDir>]
|
||||||
|
|
||||||
It records its PID in %LOCALAPPDATA%\\SaraSuite\\Services\\<Name>.pid, so
|
Like the systemd unit it stands in for: one copy at a time; restarted 10
|
||||||
anything can tell whether the service runs, and stop it (taskkill), without
|
seconds after it fails (an exception or a non-zero exit), not after a clean
|
||||||
a Windows service and without admin rights. Installed next to each program
|
exit or a stop; its output goes to <Name>.log. Its PID is in <Name>.pid, so
|
||||||
that has a Windows service (Programs/<Name>/Manifest.toml, [[Services]]).
|
anything can tell whether it runs, and stop it, without admin rights. Both
|
||||||
|
files are in SaraSuite's Services folder: %LOCALAPPDATA%\\SaraSuite\\Services
|
||||||
|
on Windows, ~/.local/share/SaraSuite/Services on Linux.
|
||||||
|
|
||||||
|
Installed next to each program that has a service (Programs/<Name>/Manifest.toml).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import runpy
|
import runpy
|
||||||
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
IsWindows = sys.platform == "win32"
|
||||||
|
RestartSeconds = 10 # the unit's RestartSec
|
||||||
|
MaxLogBytes = 1_000_000 # then the log starts over, the old one kept as <Name>.log.1
|
||||||
|
Stopping = False
|
||||||
|
|
||||||
|
|
||||||
|
def ServicesDir() -> Path:
|
||||||
|
if IsWindows:
|
||||||
|
Base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
|
||||||
|
else:
|
||||||
|
Base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
|
||||||
|
return Base / "SaraSuite" / "Services"
|
||||||
|
|
||||||
|
|
||||||
|
def _AlreadyRunning(PidFile: Path) -> bool:
|
||||||
|
"""Linux: whether the PID file's process is another ServiceRun.py still
|
||||||
|
running (not a reused PID). Windows relies on whoever starts it to check."""
|
||||||
|
if IsWindows:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
Pid = int(PidFile.read_text().strip())
|
||||||
|
if Pid == os.getpid():
|
||||||
|
return False
|
||||||
|
return b"ServiceRun.py" in Path(f"/proc/{Pid}/cmdline").read_bytes()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _Stop(Signal, Frame) -> None:
|
||||||
|
global Stopping
|
||||||
|
Stopping = True
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
|
||||||
def Main() -> None:
|
def Main() -> None:
|
||||||
Name, Script = sys.argv[1], Path(sys.argv[2])
|
Name, Script = sys.argv[1], Path(sys.argv[2])
|
||||||
LocalAppData = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
|
if len(sys.argv) > 3:
|
||||||
PidFile = LocalAppData / "SaraSuite" / "Services" / f"{Name}.pid"
|
Folder = Path(sys.argv[3])
|
||||||
PidFile.parent.mkdir(parents=True, exist_ok=True)
|
Folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.chdir(Folder)
|
||||||
|
Folder = ServicesDir()
|
||||||
|
Folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Not an f-string: SaraSuite fills in braced names like that one when it places this file.
|
||||||
|
PidFile = Folder / (Name + ".pid")
|
||||||
|
if _AlreadyRunning(PidFile):
|
||||||
|
return
|
||||||
|
|
||||||
|
LogFile = Folder / (Name + ".log")
|
||||||
|
if LogFile.exists() and LogFile.stat().st_size > MaxLogBytes:
|
||||||
|
LogFile.replace(LogFile.with_suffix(".log.1"))
|
||||||
|
sys.stdout = sys.stderr = open(LogFile, "a", buffering=1, encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
PidFile.write_text(str(os.getpid()))
|
PidFile.write_text(str(os.getpid()))
|
||||||
|
if not IsWindows:
|
||||||
|
signal.signal(signal.SIGTERM, _Stop) # a stop: finish cleanly, don't restart
|
||||||
|
sys.path.insert(0, str(Script.parent))
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
print(f"== {datetime.now():%Y-%m-%d %H:%M:%S} starting {Script.name}")
|
||||||
try:
|
try:
|
||||||
sys.argv = [str(Script)]
|
sys.argv = [str(Script)]
|
||||||
sys.path.insert(0, str(Script.parent))
|
|
||||||
runpy.run_path(str(Script), run_name="__main__")
|
runpy.run_path(str(Script), run_name="__main__")
|
||||||
|
break
|
||||||
|
except SystemExit as Exit:
|
||||||
|
if Stopping or Exit.code in (None, 0):
|
||||||
|
break
|
||||||
|
print(f"== {Script.name} exited with {Exit.code}")
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
print(f"== restarting in {RestartSeconds} seconds")
|
||||||
|
time.sleep(RestartSeconds)
|
||||||
finally:
|
finally:
|
||||||
|
print(f"== {datetime.now():%Y-%m-%d %H:%M:%S} stopped")
|
||||||
# Only remove it if it's still ours (a newer start may have replaced it).
|
# Only remove it if it's still ours (a newer start may have replaced it).
|
||||||
try:
|
try:
|
||||||
if PidFile.read_text().strip() == str(os.getpid()):
|
if PidFile.read_text().strip() == str(os.getpid()):
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ It's one AppImage file: download it, run it, pick a program.
|
|||||||
- **Install**: downloads the program's latest commit from its git repo, copies it into
|
- **Install**: downloads the program's latest commit from its git repo, copies it into
|
||||||
`~/.local/share/SaraSuite/Programs/<Name>/` with its own Python venv, places its configs in
|
`~/.local/share/SaraSuite/Programs/<Name>/` with its own Python venv, places its configs in
|
||||||
`~/.config/SaraSuite/<Name>/`, and writes its desktop entries, systemd user units, D-Bus
|
`~/.config/SaraSuite/<Name>/`, and writes its desktop entries, systemd user units, D-Bus
|
||||||
services and so on.
|
services and so on. Without systemd (runit on Void, OpenRC, s6, dinit, ...), background
|
||||||
|
services run the way they do on Windows: see the table below.
|
||||||
- **Update**: shown when the repo has a newer commit. Program files and integration files are
|
- **Update**: shown when the repo has a newer commit. Program files and integration files are
|
||||||
replaced; configs are *merged*: your edits always win, defaults you never touched follow the
|
replaced; configs are *merged*: your edits always win, defaults you never touched follow the
|
||||||
new version, new keys are added (see `ConfigMerge.py`).
|
new version, new keys are added (see `ConfigMerge.py`).
|
||||||
@@ -90,6 +91,15 @@ places it. It reapplies the installed commit, so the program itself isn't update
|
|||||||
| `~/.cache/...` | `%LOCALAPPDATA%\...` |
|
| `~/.cache/...` | `%LOCALAPPDATA%\...` |
|
||||||
| desktop entry | Start Menu shortcut (`[[Shortcuts]]`) |
|
| desktop entry | Start Menu shortcut (`[[Shortcuts]]`) |
|
||||||
| systemd user service | `ServiceRun.py`, started at login by a shortcut in the Startup folder; its PID is in `%LOCALAPPDATA%\SaraSuite\Services\<Name>.pid` |
|
| systemd user service | `ServiceRun.py`, started at login by a shortcut in the Startup folder; its PID is in `%LOCALAPPDATA%\SaraSuite\Services\<Name>.pid` |
|
||||||
|
|
||||||
|
Linux without systemd (no `/run/systemd/system`: runit on Void, OpenRC, s6, dinit, ...) runs
|
||||||
|
services like Windows does (`Services.py`): `ServiceRun.py` runs them, started at login by an XDG
|
||||||
|
autostart entry, `~/.config/autostart/<Name>.desktop`, which desktop sessions start whatever the
|
||||||
|
init system is. It restarts a service 10 seconds after it fails, like the unit's
|
||||||
|
`Restart=on-failure`, and keeps its output in `~/.local/share/SaraSuite/Services/<Name>.log` (its
|
||||||
|
PID next to it, in `<Name>.pid`). Units aren't written there, and `RestartUnits` stops the process
|
||||||
|
instead (D-Bus starts `xdg-desktop-portal` again when it's needed). A window manager that doesn't
|
||||||
|
run XDG autostart entries needs to start them itself (e.g. `dex -a` in its startup config).
|
||||||
| system `python3` | the `py` launcher / `python.exe`; if there's none, SaraSuite installs Python for the user with winget |
|
| system `python3` | the `py` launcher / `python.exe`; if there's none, SaraSuite installs Python for the user with winget |
|
||||||
| AppImage self-update (rename over it) | `.exe` self-update (the running one is renamed to `SaraSuite.old.exe`, deleted on the next start) |
|
| AppImage self-update (rename over it) | `.exe` self-update (the running one is renamed to `SaraSuite.old.exe`, deleted on the next start) |
|
||||||
|
|
||||||
@@ -142,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
|
||||||
@@ -190,16 +202,20 @@ 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: systemd --user units, enabled + started
|
[[Services]] # Linux: a systemd --user unit, enabled + started; without
|
||||||
Platform = "Linux"
|
Platform = "Linux" # systemd, Name/Script/WorkingDir run by ServiceRun.py instead,
|
||||||
Unit = "Example.service"
|
Unit = "Example.service" # started at login by an autostart entry
|
||||||
|
Name = "Example"
|
||||||
|
Script = "Example.py"
|
||||||
|
WorkingDir = "{ConfigDir}"
|
||||||
Option = "Autostart"
|
Option = "Autostart"
|
||||||
|
|
||||||
[[Services]] # Windows: run by ServiceRun.py from a Startup-folder shortcut
|
[[Services]] # Windows: run by ServiceRun.py from a Startup-folder shortcut
|
||||||
Platform = "Windows" # (also add ../../ServiceRun.py as a Template to {ProgramDir})
|
Platform = "Windows" # (both: add ../../ServiceRun.py as a Template to {ProgramDir})
|
||||||
Name = "Example"
|
Name = "Example"
|
||||||
Script = "Example.py" # relative to the app folder
|
Script = "Example.py" # relative to the app folder
|
||||||
WorkingDir = "{ConfigDir}"
|
WorkingDir = "{ConfigDir}"
|
||||||
|
|||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
"""Background services SaraSuite runs itself, where there's no systemd --user
|
||||||
|
to do it: Windows, and Linux with another init system (runit on Void, OpenRC,
|
||||||
|
s6, dinit, ...). Each runs as Programs/ServiceRun.py <Name> <Script>
|
||||||
|
<WorkingDir>, which keeps it going (restarts it if it fails), logs its output
|
||||||
|
and records its PID, all in ServicesDir. At login it's started by
|
||||||
|
- Windows: a shortcut in the Startup folder;
|
||||||
|
- Linux: an XDG autostart entry (~/.config/autostart/<Name>.desktop), which
|
||||||
|
desktop sessions start whatever the init system is.
|
||||||
|
The installer places those (Installer._ApplyStandInServices); this starts,
|
||||||
|
stops and checks the services. SaraSort's Service.py does the same for SaraSortd.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from Core import ChildEnvironment, IsWindows, ServicesDir
|
||||||
|
|
||||||
|
LogFn = Callable[[str], None]
|
||||||
|
|
||||||
|
# Windows: child processes of a windowed program would each flash a console window.
|
||||||
|
NoWindow = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
|
_Detached = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _PidFile(Name: str) -> Path:
|
||||||
|
return ServicesDir / f"{Name}.pid"
|
||||||
|
|
||||||
|
|
||||||
|
def LogFile(Name: str) -> Path:
|
||||||
|
return ServicesDir / f"{Name}.log"
|
||||||
|
|
||||||
|
|
||||||
|
def ServicePid(Name: str) -> Optional[int]:
|
||||||
|
"""The PID ServiceRun.py recorded, if that process is still it."""
|
||||||
|
try:
|
||||||
|
Pid = int(_PidFile(Name).read_text().strip())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
if not IsWindows:
|
||||||
|
try:
|
||||||
|
return Pid if b"ServiceRun.py" in Path(f"/proc/{Pid}/cmdline").read_bytes() else None
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
Output = subprocess.run(
|
||||||
|
["tasklist", "/FI", f"PID eq {Pid}", "/NH", "/FO", "CSV"],
|
||||||
|
capture_output=True, text=True, timeout=15, creationflags=NoWindow,
|
||||||
|
).stdout
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return None
|
||||||
|
return Pid if re.search(rf'"{Pid}"', Output) else None
|
||||||
|
|
||||||
|
|
||||||
|
def Command(Python: Path, Runner: Path, Name: str, Script: Path, WorkingDir: Path) -> list[str]:
|
||||||
|
"""What runs the service (Python: pythonw.exe on Windows)."""
|
||||||
|
return [str(Python), str(Runner), Name, str(Script), str(WorkingDir)]
|
||||||
|
|
||||||
|
|
||||||
|
def Start(Name: str, Python: Path, Runner: Path, Script: Path, WorkingDir: Path, Log: LogFn) -> None:
|
||||||
|
"""Start a service now (it also starts at every login, the same way)."""
|
||||||
|
if ServicePid(Name) is not None:
|
||||||
|
Log(f"{Name} is already running")
|
||||||
|
return
|
||||||
|
WorkingDir.mkdir(parents=True, exist_ok=True)
|
||||||
|
Extra = {"creationflags": _Detached | NoWindow} if IsWindows else {"start_new_session": True}
|
||||||
|
subprocess.Popen(
|
||||||
|
Command(Python, Runner, Name, Script, WorkingDir), cwd=str(WorkingDir), env=ChildEnvironment(),
|
||||||
|
close_fds=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **Extra,
|
||||||
|
)
|
||||||
|
Log(f"Started {Name}")
|
||||||
|
|
||||||
|
|
||||||
|
def Stop(Name: str, Log: LogFn) -> None:
|
||||||
|
Pid = ServicePid(Name)
|
||||||
|
if Pid is not None:
|
||||||
|
if IsWindows:
|
||||||
|
subprocess.run(["taskkill", "/PID", str(Pid), "/T", "/F"], capture_output=True, creationflags=NoWindow)
|
||||||
|
else:
|
||||||
|
# ServiceRun.py finishes cleanly on SIGTERM; one that hangs is killed.
|
||||||
|
os.kill(Pid, signal.SIGTERM)
|
||||||
|
for _ in range(50):
|
||||||
|
if ServicePid(Name) is None:
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
else:
|
||||||
|
os.kill(Pid, signal.SIGKILL)
|
||||||
|
Log(f"Stopped {Name}")
|
||||||
|
_PidFile(Name).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def Restart(Name: str, Python: Path, Runner: Path, Script: Path, WorkingDir: Path, Log: LogFn) -> None:
|
||||||
|
Stop(Name, Log)
|
||||||
|
Start(Name, Python, Runner, Script, WorkingDir, Log)
|
||||||
|
|
||||||
|
|
||||||
|
def _ExecArgument(Text: str) -> str:
|
||||||
|
"""One argument for a desktop entry's Exec key (the spec's quoting)."""
|
||||||
|
Text = Text.replace("%", "%%")
|
||||||
|
if re.fullmatch(r"[\w@%+=:,./-]+", Text):
|
||||||
|
return Text
|
||||||
|
return '"' + re.sub(r'(["`$\\])', r"\\\1", Text) + '"'
|
||||||
|
|
||||||
|
|
||||||
|
def AutostartEntry(Name: str, Python: Path, Runner: Path, Script: Path, WorkingDir: Path) -> str:
|
||||||
|
"""Linux: the XDG autostart entry that starts the service at login."""
|
||||||
|
Exec = " ".join(_ExecArgument(Part) for Part in Command(Python, Runner, Name, Script, WorkingDir))
|
||||||
|
return (
|
||||||
|
"# Written by SaraSuite: starts this background service at login on systems\n"
|
||||||
|
"# without systemd (runit, OpenRC, s6, ...). Removed with the program.\n"
|
||||||
|
"[Desktop Entry]\n"
|
||||||
|
"Type=Application\n"
|
||||||
|
f"Name={Name}\n"
|
||||||
|
f"Comment={Name} (background service, run by SaraSuite)\n"
|
||||||
|
f"Exec={Exec}\n"
|
||||||
|
"Terminal=false\n"
|
||||||
|
"NoDisplay=true\n"
|
||||||
|
"X-GNOME-Autostart-enabled=true\n"
|
||||||
|
)
|
||||||
+4
-3
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-55
@@ -3,7 +3,7 @@
|
|||||||
Linux Windows
|
Linux Windows
|
||||||
.desktop entry Start Menu shortcut (.lnk)
|
.desktop entry Start Menu shortcut (.lnk)
|
||||||
systemd --user service shortcut in the Startup folder, started and stopped
|
systemd --user service shortcut in the Startup folder, started and stopped
|
||||||
through Programs/ServiceRun.py (which records its PID)
|
through Programs/ServiceRun.py (Services.py)
|
||||||
system python3 the py launcher / python.exe, else winget installs it
|
system python3 the py launcher / python.exe, else winget installs it
|
||||||
fc-list the Fonts registry key
|
fc-list the Fonts registry key
|
||||||
|
|
||||||
@@ -17,13 +17,12 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from Core import DataHome, ServicesDir
|
from Core import DataHome
|
||||||
|
|
||||||
LogFn = Callable[[str], None]
|
LogFn = Callable[[str], None]
|
||||||
|
|
||||||
# Child processes of a windowed program would each flash a console window.
|
# Child processes of a windowed program would each flash a console window.
|
||||||
NoWindow = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
NoWindow = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
_Detached = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
||||||
|
|
||||||
PythonVersion = "3.14" # what winget installs when there's no Python yet
|
PythonVersion = "3.14" # what winget installs when there's no Python yet
|
||||||
_WingetId = f"Python.Python.{PythonVersion}"
|
_WingetId = f"Python.Python.{PythonVersion}"
|
||||||
@@ -115,58 +114,6 @@ def InstallPython(Log: LogFn) -> Optional[str]:
|
|||||||
return FindPython()
|
return FindPython()
|
||||||
|
|
||||||
|
|
||||||
# --- Background services (SaraSortd) ---------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _PidFile(Name: str) -> Path:
|
|
||||||
return ServicesDir / f"{Name}.pid"
|
|
||||||
|
|
||||||
|
|
||||||
def ServicePid(Name: str) -> Optional[int]:
|
|
||||||
"""The PID ServiceRun.py recorded, if that process is still alive."""
|
|
||||||
try:
|
|
||||||
Pid = int(_PidFile(Name).read_text().strip())
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
Output = subprocess.run(
|
|
||||||
["tasklist", "/FI", f"PID eq {Pid}", "/NH", "/FO", "CSV"],
|
|
||||||
capture_output=True, text=True, timeout=15, creationflags=NoWindow,
|
|
||||||
).stdout
|
|
||||||
except (OSError, subprocess.SubprocessError):
|
|
||||||
return None
|
|
||||||
return Pid if re.search(rf'"{Pid}"', Output) else None
|
|
||||||
|
|
||||||
|
|
||||||
def StartService(Name: str, PythonW: Path, Runner: Path, Script: Path, WorkingDir: Path, Log: LogFn) -> None:
|
|
||||||
"""Start a background service now (it's also started at every login by
|
|
||||||
its Startup shortcut, which runs the same command)."""
|
|
||||||
if ServicePid(Name) is not None:
|
|
||||||
Log(f"{Name} is already running")
|
|
||||||
return
|
|
||||||
WorkingDir.mkdir(parents=True, exist_ok=True)
|
|
||||||
subprocess.Popen(
|
|
||||||
[str(PythonW), str(Runner), Name, str(Script)], cwd=str(WorkingDir),
|
|
||||||
creationflags=_Detached | NoWindow, close_fds=True,
|
|
||||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
Log(f"Started {Name}")
|
|
||||||
|
|
||||||
|
|
||||||
def StopService(Name: str, Log: LogFn) -> None:
|
|
||||||
Pid = ServicePid(Name)
|
|
||||||
if Pid is not None:
|
|
||||||
subprocess.run(["taskkill", "/PID", str(Pid), "/T", "/F"], capture_output=True, creationflags=NoWindow)
|
|
||||||
Log(f"Stopped {Name}")
|
|
||||||
_PidFile(Name).unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def RestartService(Name: str, PythonW: Path, Runner: Path, Script: Path, WorkingDir: Path, Log: LogFn) -> None:
|
|
||||||
if ServicePid(Name) is not None:
|
|
||||||
StopService(Name, Log)
|
|
||||||
StartService(Name, PythonW, Runner, Script, WorkingDir, Log)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Fonts -----------------------------------------------------------------------------------
|
# --- Fonts -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user