mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-14 18:28:11 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
873a2d9ba0 | ||
|
|
ca04dcd4a7 | ||
|
|
1feb6d4336 | ||
|
|
768757a062 | ||
|
|
ce62249487 | ||
|
|
70c9730017 | ||
|
|
2061babb43 | ||
|
|
21ae6379ac | ||
|
|
63e06eea40 | ||
|
|
5890b52da6 | ||
|
|
61c992f959 | ||
|
|
f63e708572 | ||
|
|
e7e233951f | ||
|
|
2ebef97be1 | ||
|
|
d7db153c24 | ||
|
|
4e662beca2 | ||
|
|
9f99adc519 | ||
|
|
6fe282f686 | ||
|
|
51efb4a1f0 | ||
|
|
28513fa486 | ||
|
|
5e6d4d0a11 | ||
|
|
34457f6236 | ||
|
|
92033f3bf6 |
No files matched your search
@@ -1,11 +1,22 @@
|
||||
"""Shared app-wide constants: paths, palette, theme.
|
||||
|
||||
Paths follow each system's own per-user conventions (no admin needed):
|
||||
Linux Windows macOS
|
||||
~/.config %APPDATA% ~/Library/Application Support (settings)
|
||||
~/.local/share %LOCALAPPDATA% ~/Library/Application Support (programs, SaraSuite's data)
|
||||
~/.cache %LOCALAPPDATA% ~/Library/Caches (caches)
|
||||
so the SaraSuite layout below them (SaraSuite/<Program>/...) is the same on all
|
||||
three. (On macOS settings and data share one folder, as Mac apps' do: the
|
||||
programs' config folders and SaraSuite's Programs, Receipts, ... sit side by side.)
|
||||
|
||||
The palette and theming mirror SaraFem's Core.py (which mirrors SaraView's),
|
||||
and read the same shared ~/.config/SaraSuite/Themes/*.toml files, so a theme
|
||||
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
|
||||
from pathlib import Path
|
||||
@@ -16,8 +27,16 @@ import tomlkit
|
||||
AppName = "SaraSuite"
|
||||
# Compared with the latest Gitea release to offer self-updates (SelfUpdate.py).
|
||||
# Bump it, then run Packaging/Release.py, to publish a new SaraSuite.
|
||||
AppVersion = "1.1.1"
|
||||
IsFrozen = bool(getattr(sys, "frozen", False)) # running from the AppImage
|
||||
AppVersion = "1.3.0"
|
||||
# SaraSuite.app's launcher sets this to the app's path (Packaging/BuildMacApp.py).
|
||||
MacApp = os.environ.get("SARASUITE_APP", "")
|
||||
# Running packaged: from the AppImage / .exe (PyInstaller), or SaraSuite.app.
|
||||
IsFrozen = bool(getattr(sys, "frozen", False)) or bool(MacApp)
|
||||
IsWindows = sys.platform == "win32"
|
||||
IsMac = sys.platform == "darwin"
|
||||
IsLinux = not IsWindows and not IsMac
|
||||
# Matches manifests' Platforms / Platform.
|
||||
Platform = "Windows" if IsWindows else "macOS" if IsMac else "Linux"
|
||||
# Bundled in assets/fonts and always registered, so it works as FontFamily
|
||||
# without being installed. Any other FontFamily is looked up among the
|
||||
# system's installed fonts.
|
||||
@@ -25,9 +44,23 @@ BundledFontFamily = "Fixedsys Excelsior"
|
||||
ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme
|
||||
|
||||
Home = Path.home()
|
||||
ConfigHome = Home / ".config"
|
||||
DataHome = Home / ".local" / "share"
|
||||
CacheHome = Home / ".cache"
|
||||
if IsWindows:
|
||||
ConfigHome = Path(os.environ.get("APPDATA") or Home / "AppData" / "Roaming")
|
||||
DataHome = Path(os.environ.get("LOCALAPPDATA") or Home / "AppData" / "Local")
|
||||
CacheHome = DataHome
|
||||
# Start Menu shortcuts, and the Startup folder (run at login: SaraSortd's
|
||||
# stand-in for a systemd user service).
|
||||
StartMenuDir = ConfigHome / "Microsoft" / "Windows" / "Start Menu" / "Programs"
|
||||
StartupDir = StartMenuDir / "Startup"
|
||||
elif IsMac:
|
||||
ConfigHome = DataHome = Home / "Library" / "Application Support"
|
||||
CacheHome = Home / "Library" / "Caches"
|
||||
StartMenuDir = StartupDir = None
|
||||
else:
|
||||
ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Home / ".config")
|
||||
DataHome = Path(os.environ.get("XDG_DATA_HOME") or Home / ".local" / "share")
|
||||
CacheHome = Path(os.environ.get("XDG_CACHE_HOME") or Home / ".cache")
|
||||
StartMenuDir = StartupDir = None
|
||||
|
||||
SharedConfigDir = ConfigHome / "SaraSuite"
|
||||
ThemesDir = SharedConfigDir / "Themes"
|
||||
@@ -45,6 +78,15 @@ ReceiptsDir = DataDir / "Receipts" # what each install placed, for exact remova
|
||||
PristineDir = DataDir / "Pristine" # the default configs as last installed (merge base)
|
||||
BackupsDir = DataDir / "Backups" # displaced files and pre-merge config copies
|
||||
DownloadsDir = CacheHome / "SaraSuite" / "Downloads" # extracted source archives
|
||||
# <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. macOS runs them
|
||||
# that way too, started at login by a LaunchAgent (MacSupport.py).
|
||||
HasSystemd = IsLinux 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
|
||||
# build they're unpacked to sys._MEIPASS rather than next to the code.
|
||||
@@ -81,11 +123,50 @@ _ThemeRoleMap = {
|
||||
}
|
||||
|
||||
|
||||
# The themes every SaraSuite program seeds into ThemesDir if they're missing
|
||||
# (same values as SaraFem's and SaraView's), so whichever program is installed
|
||||
# first, the folder and these three are there to choose from.
|
||||
BuiltinThemes: dict[str, dict[str, str]] = {
|
||||
"CatppuccinMocha": {
|
||||
"Background": "#1e1e2e", "Surface": "#181825", "SurfaceAlt": "#313244", "Border": "#45475a",
|
||||
"Text": "#cdd6f4", "TextMuted": "#6c7086", "Black": "#11111b", "White": "#cdd6f4",
|
||||
"Red": "#f38ba8", "Orange": "#fab387", "Yellow": "#f9e2af", "Green": "#a6e3a1",
|
||||
"Blue": "#89b4fa", "Purple": "#cba6f7",
|
||||
},
|
||||
"Forest": {
|
||||
"Background": "#1b2420", "Surface": "#141b17", "SurfaceAlt": "#243029", "Border": "#334539",
|
||||
"Text": "#d6e5d0", "TextMuted": "#7c9184", "Black": "#0d120f", "White": "#eef5ea",
|
||||
"Red": "#e0685a", "Orange": "#e0a458", "Yellow": "#e0c458", "Green": "#7fb069",
|
||||
"Blue": "#5c9e8f", "Purple": "#9b7fb0",
|
||||
},
|
||||
"Gruvbox": {
|
||||
"Background": "#282828", "Surface": "#1d2021", "SurfaceAlt": "#3c3836", "Border": "#504945",
|
||||
"Text": "#ebdbb2", "TextMuted": "#a89984", "Black": "#1d2021", "White": "#fbf1c7",
|
||||
"Red": "#fb4934", "Orange": "#fe8019", "Yellow": "#fabd2f", "Green": "#b8bb26",
|
||||
"Blue": "#83a598", "Purple": "#d3869b",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def EnsureSharedThemes() -> None:
|
||||
"""Create ThemesDir with the built-in themes that aren't there yet
|
||||
(never overwriting one: it may have been edited)."""
|
||||
try:
|
||||
ThemesDir.mkdir(parents=True, exist_ok=True)
|
||||
for Name, RoleValues in BuiltinThemes.items():
|
||||
ThemeFile = ThemesDir / f"{Name}.toml"
|
||||
if not ThemeFile.exists():
|
||||
ThemeFile.write_text(tomlkit.dumps({"Palette": RoleValues}))
|
||||
except OSError:
|
||||
pass # read-only or full: the built-in Catppuccin Mocha values still apply
|
||||
|
||||
|
||||
def ApplyTheme(ThemeName: str) -> None:
|
||||
"""Mutate Palette in place from ~/.config/SaraSuite/Themes/<ThemeName>.toml.
|
||||
Falls back to the built-in Catppuccin Mocha values if the file is missing
|
||||
(SaraFem seeds that directory; SaraSuite only reads it). Must run before
|
||||
any control reads Palette.X."""
|
||||
Seeds the built-in themes first (EnsureSharedThemes); a missing file keeps
|
||||
the built-in Catppuccin Mocha values. Must run before any control reads
|
||||
Palette.X."""
|
||||
EnsureSharedThemes()
|
||||
ThemeFile = ThemesDir / f"{ThemeName}.toml"
|
||||
if not ThemeFile.exists():
|
||||
return
|
||||
@@ -192,14 +273,39 @@ def ChildEnvironment() -> dict[str, str]:
|
||||
Environment["LD_LIBRARY_PATH"] = Original
|
||||
else:
|
||||
Environment.pop("LD_LIBRARY_PATH", None)
|
||||
for Key in ("PYTHONHOME", "PYTHONPATH"):
|
||||
for Key in ("PYTHONHOME", "PYTHONPATH", "SARASUITE_APP"):
|
||||
Environment.pop(Key, None)
|
||||
if IsWindows:
|
||||
# Python on Windows otherwise reads/writes text in the old ANSI code
|
||||
# page; every Sara program assumes UTF-8 (their TOML files).
|
||||
Environment["PYTHONUTF8"] = "1"
|
||||
return Environment
|
||||
|
||||
|
||||
def UserPortalsSupported() -> bool:
|
||||
"""Whether xdg-desktop-portal reads portal files from
|
||||
~/.local/share/xdg-desktop-portal/portals (1.17 and newer), so installing
|
||||
one needs no password. True when it isn't installed either: then a
|
||||
portal file can't be used anywhere, so it might as well not need root."""
|
||||
import subprocess
|
||||
|
||||
for Candidate in ("/usr/libexec/xdg-desktop-portal", "/usr/lib/xdg-desktop-portal",
|
||||
"/usr/lib/x86_64-linux-gnu/xdg-desktop-portal", "/usr/local/libexec/xdg-desktop-portal"):
|
||||
if not Path(Candidate).is_file():
|
||||
continue
|
||||
try:
|
||||
Output = subprocess.run([Candidate, "--version"], capture_output=True, text=True, timeout=10).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
Found = re.search(r"(\d+)\.(\d+)", Output)
|
||||
return bool(Found) and (int(Found[1]), int(Found[2])) >= (1, 17)
|
||||
return True
|
||||
|
||||
|
||||
def CurrentDesktop() -> str:
|
||||
"""First entry of $XDG_CURRENT_DESKTOP as-is (e.g. "COSMIC"), used for
|
||||
portal descriptors' UseIn= and the <desktop>-portals.conf file name."""
|
||||
portal descriptors' UseIn= and the <desktop>-portals.conf file name.
|
||||
Empty on Windows."""
|
||||
return os.environ.get("XDG_CURRENT_DESKTOP", "").split(":")[0]
|
||||
|
||||
|
||||
|
||||
+291
-41
@@ -10,7 +10,9 @@ import datetime
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
@@ -19,7 +21,11 @@ from pathlib import Path, PurePosixPath
|
||||
from typing import Callable, Optional
|
||||
|
||||
from ConfigMerge import ApplySet, MergeConfig, MergeResult
|
||||
from Core import BackupsDir, ChildEnvironment, DownloadsDir, LoadConfig, PristineDir
|
||||
import Services
|
||||
from Core import (
|
||||
AutostartDir, BackupsDir, ChildEnvironment, DownloadsDir, HasSystemd, Home, IsLinux, IsMac, IsWindows, LoadConfig,
|
||||
PristineDir, StartMenuDir, StartupDir,
|
||||
)
|
||||
from Manifests import Leftover, Program, Render
|
||||
from Receipts import IniRecord, LoadReceipt, PlacedFile, Receipt
|
||||
from Sources import ClearDownloads, Download, LatestCommit, SourceError
|
||||
@@ -41,9 +47,18 @@ def _Now() -> str:
|
||||
def _Run(Command: list[str], Log: LogFn, Check: bool = True, Cwd: Optional[Path] = None) -> int:
|
||||
"""Run a command, streaming its output into the log."""
|
||||
Log(f"$ {' '.join(Command)}")
|
||||
Process = subprocess.Popen(
|
||||
Command, cwd=Cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=ChildEnvironment(),
|
||||
)
|
||||
try:
|
||||
Process = subprocess.Popen(
|
||||
Command, cwd=Cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=ChildEnvironment(),
|
||||
encoding="utf-8", errors="replace",
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), # no console flashes on Windows
|
||||
)
|
||||
except FileNotFoundError:
|
||||
# Not installed here (e.g. update-desktop-database without desktop-file-utils).
|
||||
if Check:
|
||||
raise InstallError(f"{Command[0]} isn't installed")
|
||||
Log(f" (skipped: {Command[0]} isn't installed)")
|
||||
return 127
|
||||
for Line in Process.stdout:
|
||||
Log(f" {Line.rstrip()}")
|
||||
Code = Process.wait()
|
||||
@@ -53,6 +68,8 @@ def _Run(Command: list[str], Log: LogFn, Check: bool = True, Cwd: Optional[Path]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -60,7 +77,12 @@ def _RemovePath(Target: Path) -> None:
|
||||
if Target.is_symlink() or Target.is_file():
|
||||
Target.unlink()
|
||||
elif Target.is_dir():
|
||||
shutil.rmtree(Target)
|
||||
def ClearReadOnly(Function, FailedPath, _Error):
|
||||
# Windows refuses to delete read-only files (e.g. in .git folders).
|
||||
os.chmod(FailedPath, 0o700)
|
||||
Function(FailedPath)
|
||||
|
||||
shutil.rmtree(Target, onexc=ClearReadOnly)
|
||||
|
||||
|
||||
def _BackupName(Target: str) -> str:
|
||||
@@ -68,18 +90,71 @@ def _BackupName(Target: str) -> str:
|
||||
return Target.strip("/").replace("/", "__")
|
||||
|
||||
|
||||
def _CopyPath(Source: Path, Destination: Path) -> None:
|
||||
"""A file, or a folder (a macOS .app bundle) with everything in it."""
|
||||
_RemovePath(Destination)
|
||||
if Source.is_dir() and not Source.is_symlink():
|
||||
shutil.copytree(Source, Destination, symlinks=True)
|
||||
else:
|
||||
shutil.copy2(Source, Destination)
|
||||
|
||||
|
||||
def _Displace(Program: Program, Target: Path, Kind: str, OldReceipt: Optional["Receipt"]) -> PlacedFile:
|
||||
"""The receipt entry for a file SaraSuite is about to place. One that's
|
||||
already there but wasn't placed by SaraSuite (e.g. a hand-made desktop
|
||||
entry from development) is moved aside, not lost: it comes back when the
|
||||
program is removed."""
|
||||
Previous = OldReceipt.FindFile(str(Target)) if OldReceipt is not None else None
|
||||
Placed = PlacedFile(str(Target), Kind, Displaced=Previous.Displaced if Previous else "")
|
||||
if Previous is None and (Target.exists() or Target.is_symlink()):
|
||||
Displaced = BackupsDir / Program.Name / "Displaced" / _BackupName(str(Target))
|
||||
Displaced.parent.mkdir(parents=True, exist_ok=True)
|
||||
_CopyPath(Target, Displaced)
|
||||
Placed.Displaced = str(Displaced)
|
||||
return Placed
|
||||
|
||||
|
||||
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:
|
||||
@@ -291,23 +366,37 @@ def _RequirementsText(Program: Program) -> str:
|
||||
return "\n".join(Program.Requirements)
|
||||
|
||||
|
||||
def _SystemPython(Program: Program, Log: LogFn) -> str:
|
||||
if IsLinux:
|
||||
Python = shutil.which("python3")
|
||||
if Python is None:
|
||||
raise InstallError(f"{Program.Name} needs python3, and it isn't installed (sudo dnf install python3)")
|
||||
return Python
|
||||
if IsMac:
|
||||
import MacSupport as Support
|
||||
else:
|
||||
import WindowsSupport as Support
|
||||
|
||||
Python = Support.FindPython() or Support.InstallPython(Log)
|
||||
if Python is None:
|
||||
raise InstallError(f"{Program.Name} needs Python, and it couldn't be installed. Get it from https://www.python.org/downloads/")
|
||||
return Python
|
||||
|
||||
|
||||
def _SetUpVenv(Program: Program, OldHash: str, Log: LogFn) -> str:
|
||||
"""(Re)install requirements only when they changed. Returns the new hash."""
|
||||
if not Program.UsesPython:
|
||||
return ""
|
||||
Venv = Program.ProgramDir / ".venv"
|
||||
NewHash = hashlib.sha256(_RequirementsText(Program).encode()).hexdigest()
|
||||
if (Venv / "bin" / "python").exists() and NewHash == OldHash:
|
||||
if Program.Python.exists() and NewHash == OldHash:
|
||||
Log("Python requirements unchanged, skipping pip")
|
||||
return NewHash
|
||||
if not (Venv / "bin" / "python").exists():
|
||||
# Always the system python: inside the AppImage sys.executable is the
|
||||
# AppImage itself, and the venv has to outlive SaraSuite anyway.
|
||||
Python = shutil.which("python3")
|
||||
if Python is None:
|
||||
raise InstallError(f"{Program.Name} needs python3, and it isn't installed (sudo dnf install python3)")
|
||||
_Run([Python, "-m", "venv", str(Venv)], Log)
|
||||
Pip = [str(Venv / "bin" / "python"), "-m", "pip", "install", "--disable-pip-version-check"]
|
||||
if not Program.Python.exists():
|
||||
# Always the system python: inside the AppImage/.exe sys.executable is
|
||||
# SaraSuite itself, and the venv has to outlive SaraSuite anyway.
|
||||
_Run([_SystemPython(Program, Log), "-m", "venv", str(Venv)], Log)
|
||||
Pip = [str(Program.Python), "-m", "pip", "install", "--disable-pip-version-check"]
|
||||
if Program.RequirementsFile:
|
||||
_Run([*Pip, "-r", str(Program.AppDir / Program.RequirementsFile)], Log)
|
||||
else:
|
||||
@@ -319,13 +408,13 @@ def _PutBackDisplaced(Placed: PlacedFile, Log: LogFn) -> None:
|
||||
if not Placed.Displaced or not Path(Placed.Displaced).exists():
|
||||
return
|
||||
Current = Path(Placed.Path)
|
||||
if Current.exists() and Current.read_bytes() == Path(Placed.Displaced).read_bytes():
|
||||
if Current.is_file() and Current.read_bytes() == Path(Placed.Displaced).read_bytes():
|
||||
pass # already identical (typical for a root file that was never removed)
|
||||
elif Placed.Root:
|
||||
_WriteRoot(Placed.Path, Path(Placed.Displaced).read_text(), Log)
|
||||
else:
|
||||
shutil.copy2(Placed.Displaced, Placed.Path)
|
||||
Path(Placed.Displaced).unlink()
|
||||
_CopyPath(Path(Placed.Displaced), Current)
|
||||
_RemovePath(Path(Placed.Displaced))
|
||||
Log(f"Restored your previous {Placed.Path}")
|
||||
|
||||
|
||||
@@ -337,7 +426,7 @@ def _RemovePlacedTemplate(Placed: PlacedFile, Log: LogFn) -> None:
|
||||
_RemoveRoot(Placed.Path, Log)
|
||||
Log(f"Removed {Placed.Path}")
|
||||
else:
|
||||
Path(Placed.Path).unlink(missing_ok=True)
|
||||
_RemovePath(Path(Placed.Path)) # a folder for a macOS app bundle
|
||||
Log(f"Removed {Placed.Path}")
|
||||
_PutBackDisplaced(Placed, Log)
|
||||
|
||||
@@ -356,20 +445,13 @@ def _ApplyTemplates(Program: Program, Context: dict, OldReceipt: Optional[Receip
|
||||
for Template in Wanted:
|
||||
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 "")
|
||||
|
||||
# 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.
|
||||
if Previous is None and Path(Target).exists():
|
||||
Displaced = BackupsDir / Program.Name / "Displaced" / _BackupName(Target)
|
||||
Displaced.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(Target, Displaced)
|
||||
Placed.Displaced = str(Displaced)
|
||||
Placed = _Displace(Program, Path(Target), "Template", OldReceipt)
|
||||
Placed.Root = Root = _NeedsRoot(Template, Target)
|
||||
if Placed.Displaced and (OldReceipt is None or OldReceipt.FindFile(Target) is None):
|
||||
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)
|
||||
@@ -381,6 +463,16 @@ def _ApplyTemplates(Program: Program, Context: dict, OldReceipt: Optional[Receip
|
||||
Log(f"Wrote {Target}")
|
||||
|
||||
|
||||
def _RemoveStaleShortcuts(OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None:
|
||||
"""Shortcuts the old install made that this one no longer wants (an
|
||||
option was unticked, a service disabled, or the manifest dropped it)."""
|
||||
if OldReceipt is None:
|
||||
return
|
||||
for Placed in OldReceipt.Files:
|
||||
if Placed.Kind == "Shortcut" and NewReceipt.FindFile(Placed.Path) is None:
|
||||
_RemovePlacedTemplate(Placed, Log)
|
||||
|
||||
|
||||
def _RevertIni(Record: IniRecord, Log: LogFn) -> None:
|
||||
FilePath = Path(Record.File)
|
||||
Parser = _ReadIni(FilePath)
|
||||
@@ -434,14 +526,138 @@ def _ApplyIniEdits(Program: Program, Context: dict, OldReceipt: Optional[Receipt
|
||||
Log(f"Set {Key}={Value} in {File}")
|
||||
|
||||
|
||||
def _ApplyServices(Program: Program, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None:
|
||||
def _PlaceShortcut(Program: Program, Link: Path, Arguments: str, WorkingDir: Path, IconPng: Optional[Path],
|
||||
Description: str, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None:
|
||||
"""Windows: a .lnk running the program's pythonw.exe (in UTF-8 mode, which
|
||||
every Sara program assumes) with Arguments. A link that was already there
|
||||
and isn't ours is moved aside like a template, and comes back on removal."""
|
||||
import WindowsSupport
|
||||
|
||||
Placed = _Displace(Program, Link, "Shortcut", OldReceipt)
|
||||
Icon = WindowsSupport.MakeIcon(IconPng, Program.ProgramDir / f"{Link.stem}.ico") if IconPng and IconPng.is_file() else None
|
||||
WindowsSupport.CreateShortcut(Link, Program.PythonW, f"-X utf8 {Arguments}", WorkingDir, Icon, Description)
|
||||
NewReceipt.Files.append(Placed)
|
||||
Log(f"Wrote {Link}")
|
||||
|
||||
|
||||
def _PlaceAppBundle(Program: Program, Link, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt,
|
||||
Log: LogFn) -> None:
|
||||
"""macOS: ~/Applications/<Name>.app, running Script with the program's
|
||||
Python from the app folder (MacSupport.CreateAppBundle)."""
|
||||
import MacSupport
|
||||
|
||||
Bundle = MacSupport.ApplicationsDir / f"{Link.Name}.app"
|
||||
Placed = _Displace(Program, Bundle, "Shortcut", OldReceipt)
|
||||
Command = [str(Program.Python), str(Program.AppDir / Link.Script), *shlex.split(Render(Link.Arguments, Context))]
|
||||
MacSupport.CreateAppBundle(
|
||||
Bundle, Link.Name, f"srhtnin.{Link.Name}", Command, Program.AppDir,
|
||||
Program.AppDir / Link.Icon if Link.Icon else None, Link.Description or Program.Description,
|
||||
)
|
||||
NewReceipt.Files.append(Placed)
|
||||
Log(f"Wrote {Bundle}")
|
||||
|
||||
|
||||
def _ApplyShortcuts(Program: Program, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None:
|
||||
"""Start Menu shortcuts (Windows) and app bundles (macOS), from
|
||||
[[Shortcuts]]; the ones starting services at login are placed by
|
||||
_ApplyServices."""
|
||||
if IsLinux:
|
||||
return
|
||||
for Link in Program.Shortcuts:
|
||||
if not Program.OptionEnabled(Link.Option, NewReceipt.Options):
|
||||
continue
|
||||
if IsMac:
|
||||
_PlaceAppBundle(Program, Link, Context, OldReceipt, NewReceipt, Log)
|
||||
continue
|
||||
Script = Program.AppDir / Link.Script
|
||||
Arguments = f'"{Script}" {Render(Link.Arguments, Context)}'.strip()
|
||||
_PlaceShortcut(
|
||||
Program, StartMenuDir / f"{Link.Name}.lnk", Arguments, Program.AppDir,
|
||||
Program.AppDir / Link.Icon if Link.Icon else None, Link.Description or Program.Description,
|
||||
OldReceipt, NewReceipt, Log,
|
||||
)
|
||||
|
||||
|
||||
def _PlaceAutostart(Program: Program, Service, Command: tuple, OldReceipt: Optional[Receipt],
|
||||
NewReceipt: Receipt, Log: LogFn) -> None:
|
||||
"""Linux without systemd: the XDG autostart entry that starts a service at
|
||||
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"
|
||||
Placed = _Displace(Program, Entry, "Shortcut", OldReceipt)
|
||||
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 _PlaceLaunchAgent(Program: Program, Service, Command: tuple, OldReceipt: Optional[Receipt],
|
||||
NewReceipt: Receipt, Log: LogFn) -> None:
|
||||
"""macOS: the LaunchAgent that starts a service at login, recorded like
|
||||
the autostart entry above."""
|
||||
import MacSupport
|
||||
|
||||
Agent = MacSupport.LaunchAgentPath(Service.Name)
|
||||
Placed = _Displace(Program, Agent, "Shortcut", OldReceipt)
|
||||
Python, Runner, Script, WorkingDir = Command
|
||||
Agent.parent.mkdir(parents=True, exist_ok=True)
|
||||
MacSupport.LogsDir.mkdir(parents=True, exist_ok=True)
|
||||
Agent.write_bytes(MacSupport.LaunchAgent(
|
||||
Service.Name, Services.Command(Python, Runner, Service.Name, Script, WorkingDir), WorkingDir,
|
||||
))
|
||||
NewReceipt.Files.append(Placed)
|
||||
Log(f"Wrote {Agent}")
|
||||
|
||||
|
||||
def _ApplyStandInServices(Program: Program, Context: dict, OldReceipt: Optional[Receipt], NewReceipt: Receipt, Log: LogFn) -> None:
|
||||
"""Services SaraSuite runs itself (Windows; macOS; Linux without systemd):
|
||||
at login from a Startup-folder shortcut / a LaunchAgent / 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()
|
||||
Runner = Program.ProgramDir / "ServiceRun.py"
|
||||
for Service in Program.Services:
|
||||
Script = Program.AppDir / Service.Script
|
||||
WorkingDir = Path(Render(Service.WorkingDir, Context)) if Service.WorkingDir else Program.AppDir
|
||||
if Program.OptionEnabled(Service.Option, NewReceipt.Options):
|
||||
NewReceipt.EnabledServices.append(Service.Id)
|
||||
if IsWindows:
|
||||
_PlaceShortcut(
|
||||
Program, StartupDir / f"{Service.Name}.lnk", f'"{Runner}" {Service.Name} "{Script}" "{WorkingDir}"',
|
||||
WorkingDir, None, f"{Service.Name} (background service)", OldReceipt, NewReceipt, Log,
|
||||
)
|
||||
elif IsMac:
|
||||
_PlaceLaunchAgent(Program, Service, (Program.Python, Runner, Script, WorkingDir), OldReceipt, NewReceipt, 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:
|
||||
Services.Stop(Service.Name, Log)
|
||||
|
||||
|
||||
def _StopWindowsServices(Program: Program, Receipt: Optional[Receipt], Log: LogFn) -> None:
|
||||
"""Windows locks a running program's files, so its services stop before
|
||||
its files or venv are replaced or removed."""
|
||||
if not IsWindows or Receipt is None:
|
||||
return
|
||||
for Name in Receipt.EnabledServices:
|
||||
Services.Stop(Name, Log)
|
||||
|
||||
|
||||
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):
|
||||
return
|
||||
if IsWindows or not HasSystemd:
|
||||
_ApplyStandInServices(Program, Context or Program.Context(NewReceipt.Answers), OldReceipt, NewReceipt, Log)
|
||||
return
|
||||
_Systemctl(Log, "daemon-reload")
|
||||
WasEnabled = set(OldReceipt.EnabledServices) if OldReceipt else set()
|
||||
for Service in Program.Services:
|
||||
if Program.OptionEnabled(Service.Option, NewReceipt.Options):
|
||||
NewReceipt.EnabledServices.append(Service.Unit)
|
||||
NewReceipt.EnabledServices.append(Service.Id)
|
||||
if Service.Unit in WasEnabled:
|
||||
_Systemctl(Log, "try-restart", Service.Unit)
|
||||
else:
|
||||
@@ -450,12 +666,33 @@ def _ApplyServices(Program: Program, OldReceipt: Optional[Receipt], NewReceipt:
|
||||
_Systemctl(Log, "disable", "--now", Service.Unit)
|
||||
|
||||
|
||||
def _StopProcesses(Name: str, Log: LogFn) -> None:
|
||||
"""Linux: stop your own processes called Name (like pkill -x -u $USER,
|
||||
which not every system has)."""
|
||||
Stopped = 0
|
||||
for Entry in Path("/proc").iterdir():
|
||||
try:
|
||||
if Entry.name.isdigit() and Entry.stat().st_uid == os.getuid() and (Entry / "comm").read_text().strip() == Name[:15]:
|
||||
os.kill(int(Entry.name), signal.SIGTERM)
|
||||
Stopped += 1
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
Log(f"Stopped {Name} ({Stopped} running) so it starts again with the change" if Stopped else f"{Name} wasn't running")
|
||||
|
||||
|
||||
def _AfterIntegrationChanges(Program: Program, Log: LogFn) -> None:
|
||||
if not IsLinux:
|
||||
return # Start Menu shortcuts and app bundles need no refresh
|
||||
Applications = Path(Program.Context()["ApplicationsDir"])
|
||||
if any(Template.Target.endswith(".desktop") for Template in Program.Templates) and Applications.exists():
|
||||
_Run(["update-desktop-database", str(Applications)], Log, Check=False)
|
||||
for Unit in Program.RestartUnits:
|
||||
_Systemctl(Log, "try-restart", Unit)
|
||||
if HasSystemd:
|
||||
_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(
|
||||
@@ -508,9 +745,11 @@ def Apply(
|
||||
|
||||
try:
|
||||
Program.ProgramDir.mkdir(parents=True, exist_ok=True)
|
||||
_StopWindowsServices(Program, OldReceipt, Log)
|
||||
_CopyProgramFiles(Program, SourceDir, Log)
|
||||
NewReceipt.RequirementsHash = _SetUpVenv(Program, OldReceipt.RequirementsHash if OldReceipt else "", Log)
|
||||
_ApplyTemplates(Program, Context, OldReceipt, NewReceipt, Log)
|
||||
_ApplyShortcuts(Program, Context, OldReceipt, NewReceipt, Log)
|
||||
_ApplyConfigs(Program, ConfigPlans, NewReceipt, Log)
|
||||
# .new files from an earlier update that are still around stay tracked.
|
||||
if OldReceipt is not None:
|
||||
@@ -518,7 +757,8 @@ def Apply(
|
||||
if Placed.Kind == "ConfigNew" and Path(Placed.Path).exists() and NewReceipt.FindFile(Placed.Path) is None:
|
||||
NewReceipt.Files.append(Placed)
|
||||
_ApplyIniEdits(Program, Context, OldReceipt, NewReceipt, Log)
|
||||
_ApplyServices(Program, OldReceipt, NewReceipt, Log)
|
||||
_ApplyServices(Program, OldReceipt, NewReceipt, Log, Context)
|
||||
_RemoveStaleShortcuts(OldReceipt, NewReceipt, Log)
|
||||
_AfterIntegrationChanges(Program, Log)
|
||||
NewReceipt.Complete = True
|
||||
ClearDownloads(Program.Name)
|
||||
@@ -581,10 +821,14 @@ def PlanRemoval(Program: Program) -> RemovalPlan:
|
||||
Plan.Items.append(RemovalItem(str(Downloaded), "Program", "downloaded source"))
|
||||
|
||||
if Current is not None:
|
||||
ServiceNote = ("background service, stopped and taken out of Startup" if IsWindows
|
||||
else "background service, stopped and its LaunchAgent removed" if IsMac
|
||||
else "systemd user service, stopped and disabled" if HasSystemd
|
||||
else "background service, stopped and taken out of autostart")
|
||||
for Unit in Current.EnabledServices:
|
||||
Plan.Items.append(RemovalItem(Unit, "Integration", "systemd user service, stopped and disabled"))
|
||||
Plan.Items.append(RemovalItem(Unit, "Integration", ServiceNote))
|
||||
for Placed in Current.Files:
|
||||
if Placed.Kind == "Template":
|
||||
if Placed.Kind in ("Template", "Shortcut"):
|
||||
Note = "put back to your previous version" if Placed.Displaced else ""
|
||||
if Placed.Root:
|
||||
Note = ", ".join(Part for Part in ("needs your password", Note) if Part)
|
||||
@@ -625,14 +869,20 @@ def Remove(Program: Program, RemoveConfigs: bool, RemoveRuntime: bool, Log: LogF
|
||||
Current = LoadReceipt(Program.Name)
|
||||
|
||||
if Current is not None:
|
||||
for Unit in Current.EnabledServices:
|
||||
_Systemctl(Log, "disable", "--now", Unit)
|
||||
if IsWindows:
|
||||
_StopWindowsServices(Program, Current, Log)
|
||||
elif not HasSystemd:
|
||||
for Name in Current.EnabledServices:
|
||||
Services.Stop(Name, Log)
|
||||
else:
|
||||
for Unit in Current.EnabledServices:
|
||||
_Systemctl(Log, "disable", "--now", Unit)
|
||||
for Placed in Current.Files:
|
||||
if Placed.Kind == "Template":
|
||||
if Placed.Kind in ("Template", "Shortcut"):
|
||||
_RemovePlacedTemplate(Placed, Log)
|
||||
for Record in Current.IniEdits:
|
||||
_RevertIni(Record, Log)
|
||||
if Current.EnabledServices or any("/systemd/" in Placed.Path for Placed in Current.Files):
|
||||
if HasSystemd and (Current.EnabledServices or any("/systemd/" in Placed.Path for Placed in Current.Files)):
|
||||
_Systemctl(Log, "daemon-reload")
|
||||
|
||||
Groups = {"Program"}
|
||||
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
"""macOS stand-ins for what SaraSuite uses on Linux:
|
||||
|
||||
Linux macOS
|
||||
.desktop entry a small app bundle in ~/Applications (<Name>.app)
|
||||
systemd --user service a LaunchAgent (~/Library/LaunchAgents) that starts it
|
||||
at login, through Programs/ServiceRun.py (Services.py)
|
||||
system python3 a standalone Python installed by uv into SaraSuite's
|
||||
own folder (no admin, no Homebrew, no Xcode tools)
|
||||
fc-list the font files' own names
|
||||
xdg-open open
|
||||
|
||||
Only used on macOS, but nothing in here needs macOS to import.
|
||||
"""
|
||||
|
||||
import plistlib
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from Core import CacheHome, ChildEnvironment, DataDir, Home
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
|
||||
PythonVersion = "3.14" # what uv installs
|
||||
# Apple Silicon only. uv's own release file, and so the Python it installs.
|
||||
UvTarget = "aarch64-apple-darwin"
|
||||
UvUrl = f"https://github.com/astral-sh/uv/releases/latest/download/uv-{UvTarget}.tar.gz"
|
||||
ToolsDir = DataDir / "Tools" # uv itself
|
||||
PythonsDir = DataDir / "Python" # the Pythons it installs (program venvs are built from these)
|
||||
|
||||
ApplicationsDir = Home / "Applications"
|
||||
LaunchAgentsDir = Home / "Library" / "LaunchAgents"
|
||||
LogsDir = Home / "Library" / "Logs"
|
||||
|
||||
|
||||
# --- Python -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _UvEnvironment() -> dict[str, str]:
|
||||
Environment = ChildEnvironment()
|
||||
Environment.update(
|
||||
UV_PYTHON_INSTALL_DIR=str(PythonsDir),
|
||||
UV_PYTHON_PREFERENCE="only-managed", # never the Xcode or Homebrew one
|
||||
UV_CACHE_DIR=str(CacheHome / "SaraSuite" / "uv"),
|
||||
UV_NO_MODIFY_PATH="1",
|
||||
)
|
||||
return Environment
|
||||
|
||||
|
||||
def _Uv() -> Path:
|
||||
return ToolsDir / "uv"
|
||||
|
||||
|
||||
def FindPython() -> Optional[str]:
|
||||
"""The Python uv installed for SaraSuite earlier, if any."""
|
||||
if not _Uv().is_file():
|
||||
return None
|
||||
try:
|
||||
Result = subprocess.run(
|
||||
[str(_Uv()), "python", "find", PythonVersion], capture_output=True, text=True, timeout=60,
|
||||
env=_UvEnvironment(),
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
Found = Result.stdout.strip()
|
||||
return Found if Result.returncode == 0 and Found and Path(Found).is_file() else None
|
||||
|
||||
|
||||
def _Stream(Command: list[str], Log: LogFn) -> int:
|
||||
Log(f"$ {' '.join(Command)}")
|
||||
Process = subprocess.Popen(Command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
errors="replace", env=_UvEnvironment())
|
||||
for Line in Process.stdout:
|
||||
if Line.strip():
|
||||
Log(f" {Line.rstrip()}")
|
||||
return Process.wait()
|
||||
|
||||
|
||||
def _InstallUv(Log: LogFn) -> bool:
|
||||
Log(f"Downloading uv ({UvUrl})")
|
||||
try:
|
||||
with urllib.request.urlopen(UvUrl, timeout=120) as Response, tempfile.TemporaryDirectory() as Temporary:
|
||||
Archive = Path(Temporary) / "uv.tar.gz"
|
||||
Archive.write_bytes(Response.read())
|
||||
with tarfile.open(Archive) as Tar:
|
||||
Member = next((Item for Item in Tar.getmembers() if Item.isfile() and Path(Item.name).name == "uv"), None)
|
||||
if Member is None:
|
||||
Log("The uv download has no uv in it.")
|
||||
return False
|
||||
Tar.extract(Member, Temporary, filter="data")
|
||||
ToolsDir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(Path(Temporary) / Member.name, _Uv())
|
||||
_Uv().chmod(0o755)
|
||||
return True
|
||||
except (OSError, tarfile.TarError) as Error:
|
||||
Log(f"Couldn't download uv: {Error}")
|
||||
return False
|
||||
|
||||
|
||||
def InstallPython(Log: LogFn) -> Optional[str]:
|
||||
"""Download uv, and with it a standalone Python, into SaraSuite's own
|
||||
folder. Nothing is installed system-wide and no password is asked."""
|
||||
Log(f"Python not found; installing Python {PythonVersion} with uv (just for SaraSuite, no admin)")
|
||||
if not _Uv().is_file() and not _InstallUv(Log):
|
||||
return None
|
||||
# --no-bin: no python3.14 command in ~/.local/bin; only SaraSuite uses this one.
|
||||
if _Stream([str(_Uv()), "python", "install", "--no-bin", PythonVersion], Log) != 0:
|
||||
return None
|
||||
return FindPython()
|
||||
|
||||
|
||||
# --- App bundles and icons -------------------------------------------------------------------
|
||||
|
||||
|
||||
def MakeIcns(Png: Path, Icns: Path) -> Optional[Path]:
|
||||
"""An .icns from one of the Sara icons (32x32 pixel art stored at 10x),
|
||||
each size a whole multiple of the 32x32 grid (nearest-neighbour), so
|
||||
the art stays crisp. Needs Pillow; None without it."""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
Grid = Image.open(Png).convert("RGBA").resize((32, 32), Image.NEAREST)
|
||||
Sizes = [Grid.resize((Size, Size), Image.NEAREST) for Size in (16, 32, 64, 128, 256, 512, 1024)]
|
||||
Icns.parent.mkdir(parents=True, exist_ok=True)
|
||||
Sizes[-1].save(Icns, format="ICNS", append_images=Sizes[:-1])
|
||||
return Icns
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def CreateAppBundle(
|
||||
Bundle: Path, Name: str, Identifier: str, Command: list[str], WorkingDir: Path,
|
||||
IconPng: Optional[Path] = None, Description: str = "",
|
||||
) -> None:
|
||||
"""~/Applications/<Name>.app: Launchpad, Spotlight and the Finder list it
|
||||
like any app. Its executable is a shell script that runs Command from
|
||||
WorkingDir. Written fresh each time (the old bundle is replaced)."""
|
||||
if Bundle.exists():
|
||||
shutil.rmtree(Bundle)
|
||||
Contents = Bundle / "Contents"
|
||||
(Contents / "MacOS").mkdir(parents=True)
|
||||
(Contents / "Resources").mkdir()
|
||||
Executable = Contents / "MacOS" / Name
|
||||
Executable.write_text(
|
||||
"#!/bin/sh\n"
|
||||
"# Written by SaraSuite; replaced on every update.\n"
|
||||
f"cd {shlex.quote(str(WorkingDir))} || exit 1\n"
|
||||
f'exec {" ".join(shlex.quote(Part) for Part in Command)} "$@"\n'
|
||||
)
|
||||
Executable.chmod(0o755)
|
||||
Info = {
|
||||
"CFBundleName": Name,
|
||||
"CFBundleDisplayName": Name,
|
||||
"CFBundleIdentifier": Identifier,
|
||||
"CFBundleExecutable": Name,
|
||||
"CFBundlePackageType": "APPL",
|
||||
"CFBundleInfoDictionaryVersion": "6.0",
|
||||
"CFBundleShortVersionString": "1.0",
|
||||
"LSMinimumSystemVersion": "11.0",
|
||||
"NSHighResolutionCapable": True,
|
||||
"NSHumanReadableCopyright": Description,
|
||||
}
|
||||
if IconPng is not None and MakeIcns(IconPng, Contents / "Resources" / f"{Name}.icns") is not None:
|
||||
Info["CFBundleIconFile"] = f"{Name}.icns"
|
||||
with (Contents / "Info.plist").open("wb") as File:
|
||||
plistlib.dump(Info, File)
|
||||
# The Finder and Launchpad cache bundles; tell them this one changed.
|
||||
Register = Path("/System/Library/Frameworks/CoreServices.framework/Frameworks/"
|
||||
"LaunchServices.framework/Support/lsregister")
|
||||
if Register.is_file():
|
||||
subprocess.run([str(Register), "-f", str(Bundle)], capture_output=True, timeout=30)
|
||||
|
||||
|
||||
# --- Services ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def LaunchAgentPath(Name: str) -> Path:
|
||||
return LaunchAgentsDir / f"srhtnin.{Name}.plist"
|
||||
|
||||
|
||||
def LaunchAgent(Name: str, Command: list[str], WorkingDir: Path) -> bytes:
|
||||
"""The LaunchAgent that starts a service at login (macOS's equivalent of
|
||||
the Startup folder / an XDG autostart entry). Only that: ServiceRun.py
|
||||
keeps it running and logs it, like on the other systems, so SaraSuite
|
||||
and SaraSort can start and stop it the same way everywhere."""
|
||||
return plistlib.dumps({
|
||||
"Label": f"srhtnin.{Name}",
|
||||
"ProgramArguments": Command,
|
||||
"WorkingDirectory": str(WorkingDir),
|
||||
"RunAtLoad": True,
|
||||
"ProcessType": "Background",
|
||||
# Only what fails before ServiceRun.py has its own log (e.g. a missing venv).
|
||||
"StandardErrorPath": str(LogsDir / f"{Name}.launchd.log"),
|
||||
})
|
||||
|
||||
|
||||
# --- Fonts and files ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _FontFamilies(FilePath: Path) -> set[str]:
|
||||
"""Family names in one font file's name table (every font of a .ttc).
|
||||
The typographic family (name ID 16) where there is one, else ID 1."""
|
||||
import struct
|
||||
|
||||
Families: set[str] = set()
|
||||
try:
|
||||
Data = FilePath.read_bytes()
|
||||
except OSError:
|
||||
return Families
|
||||
Offsets = [0]
|
||||
if Data[:4] == b"ttcf":
|
||||
Count = struct.unpack_from(">I", Data, 8)[0]
|
||||
Offsets = list(struct.unpack_from(f">{Count}I", Data, 12))
|
||||
for Offset in Offsets:
|
||||
try:
|
||||
for Index in range(struct.unpack_from(">H", Data, Offset + 4)[0]):
|
||||
Tag, _Sum, Start, _Length = struct.unpack_from(">4sIII", Data, Offset + 12 + Index * 16)
|
||||
if Tag != b"name":
|
||||
continue
|
||||
_Format, Count, Strings = struct.unpack_from(">HHH", Data, Start)
|
||||
Found: dict[int, str] = {}
|
||||
for Record in range(Count):
|
||||
PlatformId, _Encoding, Language, NameId, Length, At = struct.unpack_from(
|
||||
">6H", Data, Start + 6 + Record * 12)
|
||||
if NameId not in (1, 16) or NameId in Found:
|
||||
continue
|
||||
Raw = Data[Start + Strings + At:Start + Strings + At + Length]
|
||||
if PlatformId in (0, 3):
|
||||
Found[NameId] = Raw.decode("utf-16-be", "replace")
|
||||
elif PlatformId == 1 and Language == 0:
|
||||
Found[NameId] = Raw.decode("mac-roman", "replace")
|
||||
Name = (Found.get(16) or Found.get(1) or "").strip()
|
||||
if Name and not Name.startswith("."): # ".SF NS" and co. are system-internal
|
||||
Families.add(Name)
|
||||
except struct.error:
|
||||
continue
|
||||
return Families
|
||||
|
||||
|
||||
def FontFamilies() -> set[str]:
|
||||
"""Installed font families: the system's, the shared and your own Fonts folders."""
|
||||
Families: set[str] = set()
|
||||
for Folder in (Path("/System/Library/Fonts"), Path("/Library/Fonts"), Home / "Library" / "Fonts"):
|
||||
if Folder.is_dir():
|
||||
for FilePath in Folder.rglob("*"):
|
||||
if FilePath.suffix.lower() in (".ttf", ".otf", ".ttc"):
|
||||
Families |= _FontFamilies(FilePath)
|
||||
return Families
|
||||
|
||||
|
||||
def OpenPath(Target: Path) -> None:
|
||||
subprocess.Popen(["open", str(Target)], env=ChildEnvironment(), start_new_session=True)
|
||||
@@ -6,6 +6,7 @@ import os
|
||||
import shutil
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import flet as ft
|
||||
@@ -13,7 +14,7 @@ import flet as ft
|
||||
import SelfUpdate
|
||||
from Catalog import UpdateCatalog
|
||||
from Core import (
|
||||
AppName, AppVersion, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataDir,
|
||||
AppName, AppVersion, IsLinux, IsMac, IsWindows, StartMenuDir, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataDir,
|
||||
BundledManifestsDir, DataHome, EffectiveSettings, LoadConfig, Palette,
|
||||
)
|
||||
from Dialogs import ShowInstallDialog, ShowOptionsDialog, ShowRemoveDialog, ShowUpdateDialog
|
||||
@@ -24,10 +25,35 @@ from Settings import ShowSettingsDialog
|
||||
|
||||
_MaxLogLines = 2000
|
||||
|
||||
# When running as an AppImage, SaraSuite can put itself in the app menu.
|
||||
_AppImage = os.environ.get("APPIMAGE", "")
|
||||
_MenuEntry = DataHome / "applications" / "SaraSuite.desktop"
|
||||
_MenuIcon = DataDir / "SaraSuite.png"
|
||||
# When running packaged (AppImage / .exe), SaraSuite can put itself in the
|
||||
# app menu: a desktop entry on Linux, a Start Menu shortcut on Windows. On
|
||||
# macOS SaraSuite.app already is an app wherever it's put (e.g. /Applications).
|
||||
_Packaged = SelfUpdate.RunningAppImage()
|
||||
_AppImage = str(_Packaged) if _Packaged else ""
|
||||
if IsWindows:
|
||||
_MenuEntry = StartMenuDir / "SaraSuite.lnk"
|
||||
_MenuIcon = DataDir / "SaraSuite.ico"
|
||||
else:
|
||||
_MenuEntry = DataHome / "applications" / "SaraSuite.desktop"
|
||||
_MenuIcon = DataDir / "SaraSuite.png"
|
||||
_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 not IsLinux 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():
|
||||
@@ -44,6 +70,12 @@ def _SetMenuEntry(Enabled: bool) -> None:
|
||||
_MenuEntry.unlink(missing_ok=True)
|
||||
_MenuIcon.unlink(missing_ok=True)
|
||||
return
|
||||
if IsWindows:
|
||||
import WindowsSupport
|
||||
|
||||
Icon = WindowsSupport.MakeIcon(AssetsDir / "Icon.png", _MenuIcon)
|
||||
WindowsSupport.CreateShortcut(_MenuEntry, Path(_AppImage), Icon=Icon, Description="Install, update and remove Sara programs")
|
||||
return
|
||||
_MenuIcon.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(AssetsDir / "Icon.png", _MenuIcon)
|
||||
_MenuEntry.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -70,6 +102,13 @@ def Main(Page: ft.Page) -> None:
|
||||
Page.bgcolor = Palette.Base
|
||||
Page.window.width = 960
|
||||
Page.window.height = 720
|
||||
if IsWindows:
|
||||
# The taskbar/title bar icon (on Linux it comes from the desktop entry).
|
||||
import WindowsSupport
|
||||
|
||||
Icon = WindowsSupport.MakeIcon(AssetsDir / "Icon.png", DataDir / "SaraSuite.ico")
|
||||
if Icon is not None:
|
||||
Page.window.icon = str(Icon)
|
||||
|
||||
LogView = ft.ListView(expand=True, auto_scroll=True, spacing=0, padding=8)
|
||||
|
||||
@@ -242,13 +281,13 @@ def Main(Page: ft.Page) -> None:
|
||||
|
||||
def ToggleMenuEntry(e) -> None:
|
||||
_SetMenuEntry(not _MenuEntry.exists())
|
||||
MenuButton.content = "Remove from app menu" if _MenuEntry.exists() else "Add to app menu"
|
||||
MenuButton.content = f"Remove from {_MenuLabel}" if _MenuEntry.exists() else f"Add to {_MenuLabel}"
|
||||
MenuButton.update()
|
||||
|
||||
MenuButton = ft.OutlinedButton(
|
||||
content="Remove from app menu" if _MenuEntry.exists() else "Add to app menu",
|
||||
content=f"Remove from {_MenuLabel}" if _MenuEntry.exists() else f"Add to {_MenuLabel}",
|
||||
style=ChipButtonStyle(),
|
||||
visible=bool(_AppImage),
|
||||
visible=bool(_AppImage) and not IsMac,
|
||||
on_click=ToggleMenuEntry,
|
||||
)
|
||||
|
||||
@@ -303,12 +342,23 @@ def Main(Page: ft.Page) -> None:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_KeepFallbackLibraries() # also before a command (an install from the command line)
|
||||
if len(sys.argv) > 1:
|
||||
if IsWindows and getattr(sys, "frozen", False):
|
||||
# The .exe is built windowed (no console of its own); write the
|
||||
# command-line mode's output to the console it was started from.
|
||||
import ctypes
|
||||
|
||||
if ctypes.windll.kernel32.AttachConsole(-1):
|
||||
sys.stdout = open("CONOUT$", "w", encoding="utf-8", buffering=1)
|
||||
sys.stderr = sys.stdout
|
||||
from Cli import Run
|
||||
|
||||
sys.exit(Run(sys.argv[1:]))
|
||||
# A Flet client copy with the window app ID srhtnin.SaraSuite, so the
|
||||
# desktop matches the window to SaraSuite.desktop (StartupWMClass) instead
|
||||
# of showing it as "com.appveyor.flet". See Programs/FletIdentity.py.
|
||||
os.environ.setdefault("FLET_VIEW_PATH", _LoadFletIdentity().ClientFor(f"srhtnin.{AppName}"))
|
||||
SelfUpdate.CleanUpOldExe()
|
||||
if IsLinux:
|
||||
# A Flet client copy with the window app ID srhtnin.SaraSuite, so the
|
||||
# desktop matches the window to SaraSuite.desktop (StartupWMClass) instead
|
||||
# of showing it as "com.appveyor.flet". See Programs/FletIdentity.py.
|
||||
os.environ.setdefault("FLET_VIEW_PATH", _LoadFletIdentity().ClientFor(f"srhtnin.{AppName}"))
|
||||
ft.run(Main, assets_dir=str(AssetsDir))
|
||||
+115
-21
@@ -1,5 +1,11 @@
|
||||
"""Program manifests: Programs/<Name>/Manifest.toml, plus optional Templates/
|
||||
and Hooks.py next to it. See README.md for the full manifest format."""
|
||||
and Hooks.py next to it. See README.md for the full manifest format.
|
||||
|
||||
A manifest's Platforms (default ["Linux"]) says where the program runs; each
|
||||
SaraSuite only lists the programs for its own system. Any list item can also
|
||||
carry Platform = "Linux", "Windows" or "macOS" to only apply there (e.g.
|
||||
desktop entries on Linux, Start Menu shortcuts on Windows, app bundles on
|
||||
macOS); others are dropped at load."""
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
@@ -11,8 +17,8 @@ from typing import Optional
|
||||
import tomlkit
|
||||
|
||||
from Core import (
|
||||
CacheHome, ConfigHome, CurrentDesktop, DataHome, Home, LoadConfig, ProgramsDir,
|
||||
SharedConfigDir,
|
||||
AutostartDir, CacheHome, ConfigHome, CurrentDesktop, DataHome, HasSystemd, Home, IsLinux, IsMac, IsWindows, LoadConfig,
|
||||
Platform, ProgramsDir, SharedConfigDir, StartMenuDir, StartupDir, UserPortalsSupported,
|
||||
)
|
||||
|
||||
# Never worth copying out of a source checkout, whatever Include says.
|
||||
@@ -50,7 +56,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
|
||||
|
||||
|
||||
@@ -78,8 +86,44 @@ class IniEdit:
|
||||
|
||||
@dataclass
|
||||
class Service:
|
||||
Unit: str # a systemd --user unit, placed by a Template
|
||||
"""A background service. Linux with systemd: a --user Unit (placed by a
|
||||
Template). Windows, macOS, and Linux without systemd (runit, OpenRC, ...):
|
||||
Script (relative to the app folder) run by ServiceRun.py in WorkingDir,
|
||||
at login through a Startup-folder shortcut / a LaunchAgent / an XDG
|
||||
autostart entry (Services.py), so a Linux service gives both Unit and
|
||||
Name/Script."""
|
||||
|
||||
Unit: str = ""
|
||||
Option: str = "" # enabled + started only while this option is ticked
|
||||
Name: str = "" # run by SaraSuite: the service's name (PID file, log, autostart)
|
||||
Script: str = ""
|
||||
WorkingDir: str = ""
|
||||
|
||||
@property
|
||||
def RunBySaraSuite(self) -> bool:
|
||||
return IsWindows or not HasSystemd
|
||||
|
||||
@property
|
||||
def Id(self) -> str:
|
||||
"""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
|
||||
class Shortcut:
|
||||
"""A launcher in the system's app list running Script (relative to the
|
||||
app folder) with the program's Python, from the app folder, with Icon (a
|
||||
PNG in the app folder):
|
||||
- Windows: a Start Menu shortcut (pythonw.exe; the icon becomes an .ico);
|
||||
- macOS: an app bundle, ~/Applications/<Name>.app (the icon becomes an .icns).
|
||||
Linux uses a desktop entry Template instead."""
|
||||
|
||||
Name: str
|
||||
Script: str
|
||||
Arguments: str = ""
|
||||
Icon: str = ""
|
||||
Description: str = ""
|
||||
Option: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -119,6 +163,8 @@ class Program:
|
||||
Services: list[Service] = field(default_factory=list)
|
||||
RestartUnits: list[str] = field(default_factory=list)
|
||||
Leftovers: list[Leftover] = field(default_factory=list)
|
||||
Shortcuts: list[Shortcut] = field(default_factory=list)
|
||||
Platforms: list[str] = field(default_factory=lambda: ["Linux"])
|
||||
Hooks: Optional[ModuleType] = None
|
||||
|
||||
@property
|
||||
@@ -132,9 +178,13 @@ class Program:
|
||||
@property
|
||||
def IconPath(self) -> Optional[Path]:
|
||||
"""The installed copy's own icon if it's there (always current; the
|
||||
Icon= line of the program's desktop entry template says where), otherwise
|
||||
the copy bundled next to the manifest (Programs/<Name>/Icon.png), so the
|
||||
card has an icon before installing too."""
|
||||
Icon= line of the program's desktop entry template, or its Windows
|
||||
Shortcut's Icon, says where), otherwise the copy bundled next to the
|
||||
manifest (Programs/<Name>/Icon.png), so the card has an icon before
|
||||
installing too."""
|
||||
for Link in self.Shortcuts:
|
||||
if Link.Icon and (self.AppDir / Link.Icon).is_file():
|
||||
return self.AppDir / Link.Icon
|
||||
for Template in self.Templates:
|
||||
if Template.Target.endswith(".desktop"):
|
||||
for Line in (self.Folder / "Templates" / Template.Source).read_text().splitlines():
|
||||
@@ -153,6 +203,16 @@ class Program:
|
||||
def GitUrl(self) -> str:
|
||||
return Render(self.Git, self.Context())
|
||||
|
||||
@property
|
||||
def Python(self) -> Path:
|
||||
Venv = self.ProgramDir / ".venv"
|
||||
return Venv / "Scripts" / "python.exe" if IsWindows else Venv / "bin" / "python"
|
||||
|
||||
@property
|
||||
def PythonW(self) -> Path:
|
||||
"""Windows: the venv's console-less python, for GUI programs and services."""
|
||||
return self.Python.with_name("pythonw.exe") if IsWindows else self.Python
|
||||
|
||||
@property
|
||||
def UsesPython(self) -> bool:
|
||||
return bool(self.Requirements or self.RequirementsFile)
|
||||
@@ -170,16 +230,34 @@ class Program:
|
||||
"ConfigHome": str(ConfigHome),
|
||||
"DataHome": str(DataHome),
|
||||
"CacheHome": str(CacheHome),
|
||||
"SystemdUserDir": str(ConfigHome / "systemd" / "user"),
|
||||
"ApplicationsDir": str(DataHome / "applications"),
|
||||
"DBusServicesDir": str(DataHome / "dbus-1" / "services"),
|
||||
"ProgramDir": str(self.ProgramDir),
|
||||
"AppDir": str(self.AppDir),
|
||||
"Python": str(self.ProgramDir / ".venv" / "bin" / "python"),
|
||||
"Python": str(self.Python),
|
||||
"PythonW": str(self.PythonW),
|
||||
"ConfigDir": str(self.ConfigDir),
|
||||
"DesktopName": Desktop,
|
||||
"Desktop": Desktop.lower(),
|
||||
}
|
||||
if IsWindows:
|
||||
Context.update(StartMenuDir=str(StartMenuDir), StartupDir=str(StartupDir))
|
||||
elif IsMac:
|
||||
Context.update(ApplicationsDir=str(Home / "Applications"),
|
||||
LaunchAgentsDir=str(Home / "Library" / "LaunchAgents"))
|
||||
else:
|
||||
Context.update(
|
||||
SystemdUserDir=str(ConfigHome / "systemd" / "user"),
|
||||
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
|
||||
|
||||
@@ -200,8 +278,18 @@ def _LoadHooks(Folder: Path, Name: str) -> Optional[ModuleType]:
|
||||
return Module
|
||||
|
||||
|
||||
def _ForPlatform(Items: list[dict]) -> list[dict]:
|
||||
"""Drop the items meant for the other system (their Platform key)."""
|
||||
Kept = []
|
||||
for Item in Items:
|
||||
Item = dict(Item)
|
||||
if Item.pop("Platform", Platform) == Platform:
|
||||
Kept.append(Item)
|
||||
return Kept
|
||||
|
||||
|
||||
def LoadProgram(Folder: Path) -> Program:
|
||||
Data = tomlkit.parse((Folder / "Manifest.toml").read_text()).unwrap()
|
||||
Data = tomlkit.parse((Folder / "Manifest.toml").read_text(encoding="utf-8")).unwrap()
|
||||
Source = Data.get("Source", {})
|
||||
Version = Data.get("Version", {})
|
||||
Python = Data.get("Python", {})
|
||||
@@ -218,14 +306,18 @@ def LoadProgram(Folder: Path) -> Program:
|
||||
VersionPattern=Version.get("Pattern", ""),
|
||||
Requirements=Python.get("Requirements", []),
|
||||
RequirementsFile=Python.get("RequirementsFile", ""),
|
||||
Options=[Option(**Item) for Item in Data.get("Options", [])],
|
||||
Questions=[Question(**Item) for Item in Data.get("Questions", [])],
|
||||
Templates=[TemplateFile(**Item) for Item in Data.get("Templates", [])],
|
||||
Configs=[ConfigFile(**Item) for Item in Data.get("Configs", [])],
|
||||
IniEdits=[IniEdit(**Item) for Item in Data.get("IniEdits", [])],
|
||||
Services=[Service(**Item) for Item in Data.get("Services", [])],
|
||||
RestartUnits=Data.get("RestartUnits", []),
|
||||
Leftovers=[Leftover(**Item) for Item in Data.get("Leftovers", [])],
|
||||
Options=[Option(**Item) for Item in _ForPlatform(Data.get("Options", []))],
|
||||
Questions=[Question(**Item) for Item in _ForPlatform(Data.get("Questions", []))],
|
||||
# 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", []))],
|
||||
IniEdits=[IniEdit(**Item) for Item in _ForPlatform(Data.get("IniEdits", []))],
|
||||
Services=[Service(**Item) for Item in _ForPlatform(Data.get("Services", []))],
|
||||
RestartUnits=Data.get("RestartUnits", []) if IsLinux else [],
|
||||
Leftovers=[Leftover(**Item) for Item in _ForPlatform(Data.get("Leftovers", []))],
|
||||
Shortcuts=[Shortcut(**Item) for Item in _ForPlatform(Data.get("Shortcuts", []))],
|
||||
Platforms=Data.get("Platforms", ["Linux"]),
|
||||
Hooks=_LoadHooks(Folder, Data["Name"]),
|
||||
)
|
||||
|
||||
@@ -246,6 +338,8 @@ def LoadPrograms() -> list[Program]:
|
||||
Loaded = _LoadFrom(ManifestsDir())
|
||||
except Exception:
|
||||
Loaded = _LoadFrom(BundledManifestsDir)
|
||||
# Only the programs that run on this system (the .exe lists Windows ones, the Mac app macOS ones).
|
||||
Loaded = [Current for Current in Loaded if Platform in Current.Platforms]
|
||||
Names = {Current.Name for Current in Loaded}
|
||||
Ordered = []
|
||||
for Current in Loaded:
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
#!/bin/bash
|
||||
# Builds dist/SaraSuite-x86_64.AppImage: a PyInstaller one-file build (with
|
||||
# the program manifests, fonts and icon bundled) wrapped by appimagetool,
|
||||
# the same way SaraFem's AppImage is made. Needs appimagetool on PATH.
|
||||
# the same way SaraFem's AppImage is made. Needs podman and appimagetool
|
||||
# on PATH.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$(readlink -f "$0")")/.."
|
||||
|
||||
.venv/bin/pip install --quiet --disable-pip-version-check pyinstaller
|
||||
find Programs -name __pycache__ -type d -prune -exec rm -rf {} +
|
||||
|
||||
.venv/bin/pyinstaller --noconfirm --clean --log-level WARN \
|
||||
--onefile --windowed --name SaraSuite --collect-data flet \
|
||||
--add-data "$PWD/assets:assets" --add-data "$PWD/Programs:Programs" \
|
||||
--distpath build/PyInstaller/dist --workpath build/PyInstaller/work --specpath build/PyInstaller \
|
||||
"$PWD/Main.py"
|
||||
# PyInstaller runs in a manylinux_2_28 container (glibc 2.28) with uv's
|
||||
# portable Python, not with Fedora's: PyInstaller bundles libpython, and
|
||||
# Fedora's needs a newer glibc than many distros have ("GLIBC_ABI_GNU2_TLS
|
||||
# not found"). The Python, venv and downloads are cached in build/Container.
|
||||
mkdir -p build/Container
|
||||
podman run --rm --security-opt label=disable -v "$PWD:/src" -w /src \
|
||||
-e UV_CACHE_DIR=/src/build/Container/cache -e UV_PYTHON_INSTALL_DIR=/src/build/Container/python \
|
||||
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 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 \
|
||||
--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" \
|
||||
--distpath build/PyInstaller/dist --workpath build/PyInstaller/work --specpath build/PyInstaller \
|
||||
"$PWD/Main.py"'
|
||||
|
||||
AppDir=build/AppDir
|
||||
rm -rf "$AppDir"
|
||||
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# Builds dist/SaraSuite.exe, the Windows version: the same PyInstaller
|
||||
# one-file recipe as the AppImage, run with the Windows Python inside a Wine
|
||||
# prefix (build/WinBuild/Prefix; PyInstaller can't build for Windows from
|
||||
# Linux directly). The first run sets that prefix up: it downloads the
|
||||
# official Windows Python and installs the build requirements into it.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$(readlink -f "$0")")/.."
|
||||
|
||||
PythonVersion=3.14.8
|
||||
export WINEPREFIX="$PWD/build/WinBuild/Prefix" WINEDEBUG=-all WINEARCH=win64
|
||||
WinPython='C:\Python314\python.exe'
|
||||
|
||||
if [ ! -f "$WINEPREFIX/drive_c/Python314/python.exe" ]; then
|
||||
echo "== Setting up the Windows build environment (one-time)"
|
||||
mkdir -p build/WinBuild
|
||||
Installer="build/WinBuild/python-$PythonVersion-amd64.exe"
|
||||
[ -f "$Installer" ] || curl -sfL -o "$Installer" "https://www.python.org/ftp/python/$PythonVersion/python-$PythonVersion-amd64.exe"
|
||||
wineboot -i >/dev/null 2>&1 || true
|
||||
wine "$Installer" /quiet InstallAllUsers=0 PrependPath=1 Include_test=0 TargetDir='C:\Python314'
|
||||
fi
|
||||
wine "$WinPython" -m pip install --quiet --disable-pip-version-check -r requirements.txt pyinstaller pylnk3 pillow
|
||||
|
||||
find Programs -name __pycache__ -type d -prune -exec rm -rf {} +
|
||||
Win() { winepath -w "$1"; }
|
||||
|
||||
# The window/taskbar/Explorer icon, from the same pixel art at whole multiples of its grid.
|
||||
wine "$WinPython" -c "
|
||||
from PIL import Image
|
||||
Grid = Image.open(r'$(Win assets/Icon.png)').convert('RGBA').resize((32, 32), Image.NEAREST)
|
||||
Grid.resize((256, 256), Image.NEAREST).save(r'$(Win build/WinBuild/Icon.ico)', sizes=[(S, S) for S in (256, 128, 64, 32, 16)])
|
||||
"
|
||||
|
||||
# --python-option "X utf8": Python's UTF-8 mode, since on Windows it would
|
||||
# otherwise read and write every TOML file in the old ANSI code page.
|
||||
wine "$WinPython" -m PyInstaller --noconfirm --clean --log-level WARN \
|
||||
--onefile --windowed --name SaraSuite --collect-data flet \
|
||||
--python-option "X utf8" \
|
||||
--icon "$(Win build/WinBuild/Icon.ico)" \
|
||||
--add-data "$(Win assets);assets" --add-data "$(Win Programs);Programs" \
|
||||
--distpath "$(Win build/WinBuild/dist)" --workpath "$(Win build/WinBuild/work)" --specpath "$(Win build/WinBuild)" \
|
||||
"$(Win Main.py)"
|
||||
|
||||
mkdir -p dist
|
||||
# Renamed into place, like the AppImage, in case one is running from dist/.
|
||||
cp build/WinBuild/dist/SaraSuite.exe dist/SaraSuite.exe.new
|
||||
mv -f dist/SaraSuite.exe.new dist/SaraSuite.exe
|
||||
echo "Built dist/SaraSuite.exe"
|
||||
Executable
+220
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build dist/SaraSuite-macOS.zip (SaraSuite.app, Apple Silicon) on Linux.
|
||||
|
||||
PyInstaller can only build a Mac app on a Mac, so this puts the app together
|
||||
from parts that need no compiling:
|
||||
- a standalone Python for macOS (python-build-standalone, the Pythons uv installs);
|
||||
- SaraSuite's requirements as macOS wheels (pip download for the Mac platform);
|
||||
- the Flet desktop client for macOS, bundled where flet-desktop looks for it,
|
||||
so the first start needs no download;
|
||||
- SaraSuite's own code, assets and Programs/;
|
||||
- Contents/MacOS/SaraSuite, a shell script that runs Main.py with that Python.
|
||||
|
||||
The app isn't signed by an Apple developer account, so the first start needs
|
||||
System Settings > Privacy & Security > Open Anyway (see the website's
|
||||
instructions). Its launcher then clears the download quarantine from the
|
||||
whole app, so the Python and Flet inside it run too.
|
||||
|
||||
Packaging/BuildMacApp.py # also run by Packaging/Release.py
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import plistlib
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
RepoDir = Path(__file__).resolve().parent.parent
|
||||
BuildDir = RepoDir / "build" / "MacBuild"
|
||||
DownloadsDir = BuildDir / "Downloads" # kept between builds
|
||||
BuildVenv = BuildDir / "Venv" # Pillow, for the .icns
|
||||
Output = RepoDir / "dist" / "SaraSuite-macOS.zip"
|
||||
|
||||
PythonVersion = "3.14"
|
||||
MacPlatform = "macosx_11_0_arm64"
|
||||
PythonTarget = "aarch64-apple-darwin"
|
||||
StandaloneReleases = "https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest"
|
||||
AppIdentifier = "srhtnin.SaraSuite"
|
||||
UserAgent = "SaraSuite-Build (Python)"
|
||||
|
||||
# The launcher won't run from the read-only copy macOS makes of a quarantined
|
||||
# app that wasn't moved out of Downloads (App Translocation): neither clearing
|
||||
# the quarantine nor self-updating would work there.
|
||||
Launcher = '''#!/bin/sh
|
||||
# SaraSuite.app's launcher (written by Packaging/BuildMacApp.py).
|
||||
Contents="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
App="$(dirname "$Contents")"
|
||||
case "$App" in
|
||||
*/AppTranslocation/*)
|
||||
/usr/bin/osascript -e 'display alert "Move SaraSuite to Applications first" message "Drag SaraSuite from your Downloads folder into Applications, then open it from there."' >/dev/null 2>&1
|
||||
exit 1 ;;
|
||||
esac
|
||||
# Downloaded apps are quarantined, the Python and Flet inside included. Once
|
||||
# macOS has let SaraSuite itself open, the rest of it may run too.
|
||||
/usr/bin/xattr -dr com.apple.quarantine "$App" 2>/dev/null
|
||||
export SARASUITE_APP="$App"
|
||||
exec "$Contents/Resources/Python/bin/python3" "$Contents/Resources/SaraSuite/Main.py" "$@"
|
||||
'''
|
||||
|
||||
|
||||
def Download(Url: str, Name: str) -> Path:
|
||||
"""Into DownloadsDir, once."""
|
||||
Target = DownloadsDir / Name
|
||||
if Target.is_file():
|
||||
return Target
|
||||
print(f"Downloading {Url}")
|
||||
DownloadsDir.mkdir(parents=True, exist_ok=True)
|
||||
Request = urllib.request.Request(Url, headers={"User-Agent": UserAgent})
|
||||
with urllib.request.urlopen(Request, timeout=600) as Response:
|
||||
Partial = Target.with_suffix(".part")
|
||||
Partial.write_bytes(Response.read())
|
||||
Partial.replace(Target)
|
||||
return Target
|
||||
|
||||
|
||||
def StandalonePython() -> Path:
|
||||
"""The latest python-build-standalone release of PythonVersion for Apple Silicon."""
|
||||
Request = urllib.request.Request(StandaloneReleases, headers={"User-Agent": UserAgent})
|
||||
with urllib.request.urlopen(Request, timeout=60) as Response:
|
||||
Assets = json.load(Response)["assets"]
|
||||
Pattern = re.compile(rf"cpython-{re.escape(PythonVersion)}\.\d+\+\d+-{PythonTarget}-install_only_stripped\.tar\.gz")
|
||||
Asset = next((Item for Item in Assets if Pattern.fullmatch(Item["name"])), None)
|
||||
if Asset is None:
|
||||
sys.exit(f"BuildMacApp.py: no Python {PythonVersion} for {PythonTarget} in the latest python-build-standalone release")
|
||||
return Download(Asset["browser_download_url"], Asset["name"])
|
||||
|
||||
|
||||
def MakeIcns(Png: Path, Icns: Path) -> None:
|
||||
"""Same as MacSupport.MakeIcns: whole multiples of the 32x32 art grid."""
|
||||
from PIL import Image
|
||||
|
||||
Grid = Image.open(Png).convert("RGBA").resize((32, 32), Image.NEAREST)
|
||||
Sizes = [Grid.resize((Size, Size), Image.NEAREST) for Size in (16, 32, 64, 128, 256, 512, 1024)]
|
||||
Sizes[-1].save(Icns, format="ICNS", append_images=Sizes[:-1])
|
||||
|
||||
|
||||
def CopyCode(Destination: Path) -> None:
|
||||
Ignore = shutil.ignore_patterns("__pycache__", "*.pyc")
|
||||
Destination.mkdir(parents=True)
|
||||
for Source in sorted(RepoDir.glob("*.py")):
|
||||
shutil.copy2(Source, Destination / Source.name)
|
||||
shutil.copytree(RepoDir / "assets", Destination / "assets", ignore=Ignore)
|
||||
shutil.copytree(RepoDir / "Programs", Destination / "Programs", ignore=Ignore)
|
||||
|
||||
|
||||
def Zip(Bundle: Path, Target: Path) -> None:
|
||||
"""Zip the app keeping what a Mac app needs: execute bits and symlinks
|
||||
(Python's bin/python3 is one), the way Finder's Archive Utility and
|
||||
ditto read them back."""
|
||||
Target.parent.mkdir(parents=True, exist_ok=True)
|
||||
Partial = Target.with_suffix(".zip.new")
|
||||
with zipfile.ZipFile(Partial, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as Archive:
|
||||
for Current in [Bundle, *sorted(Bundle.rglob("*"))]:
|
||||
Name = Current.relative_to(Bundle.parent).as_posix()
|
||||
Mode = Current.lstat().st_mode
|
||||
Stamp = time.localtime(max(Current.lstat().st_mtime, 315532800))[:6] # zip dates start in 1980
|
||||
if Current.is_symlink():
|
||||
Info = zipfile.ZipInfo(Name, Stamp)
|
||||
Info.create_system = 3 # Unix, so the mode below counts
|
||||
Info.external_attr = (stat.S_IFLNK | 0o755) << 16
|
||||
Archive.writestr(Info, os.readlink(Current))
|
||||
elif Current.is_dir():
|
||||
Info = zipfile.ZipInfo(Name + "/", Stamp)
|
||||
Info.create_system = 3
|
||||
Info.external_attr = (stat.S_IFDIR | 0o755) << 16 | 0x10
|
||||
Archive.writestr(Info, b"")
|
||||
else:
|
||||
Info = zipfile.ZipInfo.from_file(Current, Name)
|
||||
Info.create_system = 3
|
||||
Info.external_attr = (stat.S_IFREG | (0o755 if Mode & 0o111 else 0o644)) << 16
|
||||
Info.compress_type = zipfile.ZIP_DEFLATED
|
||||
Archive.writestr(Info, Current.read_bytes())
|
||||
Partial.replace(Target)
|
||||
|
||||
|
||||
def Main() -> None:
|
||||
try:
|
||||
import PIL # noqa: F401
|
||||
except ImportError:
|
||||
# Pillow for the icon: run this script again from a build venv that has it.
|
||||
if not (BuildVenv / "bin" / "python").exists():
|
||||
subprocess.run([sys.executable, "-m", "venv", str(BuildVenv)], check=True)
|
||||
subprocess.run([str(BuildVenv / "bin" / "python"), "-m", "pip", "install", "--quiet", "pillow"], check=True)
|
||||
os.execv(BuildVenv / "bin" / "python", [str(BuildVenv / "bin" / "python"), __file__, *sys.argv[1:]])
|
||||
|
||||
Version = re.search(r'^AppVersion = "([^"]+)"', (RepoDir / "Core.py").read_text(), re.MULTILINE)[1]
|
||||
Stage = BuildDir / "Stage"
|
||||
shutil.rmtree(Stage, ignore_errors=True)
|
||||
Bundle = Stage / "SaraSuite.app"
|
||||
Contents = Bundle / "Contents"
|
||||
Resources = Contents / "Resources"
|
||||
(Contents / "MacOS").mkdir(parents=True)
|
||||
Resources.mkdir()
|
||||
|
||||
# Python, unpacked as Resources/Python.
|
||||
with tarfile.open(StandalonePython()) as Tar:
|
||||
Tar.extractall(Resources, filter="tar")
|
||||
(Resources / "python").rename(Resources / "Python")
|
||||
SitePackages = next((Resources / "Python" / "lib").glob("python3.*")) / "site-packages"
|
||||
Minor = SitePackages.parent.name.removeprefix("python")
|
||||
|
||||
# SaraSuite's requirements as macOS wheels, straight into that Python.
|
||||
# (Pillow and flet-desktop are only for macOS, so they're named here: pip
|
||||
# judges requirements.txt's markers by this Linux machine.)
|
||||
Requirements = [Line for Line in (RepoDir / "requirements.txt").read_text().splitlines()
|
||||
if Line.strip() and not Line.startswith("#") and "sys_platform" not in Line]
|
||||
print("Installing the macOS wheels")
|
||||
subprocess.run([
|
||||
sys.executable, "-m", "pip", "install", "--quiet", "--disable-pip-version-check", "--no-compile",
|
||||
"--target", str(SitePackages), "--platform", MacPlatform, "--python-version", Minor,
|
||||
"--implementation", "cp", "--abi", f"cp{Minor.replace('.', '')}", "--only-binary=:all:",
|
||||
*Requirements, "pillow", "flet-desktop",
|
||||
], check=True)
|
||||
FletVersion = re.search(r'version = "([^"]+)"', (SitePackages / "flet_desktop" / "version.py").read_text())[1]
|
||||
Wanted = re.search(r"^Version: (.+)$", next(SitePackages.glob("flet-*.dist-info/METADATA")).read_text(), re.MULTILINE)[1]
|
||||
if FletVersion != Wanted:
|
||||
sys.exit(f"BuildMacApp.py: flet {Wanted} but flet-desktop {FletVersion}")
|
||||
|
||||
# The Flet client, where flet-desktop looks for a bundled one first.
|
||||
Client = Download(f"https://github.com/flet-dev/flet/releases/download/v{FletVersion}/flet-macos.tar.gz",
|
||||
f"flet-macos-{FletVersion}.tar.gz")
|
||||
(SitePackages / "flet_desktop" / "app").mkdir(exist_ok=True)
|
||||
shutil.copy2(Client, SitePackages / "flet_desktop" / "app" / "flet-macos.tar.gz")
|
||||
|
||||
CopyCode(Resources / "SaraSuite")
|
||||
MakeIcns(RepoDir / "assets" / "Icon.png", Resources / "SaraSuite.icns")
|
||||
Executable = Contents / "MacOS" / "SaraSuite"
|
||||
Executable.write_text(Launcher)
|
||||
Executable.chmod(0o755)
|
||||
with (Contents / "Info.plist").open("wb") as File:
|
||||
plistlib.dump({
|
||||
"CFBundleName": "SaraSuite",
|
||||
"CFBundleDisplayName": "SaraSuite",
|
||||
"CFBundleIdentifier": AppIdentifier,
|
||||
"CFBundleExecutable": "SaraSuite",
|
||||
"CFBundleIconFile": "SaraSuite.icns",
|
||||
"CFBundlePackageType": "APPL",
|
||||
"CFBundleInfoDictionaryVersion": "6.0",
|
||||
"CFBundleShortVersionString": Version,
|
||||
"CFBundleVersion": Version,
|
||||
"LSMinimumSystemVersion": "11.0",
|
||||
"LSArchitecturePriority": ["arm64"],
|
||||
"NSHighResolutionCapable": True,
|
||||
"NSHumanReadableCopyright": "Install, update and remove Sara programs",
|
||||
}, File)
|
||||
|
||||
Zip(Bundle, Output)
|
||||
print(f"Built {Output.relative_to(RepoDir)} ({Output.stat().st_size // 1_000_000} MB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Main()
|
||||
+27
-16
@@ -1,7 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish a SaraSuite release: build the AppImage, create the Gitea release
|
||||
v<AppVersion> (from Core.py) with it attached, and copy it to the Shared
|
||||
folder. Running SaraSuites then offer to update themselves (SelfUpdate.py).
|
||||
"""Publish a SaraSuite release: build the AppImage (Linux), SaraSuite.exe
|
||||
(Windows, built under Wine by BuildExe.sh) and SaraSuite-macOS.zip (Apple
|
||||
Silicon, put together by BuildMacApp.py), create the Gitea release
|
||||
v<AppVersion> (from Core.py) with all three attached, and copy them to the
|
||||
Shared folder. Running SaraSuites then offer to update themselves (SelfUpdate.py),
|
||||
each to its own platform's file.
|
||||
|
||||
Packaging/Release.py # after bumping AppVersion in Core.py
|
||||
Packaging/Release.py --notes "What changed"
|
||||
@@ -25,7 +28,12 @@ from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
RepoDir = Path(__file__).resolve().parent.parent
|
||||
AppImage = RepoDir / "dist" / "SaraSuite-x86_64.AppImage"
|
||||
# Each running SaraSuite downloads the one for its own platform (SelfUpdate.AssetName).
|
||||
Builds = [
|
||||
("Packaging/BuildAppImage.sh", RepoDir / "dist" / "SaraSuite-x86_64.AppImage"),
|
||||
("Packaging/BuildExe.sh", RepoDir / "dist" / "SaraSuite.exe"),
|
||||
("Packaging/BuildMacApp.py", RepoDir / "dist" / "SaraSuite-macOS.zip"),
|
||||
]
|
||||
SharedDir = Path("/mnt/files/Shared/SaraSuite")
|
||||
|
||||
|
||||
@@ -93,26 +101,29 @@ def Main() -> None:
|
||||
Range = f"{Previous[0]}..HEAD" if Previous else "HEAD"
|
||||
Notes = Git("log", "--format=- %s", Range) or "- First release with self-updates."
|
||||
|
||||
print(f"== Building SaraSuite {Version}")
|
||||
subprocess.run(["Packaging/BuildAppImage.sh"], cwd=RepoDir, check=True,
|
||||
env={**os.environ, "PATH": f"{Path.home()}/.local/bin:{os.environ['PATH']}"})
|
||||
for Script, Output in Builds:
|
||||
print(f"== Building {Output.name} {Version}")
|
||||
subprocess.run([Script], cwd=RepoDir, check=True,
|
||||
env={**os.environ, "PATH": f"{Path.home()}/.local/bin:{os.environ['PATH']}"})
|
||||
|
||||
print(f"== Creating release {Tag}")
|
||||
Created = Api("POST", f"{ApiBase}/releases", TokenValue, json.dumps({
|
||||
"tag_name": Tag, "target_commitish": Head, "name": f"SaraSuite {Version}", "body": Notes,
|
||||
}).encode())
|
||||
|
||||
print(f"== Uploading {AppImage.name} ({AppImage.stat().st_size // 1_000_000} MB)")
|
||||
Boundary = uuid.uuid4().hex
|
||||
Body = (
|
||||
f"--{Boundary}\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"{AppImage.name}\"\r\n"
|
||||
f"Content-Type: application/octet-stream\r\n\r\n"
|
||||
).encode() + AppImage.read_bytes() + f"\r\n--{Boundary}--\r\n".encode()
|
||||
Api("POST", f"{ApiBase}/releases/{Created['id']}/assets?name={AppImage.name}", TokenValue, Body,
|
||||
f"multipart/form-data; boundary={Boundary}")
|
||||
for _Script, Output in Builds:
|
||||
print(f"== Uploading {Output.name} ({Output.stat().st_size // 1_000_000} MB)")
|
||||
Boundary = uuid.uuid4().hex
|
||||
Body = (
|
||||
f"--{Boundary}\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"{Output.name}\"\r\n"
|
||||
f"Content-Type: application/octet-stream\r\n\r\n"
|
||||
).encode() + Output.read_bytes() + f"\r\n--{Boundary}--\r\n".encode()
|
||||
Api("POST", f"{ApiBase}/releases/{Created['id']}/assets?name={Output.name}", TokenValue, Body,
|
||||
f"multipart/form-data; boundary={Boundary}")
|
||||
|
||||
if SharedDir.is_dir():
|
||||
shutil.copy2(AppImage, SharedDir / AppImage.name)
|
||||
for _Script, Output in Builds:
|
||||
shutil.copy2(Output, SharedDir / Output.name)
|
||||
print(f"== Copied to {SharedDir}")
|
||||
Git("fetch", "--quiet", "--tags", "origin") # the new tag, for the next release's notes
|
||||
print(f"== Released {Tag}: {Origin.scheme}://{Origin.netloc}/{Owner}/{Repo}/releases/tag/{Tag}")
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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:
|
||||
"""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
|
||||
@@ -45,6 +83,7 @@ def ClientFor(AppId: str) -> str:
|
||||
if Binary.count(b"\x00" + OriginalId + b"\x00") != 1:
|
||||
return "" # not the client layout this was written for
|
||||
|
||||
AddMissingLibraries(Copy.parent / "fallback-libs")
|
||||
Target = Copy / "flet"
|
||||
if not (Target.exists() and Target.read_bytes() == Patched):
|
||||
Copy.mkdir(parents=True, exist_ok=True)
|
||||
@@ -64,4 +103,16 @@ def ClientFor(AppId: str) -> str:
|
||||
|
||||
|
||||
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)
|
||||
@@ -1,5 +1,6 @@
|
||||
Name = "SaraFem"
|
||||
Description = "Desktop file manager that understands SaraTag Books, and can stand in as the system file picker."
|
||||
Description = "Desktop file manager that understands SaraTag Books, and (on Linux) can stand in as the system file picker."
|
||||
Platforms = ["Linux", "Windows", "macOS"]
|
||||
|
||||
# The portal reads its descriptors and preferences once at startup.
|
||||
RestartUnits = ["xdg-desktop-portal.service"]
|
||||
@@ -12,61 +13,90 @@ Exclude = ["Packaging"]
|
||||
[Python]
|
||||
RequirementsFile = "requirements.txt"
|
||||
|
||||
# The file picker (xdg-desktop-portal) and default file manager (mimeapps.list)
|
||||
# are Linux things; Windows has no equivalent SaraFem can plug into.
|
||||
[[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]]
|
||||
Platform = "Linux"
|
||||
Id = "DefaultFileManager"
|
||||
Label = "Make SaraFem the default file manager"
|
||||
Default = false
|
||||
|
||||
# Linux: a launcher, a desktop entry, and the file picker's portal service.
|
||||
[[Templates]]
|
||||
Platform = "Linux"
|
||||
Source = "Launch.sh"
|
||||
Target = "{AppDir}/Packaging/Launch.sh" # where PortalService.py looks for it
|
||||
Executable = true
|
||||
|
||||
[[Templates]]
|
||||
Platform = "Linux"
|
||||
Source = "SaraFem.desktop"
|
||||
Target = "{ApplicationsDir}/SaraFem.desktop"
|
||||
|
||||
[[Templates]]
|
||||
Platform = "Linux"
|
||||
Source = "PortalService.service"
|
||||
Target = "{DBusServicesDir}/org.freedesktop.impl.portal.desktop.SaraFem.service"
|
||||
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]]
|
||||
File = "{ConfigHome}/xdg-desktop-portal/{Desktop}-portals.conf"
|
||||
Platform = "Linux"
|
||||
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]]
|
||||
Platform = "Linux"
|
||||
File = "{ConfigHome}/mimeapps.list"
|
||||
Section = "Default Applications"
|
||||
Key = "inode/directory"
|
||||
Value = "SaraFem.desktop"
|
||||
Option = "DefaultFileManager"
|
||||
|
||||
# Windows: a Start Menu shortcut (pythonw.exe Main.py, from the app folder).
|
||||
[[Shortcuts]]
|
||||
Platform = "Windows"
|
||||
Name = "SaraFem"
|
||||
Script = "Main.py"
|
||||
Icon = "assets/Icon.png"
|
||||
|
||||
# macOS: an app bundle, ~/Applications/SaraFem.app (python Main.py, from the app folder).
|
||||
[[Shortcuts]]
|
||||
Platform = "macOS"
|
||||
Name = "SaraFem"
|
||||
Script = "Main.py"
|
||||
Icon = "assets/Icon.png"
|
||||
|
||||
[[Leftovers]]
|
||||
Path = "{CacheHome}/SaraFem"
|
||||
Kind = "Runtime"
|
||||
Note = "thumbnail cache"
|
||||
|
||||
[[Leftovers]]
|
||||
Platform = "Linux"
|
||||
Path = "{ConfigHome}/SaraFem"
|
||||
Kind = "Config"
|
||||
Note = "old pre-SaraSuite config location"
|
||||
|
||||
# Shared with the other Flet programs; see Programs/FletIdentity.py.
|
||||
[[Templates]]
|
||||
Platform = "Linux"
|
||||
Source = "../../FletIdentity.py"
|
||||
Target = "{ProgramDir}/FletIdentity.py"
|
||||
@@ -4,5 +4,9 @@
|
||||
# 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.
|
||||
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
|
||||
exec "{Python}" Main.py "$@"
|
||||
@@ -1,6 +1,7 @@
|
||||
# Kept in sync between SaraSuite/Programs/SaraSort/ and SaraSort/Packaging/SaraSuite/.
|
||||
Name = "SaraSort"
|
||||
Parent = "SaraSortd" # a GUI for SaraSortd, listed under it
|
||||
Platforms = ["Linux", "Windows", "macOS"]
|
||||
Description = "A GUI for configuring SaraSortd: a flowchart of what goes where, its service and logs, configs and templates."
|
||||
|
||||
[Source]
|
||||
@@ -11,15 +12,32 @@ Exclude = ["Packaging"]
|
||||
[Python]
|
||||
RequirementsFile = "requirements.txt"
|
||||
|
||||
# Linux: a launcher and a desktop entry.
|
||||
[[Templates]]
|
||||
Platform = "Linux"
|
||||
Source = "Launch.sh"
|
||||
Target = "{AppDir}/Packaging/Launch.sh"
|
||||
Executable = true
|
||||
|
||||
[[Templates]]
|
||||
Platform = "Linux"
|
||||
Source = "SaraSort.desktop"
|
||||
Target = "{ApplicationsDir}/SaraSort.desktop"
|
||||
|
||||
# Windows: a Start Menu shortcut (pythonw.exe Main.py, from the app folder).
|
||||
[[Shortcuts]]
|
||||
Platform = "Windows"
|
||||
Name = "SaraSort"
|
||||
Script = "Main.py"
|
||||
Icon = "assets/Icon.png"
|
||||
|
||||
# macOS: an app bundle, ~/Applications/SaraSort.app (python Main.py, from the app folder).
|
||||
[[Shortcuts]]
|
||||
Platform = "macOS"
|
||||
Name = "SaraSort"
|
||||
Script = "Main.py"
|
||||
Icon = "assets/Icon.png"
|
||||
|
||||
# Made by SaraSort itself: Config.toml, Layout.toml, Templates/.
|
||||
[[Leftovers]]
|
||||
Path = "{ConfigDir}"
|
||||
@@ -28,5 +46,6 @@ Note = "SaraSort's settings, flowchart layout and your templates"
|
||||
|
||||
# Shared with the other Flet programs; see Programs/FletIdentity.py.
|
||||
[[Templates]]
|
||||
Platform = "Linux"
|
||||
Source = "../../FletIdentity.py"
|
||||
Target = "{ProgramDir}/FletIdentity.py"
|
||||
@@ -4,5 +4,9 @@
|
||||
# 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.
|
||||
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
|
||||
exec "{Python}" Main.py "$@"
|
||||
@@ -1,5 +1,6 @@
|
||||
Name = "SaraSortd"
|
||||
Description = "Sorts files from input folders into output folders by the rules in its configs. Runs in the background as a systemd user service."
|
||||
Description = "Sorts files from input folders into output folders by the rules in its configs. Runs in the background."
|
||||
Platforms = ["Linux", "Windows", "macOS"]
|
||||
|
||||
[Version]
|
||||
File = "SaraSortd.py"
|
||||
@@ -39,12 +40,41 @@ Target = "{ConfigDir}/InputDirConf.toml"
|
||||
Source = "OutputDirConf.toml"
|
||||
Target = "{ConfigDir}/OutputDirConf.toml"
|
||||
|
||||
# Linux with systemd: a systemd user service.
|
||||
[[Templates]]
|
||||
Platform = "Linux"
|
||||
Source = "SaraSortd.service"
|
||||
Target = "{SystemdUserDir}/SaraSortd.service"
|
||||
|
||||
# Windows, macOS, 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 / a LaunchAgent / 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]]
|
||||
Platform = "Linux"
|
||||
Unit = "SaraSortd.service"
|
||||
Name = "SaraSortd"
|
||||
Script = "SaraSortd.py"
|
||||
WorkingDir = "{ConfigDir}"
|
||||
Option = "Autostart"
|
||||
|
||||
[[Services]]
|
||||
Platform = "Windows"
|
||||
Name = "SaraSortd"
|
||||
Script = "SaraSortd.py"
|
||||
WorkingDir = "{ConfigDir}"
|
||||
Option = "Autostart"
|
||||
|
||||
[[Services]]
|
||||
Platform = "macOS"
|
||||
Name = "SaraSortd"
|
||||
Script = "SaraSortd.py"
|
||||
WorkingDir = "{ConfigDir}"
|
||||
Option = "Autostart"
|
||||
|
||||
# Everything SaraSortd makes inside the sorting folders is worked out from
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Kept in sync between SaraSuite/Programs/SaraTag/ and SaraTag/Packaging/SaraSuite/.
|
||||
Name = "SaraTag"
|
||||
Platforms = ["Linux", "Windows", "macOS"]
|
||||
Description = "Tag files with Books (sidecars) defined by Lexicons: browse, filter and sort by tag, edit tags on many files at once."
|
||||
|
||||
[Version]
|
||||
@@ -20,15 +21,32 @@ RequirementsFile = "requirements.txt"
|
||||
Source = "Location.toml"
|
||||
Target = "{AppDir}/.saratag/Location.toml" # tells .saratag/Core.py where the data lives
|
||||
|
||||
# Linux: a launcher and a desktop entry.
|
||||
[[Templates]]
|
||||
Platform = "Linux"
|
||||
Source = "Launch.sh"
|
||||
Target = "{AppDir}/Packaging/Launch.sh"
|
||||
Executable = true
|
||||
|
||||
[[Templates]]
|
||||
Platform = "Linux"
|
||||
Source = "SaraTag.desktop"
|
||||
Target = "{ApplicationsDir}/SaraTag.desktop"
|
||||
|
||||
# Windows: a Start Menu shortcut (pythonw.exe Main.py, from the app folder).
|
||||
[[Shortcuts]]
|
||||
Platform = "Windows"
|
||||
Name = "SaraTag"
|
||||
Script = "Main.py"
|
||||
Icon = "assets/Icon.png"
|
||||
|
||||
# macOS: an app bundle, ~/Applications/SaraTag.app (python Main.py, from the app folder).
|
||||
[[Shortcuts]]
|
||||
Platform = "macOS"
|
||||
Name = "SaraTag"
|
||||
Script = "Main.py"
|
||||
Icon = "assets/Icon.png"
|
||||
|
||||
# Made by SaraTag itself. The Lexicons are your tag definitions: only tick
|
||||
# "remove configs" if you don't need them any more.
|
||||
[[Leftovers]]
|
||||
@@ -38,5 +56,6 @@ Note = "SaraTag's settings, Records, Libraries, history and Lexicons (Books next
|
||||
|
||||
# Shared with the other Flet programs; see Programs/FletIdentity.py.
|
||||
[[Templates]]
|
||||
Platform = "Linux"
|
||||
Source = "../../FletIdentity.py"
|
||||
Target = "{ProgramDir}/FletIdentity.py"
|
||||
@@ -4,5 +4,9 @@
|
||||
# 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.
|
||||
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
|
||||
exec "{Python}" Main.py "$@"
|
||||
@@ -1,3 +1,4 @@
|
||||
# Written by SaraSuite. Where SaraTag keeps SaraTagConf.toml, its Records,
|
||||
# history and Lexicons (see .saratag/Core.py).
|
||||
DataDir = "{ConfigDir}"
|
||||
# A literal ('...') string: Windows paths are full of backslashes.
|
||||
DataDir = '{ConfigDir}'
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,55 @@
|
||||
# Kept in sync between SaraSuite/Programs/SaraTune/ and SaraTune/Packaging/SaraSuite/.
|
||||
Name = "SaraTune"
|
||||
Platforms = ["Linux", "Windows", "macOS"]
|
||||
Description = "A music player with two modes: Audio to open a file or folder and listen right away, Music for your library, a queue and saved playlists. Linux needs libmpv (Fedora: mpv-libs)."
|
||||
|
||||
[Source]
|
||||
Git = "{GitHost}/SaraTune"
|
||||
Include = ["*.py", "requirements.txt", "assets/**"]
|
||||
Exclude = ["Packaging"]
|
||||
|
||||
[Python]
|
||||
RequirementsFile = "requirements.txt"
|
||||
|
||||
# Linux: a launcher and a desktop entry (also offered for opening audio files).
|
||||
[[Templates]]
|
||||
Platform = "Linux"
|
||||
Source = "Launch.sh"
|
||||
Target = "{AppDir}/Packaging/Launch.sh"
|
||||
Executable = true
|
||||
|
||||
[[Templates]]
|
||||
Platform = "Linux"
|
||||
Source = "SaraTune.desktop"
|
||||
Target = "{ApplicationsDir}/SaraTune.desktop"
|
||||
|
||||
# Windows: a Start Menu shortcut (pythonw.exe Main.py, from the app folder).
|
||||
[[Shortcuts]]
|
||||
Platform = "Windows"
|
||||
Name = "SaraTune"
|
||||
Script = "Main.py"
|
||||
Icon = "assets/Icon.png"
|
||||
|
||||
# macOS: an app bundle, ~/Applications/SaraTune.app (python Main.py, from the app folder).
|
||||
[[Shortcuts]]
|
||||
Platform = "macOS"
|
||||
Name = "SaraTune"
|
||||
Script = "Main.py"
|
||||
Icon = "assets/Icon.png"
|
||||
|
||||
# Made by SaraTune itself.
|
||||
[[Leftovers]]
|
||||
Path = "{ConfigDir}"
|
||||
Kind = "Config"
|
||||
Note = "SaraTune's settings, saved queue and your playlists"
|
||||
|
||||
[[Leftovers]]
|
||||
Path = "{CacheHome}/SaraSuite/SaraTune"
|
||||
Kind = "Runtime"
|
||||
Note = "library tag cache and cover thumbnails"
|
||||
|
||||
# Shared with the other Flet programs; see Programs/FletIdentity.py.
|
||||
[[Templates]]
|
||||
Platform = "Linux"
|
||||
Source = "../../FletIdentity.py"
|
||||
Target = "{ProgramDir}/FletIdentity.py"
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# Written by SaraSuite. Runs from the app folder so Flet finds assets/.
|
||||
# Flet's audio player is only in its "full" desktop client.
|
||||
export FLET_DESKTOP_FLAVOR=full
|
||||
# A Flet client copy with the window app ID srhtnin.{Name}, so the desktop
|
||||
# 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.
|
||||
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
|
||||
exec "{Python}" Main.py "$@"
|
||||
@@ -0,0 +1,11 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=SaraTune
|
||||
Comment=Play audio files and your music library
|
||||
Exec={AppDir}/Packaging/Launch.sh %F
|
||||
Icon={AppDir}/assets/Icon.png
|
||||
Terminal=false
|
||||
Categories=AudioVideo;Audio;Player;
|
||||
MimeType=audio/mpeg;audio/flac;audio/x-flac;audio/ogg;audio/x-vorbis+ogg;audio/opus;audio/x-opus+ogg;audio/wav;audio/x-wav;audio/mp4;audio/x-m4a;audio/aac;audio/x-aiff;audio/x-ms-wma;audio/x-matroska;audio/webm;
|
||||
StartupNotify=true
|
||||
StartupWMClass=srhtnin.{Name}
|
||||
@@ -1,25 +1,44 @@
|
||||
# Kept in sync between SaraSuite/Programs/SaraView/ and SaraView/Client/Packaging/SaraSuite/.
|
||||
Name = "SaraView"
|
||||
Platforms = ["Linux", "Windows", "macOS"]
|
||||
Description = "SaraView's desktop client: view and edit files in a local folder, or on a SaraView server."
|
||||
|
||||
[Source]
|
||||
Git = "{GitHost}/SaraView"
|
||||
# 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"]
|
||||
|
||||
[Python]
|
||||
RequirementsFile = "Client/requirements.txt"
|
||||
|
||||
# Linux: a launcher and a desktop entry.
|
||||
[[Templates]]
|
||||
Platform = "Linux"
|
||||
Source = "Launch.sh"
|
||||
Target = "{AppDir}/Client/Packaging/Launch.sh"
|
||||
Executable = true
|
||||
|
||||
[[Templates]]
|
||||
Platform = "Linux"
|
||||
Source = "SaraView.desktop"
|
||||
Target = "{ApplicationsDir}/SaraView.desktop"
|
||||
|
||||
# Windows: a Start Menu shortcut (pythonw.exe Client/Main.py, from the app folder).
|
||||
[[Shortcuts]]
|
||||
Platform = "Windows"
|
||||
Name = "SaraView"
|
||||
Script = "Client/Main.py"
|
||||
Icon = "Client/assets/Icon.png"
|
||||
|
||||
# macOS: an app bundle, ~/Applications/SaraView.app (python Client/Main.py, from the app folder).
|
||||
[[Shortcuts]]
|
||||
Platform = "macOS"
|
||||
Name = "SaraView"
|
||||
Script = "Client/Main.py"
|
||||
Icon = "Client/assets/Icon.png"
|
||||
|
||||
# Made by SaraView itself: Config.toml and Session.toml (your saved login).
|
||||
[[Leftovers]]
|
||||
Path = "{ConfigDir}"
|
||||
@@ -33,5 +52,6 @@ Note = "server files downloaded to open in other programs"
|
||||
|
||||
# Shared with the other Flet programs; see Programs/FletIdentity.py.
|
||||
[[Templates]]
|
||||
Platform = "Linux"
|
||||
Source = "../../FletIdentity.py"
|
||||
Target = "{ProgramDir}/FletIdentity.py"
|
||||
@@ -4,5 +4,9 @@
|
||||
# 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.
|
||||
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
|
||||
exec "{Python}" Main.py "$@"
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Runs a SaraSuite program as a background service where there's no systemd
|
||||
--user to do it: on Windows (started windowless with pythonw.exe), on macOS,
|
||||
and on Linux with another init system (runit on Void, OpenRC, s6, dinit, ...).
|
||||
Started at login (Windows: a shortcut in the Startup folder; macOS: a
|
||||
LaunchAgent; Linux: an XDG autostart entry) and when SaraSuite or the program's own GUI (e.g. SaraSort
|
||||
for SaraSortd) starts it:
|
||||
|
||||
python ServiceRun.py <Name> <Script> [<WorkingDir>]
|
||||
|
||||
Like the systemd unit it stands in for: one copy at a time; restarted 10
|
||||
seconds after it fails (an exception or a non-zero exit), not after a clean
|
||||
exit or a stop; its output goes to <Name>.log. Its PID is in <Name>.pid, so
|
||||
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, ~/Library/Application Support/SaraSuite/Services on macOS,
|
||||
~/.local/share/SaraSuite/Services on Linux.
|
||||
|
||||
Installed next to each program that has a service (Programs/<Name>/Manifest.toml).
|
||||
"""
|
||||
|
||||
import os
|
||||
import runpy
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
IsWindows = sys.platform == "win32"
|
||||
IsMac = sys.platform == "darwin"
|
||||
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")
|
||||
elif IsMac:
|
||||
Base = Path.home() / "Library" / "Application Support"
|
||||
else:
|
||||
Base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
|
||||
return Base / "SaraSuite" / "Services"
|
||||
|
||||
|
||||
def _AlreadyRunning(PidFile: Path) -> bool:
|
||||
"""Linux and macOS: 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
|
||||
if IsMac: # no /proc
|
||||
Output = subprocess.run(["ps", "-o", "command=", "-p", str(Pid)], capture_output=True, text=True, timeout=15).stdout
|
||||
return "ServiceRun.py" in Output
|
||||
return b"ServiceRun.py" in Path(f"/proc/{Pid}/cmdline").read_bytes()
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
|
||||
def _Stop(Signal, Frame) -> None:
|
||||
global Stopping
|
||||
Stopping = True
|
||||
raise SystemExit(0)
|
||||
|
||||
|
||||
def Main() -> None:
|
||||
Name, Script = sys.argv[1], Path(sys.argv[2])
|
||||
if len(sys.argv) > 3:
|
||||
Folder = Path(sys.argv[3])
|
||||
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()))
|
||||
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:
|
||||
sys.argv = [str(Script)]
|
||||
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:
|
||||
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).
|
||||
try:
|
||||
if PidFile.read_text().strip() == str(os.getpid()):
|
||||
PidFile.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Main()
|
||||
@@ -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
|
||||
`~/.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
|
||||
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
|
||||
replaced; configs are *merged*: your edits always win, defaults you never touched follow the
|
||||
new version, new keys are added (see `ConfigMerge.py`).
|
||||
@@ -78,11 +79,60 @@ an option off takes out what it placed and puts back any file it had replaced; t
|
||||
places it. It reapplies the installed commit, so the program itself isn't updated. The
|
||||
**Update** button only shows up when there's an update (or a broken install to repair).
|
||||
|
||||
## Windows and macOS
|
||||
|
||||
`SaraSuite.exe` is the Windows version, `SaraSuite-macOS.zip` (SaraSuite.app) the macOS one.
|
||||
Each one only lists the programs for its own system (a manifest's
|
||||
`Platforms`), and does everything that system's way:
|
||||
|
||||
| Linux | Windows | macOS |
|
||||
| --- | --- | --- |
|
||||
| `~/.config/SaraSuite/` (settings) | `%APPDATA%\SaraSuite\` | `~/Library/Application Support/SaraSuite/` |
|
||||
| `~/.local/share/SaraSuite/` (programs, receipts) | `%LOCALAPPDATA%\SaraSuite\` | the same folder as the settings, as Mac apps do |
|
||||
| `~/.cache/...` | `%LOCALAPPDATA%\...` | `~/Library/Caches/...` |
|
||||
| desktop entry | Start Menu shortcut (`[[Shortcuts]]`) | `~/Applications/<Name>.app`, a small app bundle (`[[Shortcuts]]`) |
|
||||
| systemd user service | `ServiceRun.py`, started at login by a shortcut in the Startup folder | `ServiceRun.py`, started at login by a LaunchAgent, `~/Library/LaunchAgents/srhtnin.<Name>.plist` |
|
||||
| system `python3` | the `py` launcher / `python.exe`; if there's none, SaraSuite installs Python for the user with winget | a standalone Python that uv installs into SaraSuite's own folder (`Python/`, uv itself in `Tools/`); no admin, Homebrew or Xcode tools |
|
||||
| AppImage self-update (rename over it) | `.exe` self-update (the running one is renamed to `SaraSuite.old.exe`, deleted on the next start) | `SaraSuite-macOS.zip` self-update (unpacked next to `SaraSuite.app`, then swapped in) |
|
||||
|
||||
A service run by `ServiceRun.py` restarts 10 seconds after it fails, like the unit's
|
||||
`Restart=on-failure`, and keeps its output in SaraSuite's `Services/<Name>.log`, its PID next to it
|
||||
in `<Name>.pid` (that's how SaraSuite and SaraSort tell it runs, and stop it).
|
||||
|
||||
Linux without systemd (no `/run/systemd/system`: runit on Void, OpenRC, s6, dinit, ...) runs
|
||||
services the same way (`Services.py`), started at login by an XDG autostart entry,
|
||||
`~/.config/autostart/<Name>.desktop`, which desktop sessions start whatever the init system is.
|
||||
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).
|
||||
|
||||
Programs run on Windows with `pythonw.exe -X utf8` (no console window; UTF-8 mode, since Python
|
||||
on Windows otherwise reads text files in the old ANSI code page). Nothing needs admin rights on
|
||||
either system. Linux-only features (SaraFem's file picker portal and default-file-manager option)
|
||||
simply aren't offered there, and on macOS SaraTune has no media keys (see its README).
|
||||
|
||||
macOS is written but not tested on a real Mac yet (Apple Silicon only). Things to know there:
|
||||
- SaraSortd sorting `~/Downloads`, `~/Documents` or `~/Desktop` needs permission: macOS asks the
|
||||
first time, or the Python in `~/Library/Application Support/SaraSuite/Python/` can be given
|
||||
Full Disk Access in System Settings > Privacy & Security.
|
||||
- A program's Dock icon is the Flet client's, not its own; its app bundle in `~/Applications`
|
||||
(what Launchpad and Spotlight show) has the right one.
|
||||
|
||||
## Running and building
|
||||
|
||||
- AppImage: `Packaging/BuildAppImage.sh` builds `dist/SaraSuite-x86_64.AppImage`
|
||||
(PyInstaller one-file build + `appimagetool`, like SaraFem's). When run as an AppImage,
|
||||
the header has an "Add to app menu" button.
|
||||
- Windows: `Packaging/BuildExe.sh` builds `dist/SaraSuite.exe` with the Windows Python inside a
|
||||
Wine prefix (`build/WinBuild/`, set up on its first run), since PyInstaller can't build for
|
||||
Windows from Linux directly. The .exe's header has an "Add to Start menu" button.
|
||||
- macOS: `Packaging/BuildMacApp.py` puts `dist/SaraSuite-macOS.zip` together on Linux, since
|
||||
PyInstaller can only build a Mac app on a Mac: a standalone Mac Python
|
||||
(python-build-standalone), SaraSuite's requirements as macOS wheels, the Flet client for macOS
|
||||
and SaraSuite's code, run by a shell-script launcher (which sets `SARASUITE_APP`, so SaraSuite
|
||||
knows it's packaged). It isn't signed with an Apple developer account, so the first start needs
|
||||
System Settings > Privacy & Security > Open Anyway; the launcher then clears the download
|
||||
quarantine from the whole app. It has to be moved out of Downloads (into Applications) first.
|
||||
- From source: `python3 -m venv .venv && .venv/bin/pip install -r requirements.txt`, then
|
||||
`Packaging/Launch.sh`.
|
||||
- Without a desktop (e.g. a server), the same file works from a terminal:
|
||||
@@ -98,6 +148,8 @@ places it. It reapplies the installed commit, so the program itself isn't update
|
||||
|
||||
## Where things live
|
||||
|
||||
On Linux (on Windows and macOS, the same layout in their own folders; see Windows and macOS above):
|
||||
|
||||
| Path | What |
|
||||
| --- | --- |
|
||||
| `~/.local/share/SaraSuite/Programs/<Name>/` | installed program (`App/`) and its `.venv/` |
|
||||
@@ -115,13 +167,23 @@ Manifests are bundled into the AppImage, so a new or changed manifest means rebu
|
||||
Anything the manifest can't describe goes in an optional `Programs/<Name>/Hooks.py`.
|
||||
|
||||
Targets, templates and `Set` values can use `{Variables}`: `Name`, `GitHost`, `Home`, `ConfigHome`,
|
||||
`DataHome`, `CacheHome`, `SystemdUserDir`, `ApplicationsDir`, `DBusServicesDir`, `ProgramDir`,
|
||||
`AppDir`, `Python` (the venv's python), `ConfigDir`, `DesktopName` (e.g. `COSMIC`),
|
||||
`Desktop` (e.g. `cosmic`), plus every Question's `Id`.
|
||||
`DataHome`, `CacheHome`, `ProgramDir`, `AppDir`, `Python` (the venv's python), `PythonW`
|
||||
(Windows: its console-less pythonw.exe), `ConfigDir`, `DesktopName` (e.g. `COSMIC`),
|
||||
`Desktop` (e.g. `cosmic`), plus every Question's `Id`. Linux only: `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`. macOS only:
|
||||
`ApplicationsDir` (`~/Applications`), `LaunchAgentsDir`.
|
||||
|
||||
Every list item (`[[Options]]`, `[[Templates]]`, `[[Services]]`, ...) can carry
|
||||
`Platform = "Linux"`, `"Windows"` or `"macOS"` to only apply on that system; items without one apply everywhere.
|
||||
In TOML templates, write paths as literal strings (`'{ConfigDir}'`): Windows paths are full of
|
||||
backslashes, which a `"..."` string would read as escapes.
|
||||
|
||||
```toml
|
||||
Name = "Example"
|
||||
Description = "Shown on the program's card."
|
||||
Platforms = ["Linux", "Windows", "macOS"] # where it runs (default ["Linux"])
|
||||
Parent = "SaraSortd" # optional: listed (indented) under that program
|
||||
RestartUnits = ["xdg-desktop-portal.service"] # try-restarted after install/update/remove
|
||||
|
||||
@@ -158,13 +220,34 @@ Set = { "Paths.RootDir" = "{RootDir}" }
|
||||
Source = "Example.service" # in Templates/
|
||||
Target = "{SystemdUserDir}/Example.service"
|
||||
Option = "Autostart" # optional: only while the option is ticked
|
||||
Root = false # true: outside $HOME, written through pkexec
|
||||
Root = false # true: outside $HOME, through pkexec; "Auto": only if Target is
|
||||
# outside $HOME once rendered (e.g. {PortalsDir})
|
||||
Executable = false
|
||||
|
||||
[[Services]] # systemd --user units, enabled + started
|
||||
Unit = "Example.service"
|
||||
[[Services]] # Linux: a systemd --user unit, enabled + started; without
|
||||
Platform = "Linux" # systemd, Name/Script/WorkingDir run by ServiceRun.py instead,
|
||||
Unit = "Example.service" # started at login by an autostart entry
|
||||
Name = "Example"
|
||||
Script = "Example.py"
|
||||
WorkingDir = "{ConfigDir}"
|
||||
Option = "Autostart"
|
||||
|
||||
[[Services]] # Windows: run by ServiceRun.py from a Startup-folder shortcut
|
||||
Platform = "Windows" # (macOS: the same with Platform = "macOS", from a LaunchAgent;
|
||||
# both: add ../../ServiceRun.py as a Template to {ProgramDir})
|
||||
Name = "Example"
|
||||
Script = "Example.py" # relative to the app folder
|
||||
WorkingDir = "{ConfigDir}"
|
||||
Option = "Autostart"
|
||||
|
||||
[[Shortcuts]] # Windows: a Start Menu shortcut (pythonw.exe -X utf8 Script)
|
||||
Platform = "Windows"
|
||||
Name = "Example"
|
||||
Script = "Main.py" # relative to the app folder, which is also its working folder
|
||||
Icon = "assets/Icon.png" # turned into an .ico
|
||||
# macOS: the same with Platform = "macOS" makes ~/Applications/<Name>.app
|
||||
# (the venv's python Script; the icon becomes an .icns)
|
||||
|
||||
[[IniEdits]] # one key in a file SaraSuite doesn't own; restored on removal
|
||||
File = "{ConfigHome}/mimeapps.list"
|
||||
Section = "Default Applications"
|
||||
|
||||
+81
-17
@@ -1,24 +1,33 @@
|
||||
"""SaraSuite updating itself from its Gitea releases.
|
||||
|
||||
Packaging/Release.py publishes a release (tag v<AppVersion>) with the
|
||||
AppImage attached. Here SaraSuite asks Gitea for the latest release, and if
|
||||
it's newer than AppVersion and SaraSuite runs as an AppImage, downloads it
|
||||
next to the running one and renames it into place (allowed while it's
|
||||
running), then restarts into it. The AppImage keeps its path, so its app
|
||||
menu entry keeps working.
|
||||
AppImage and SaraSuite.exe attached (and SaraSuite-macOS.zip, once there's
|
||||
a Mac build). Here SaraSuite asks Gitea for the latest
|
||||
release, and if it's newer than AppVersion and SaraSuite runs packaged
|
||||
(AppImage / .exe), downloads its own platform's file and swaps it in, then
|
||||
restarts into it. The file keeps its path, so its menu entry keeps working.
|
||||
- Linux: the download is renamed over the running AppImage (allowed).
|
||||
- Windows: a running .exe can't be overwritten but can be renamed, so the
|
||||
old one becomes SaraSuite.old.exe (deleted on the next start).
|
||||
- macOS: SaraSuite.app (a folder, Packaging/BuildMacApp.py) comes zipped; it's
|
||||
unpacked next to the running one, which is renamed aside and deleted once
|
||||
the new one is in place.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from Core import AppName, AppVersion, ChildEnvironment, LoadConfig
|
||||
from Core import AppName, AppVersion, ChildEnvironment, IsFrozen, IsMac, IsWindows, LoadConfig, MacApp
|
||||
from Sources import GetBytes, GetJson, SourceError
|
||||
|
||||
AssetName = f"{AppName}-x86_64.AppImage"
|
||||
AssetName = f"{AppName}.exe" if IsWindows else f"{AppName}-macOS.zip" if IsMac else f"{AppName}-x86_64.AppImage"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -28,10 +37,43 @@ class Release:
|
||||
|
||||
|
||||
def RunningAppImage() -> Optional[Path]:
|
||||
"""The packaged SaraSuite file that's running: the AppImage on Linux, the
|
||||
.exe on Windows, the SaraSuite.app bundle on macOS. None when running
|
||||
from source."""
|
||||
if IsWindows:
|
||||
return Path(sys.executable) if IsFrozen else None
|
||||
if IsMac:
|
||||
return Path(MacApp) if MacApp.endswith(".app") else None
|
||||
Value = os.environ.get("APPIMAGE", "")
|
||||
return Path(Value) if Value else None
|
||||
|
||||
|
||||
def CleanUpOldExe() -> None:
|
||||
"""Windows: delete the .exe a previous update renamed aside."""
|
||||
Running = RunningAppImage()
|
||||
if IsWindows and Running is not None:
|
||||
Running.with_name(f"{Running.stem}.old.exe").unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _InstallMacBundle(Target: Path, Data: bytes) -> None:
|
||||
"""Unpack the zipped SaraSuite.app next to the running one (same volume,
|
||||
so the renames are instant), then swap the two. ditto keeps what a Mac
|
||||
app needs: permissions, symlinks inside its frameworks, its signature."""
|
||||
with tempfile.TemporaryDirectory(dir=Target.parent, prefix=".SaraSuite-update-") as Temporary:
|
||||
Archive = Path(Temporary) / AssetName
|
||||
Archive.write_bytes(Data)
|
||||
Unpacked = Path(Temporary) / "Unpacked"
|
||||
subprocess.run(["ditto", "-x", "-k", str(Archive), str(Unpacked)], check=True, capture_output=True)
|
||||
New = next(Unpacked.glob("*.app"), None)
|
||||
if New is None:
|
||||
raise OSError(f"{AssetName} has no app in it; leaving the current one alone.")
|
||||
Old = Target.with_name(f"{Target.stem}.old.app")
|
||||
shutil.rmtree(Old, ignore_errors=True)
|
||||
Target.rename(Old)
|
||||
New.rename(Target)
|
||||
shutil.rmtree(Old, ignore_errors=True)
|
||||
|
||||
|
||||
def _VersionTuple(Version: str) -> tuple[int, ...]:
|
||||
Parts = []
|
||||
for Part in Version.strip().lstrip("vV").split("."):
|
||||
@@ -74,14 +116,28 @@ def Install(Latest: Release, Log: Callable[[str], None]) -> Path:
|
||||
Log(f"== Updating {AppName} {AppVersion} -> {Latest.Version.lstrip('v')}")
|
||||
Log(f"Downloading {Latest.AssetUrl}")
|
||||
Data = GetBytes(Latest.AssetUrl)
|
||||
if not Data.startswith(b"\x7fELF"):
|
||||
raise OSError("The download isn't an AppImage; leaving the current one alone.")
|
||||
# Same folder, so the rename is atomic; renaming over a running AppImage
|
||||
# is fine (overwriting it in place isn't: "Text file busy").
|
||||
if not Data.startswith(b"MZ" if IsWindows else b"PK" if IsMac else b"\x7fELF"):
|
||||
raise OSError(f"The download isn't a {AssetName}; leaving the current one alone.")
|
||||
if IsMac:
|
||||
try:
|
||||
_InstallMacBundle(Target, Data)
|
||||
except subprocess.CalledProcessError as Error:
|
||||
raise OSError(f"Couldn't unpack {AssetName}: {Error.stderr.decode(errors='replace').strip()}") from Error
|
||||
Log(f"== {AppName} updated; it's the new version from the next start")
|
||||
return Target
|
||||
Temporary = Target.with_name(Target.name + ".new")
|
||||
Temporary.write_bytes(Data)
|
||||
Temporary.chmod(0o755)
|
||||
Temporary.replace(Target)
|
||||
if IsWindows:
|
||||
# A running .exe can't be replaced, but it can be renamed out of the way.
|
||||
Old = Target.with_name(f"{Target.stem}.old.exe")
|
||||
Old.unlink(missing_ok=True)
|
||||
Target.rename(Old)
|
||||
Temporary.rename(Target)
|
||||
else:
|
||||
# Same folder, so the rename is atomic; renaming over a running AppImage
|
||||
# is fine (overwriting it in place isn't: "Text file busy").
|
||||
Temporary.chmod(0o755)
|
||||
Temporary.replace(Target)
|
||||
Log(f"== {AppName} updated; it's the new version from the next start")
|
||||
return Target
|
||||
|
||||
@@ -92,7 +148,15 @@ def Restart() -> None:
|
||||
if Target is None:
|
||||
return
|
||||
Environment = ChildEnvironment()
|
||||
for Key in ("APPIMAGE", "APPDIR", "ARGV0", "OWD"): # the new AppImage sets its own
|
||||
Environment.pop(Key, None)
|
||||
subprocess.Popen([str(Target)], env=Environment, start_new_session=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
for Key in ("APPIMAGE", "APPDIR", "ARGV0", "OWD", "_MEIPASS2", "_PYI_APPLICATION_HOME_DIR"):
|
||||
Environment.pop(Key, None) # the new one sets its own
|
||||
if IsMac:
|
||||
# -n: a new instance, even though this one is still running.
|
||||
subprocess.Popen(["open", "-n", str(Target)], env=Environment, start_new_session=True)
|
||||
elif IsWindows:
|
||||
Environment["PYINSTALLER_RESET_ENVIRONMENT"] = "1" # a fresh onefile unpack, not ours
|
||||
subprocess.Popen([str(Target)], env=Environment, close_fds=True,
|
||||
creationflags=getattr(subprocess, "DETACHED_PROCESS", 0))
|
||||
else:
|
||||
subprocess.Popen([str(Target)], env=Environment, start_new_session=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
"""Background services SaraSuite runs itself, where there's no systemd --user
|
||||
to do it: Windows, macOS, 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;
|
||||
- macOS: a LaunchAgent (~/Library/LaunchAgents/srhtnin.<Name>.plist);
|
||||
- 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, IsMac, 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 IsMac: # no /proc: ask ps for its command line
|
||||
try:
|
||||
Output = subprocess.run(["ps", "-o", "command=", "-p", str(Pid)], capture_output=True, text=True, timeout=15).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return Pid if "ServiceRun.py" in Output else 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"
|
||||
)
|
||||
+16
-5
@@ -8,8 +8,8 @@ from typing import Callable
|
||||
import flet as ft
|
||||
|
||||
from Core import (
|
||||
AppConfig, BundledFontFamily, ChildEnvironment, ChipButtonStyle, LoadSharedSettings, Palette,
|
||||
SharedSettings, ThemesDir,
|
||||
AppConfig, BundledFontFamily, ChildEnvironment, ChipButtonStyle, EnsureSharedThemes, IsMac, IsWindows,
|
||||
LoadSharedSettings, Palette, SharedConfigPath, SharedSettings, ThemesDir,
|
||||
)
|
||||
from Installer import DefaultAnswers, DefaultOptions
|
||||
from Manifests import Program
|
||||
@@ -25,13 +25,24 @@ def _Note(Text: str) -> ft.Text:
|
||||
|
||||
|
||||
def _Themes() -> list[str]:
|
||||
EnsureSharedThemes()
|
||||
Names = {Path.stem for Path in ThemesDir.glob("*.toml")} if ThemesDir.is_dir() else set()
|
||||
Names.add("CatppuccinMocha") # built in, even before SaraFem seeds the folder
|
||||
Names.add("CatppuccinMocha") # built in, even where the folder can't be written
|
||||
return sorted(Names)
|
||||
|
||||
|
||||
def _Fonts() -> list[str]:
|
||||
"""Installed font families (fontconfig), with the bundled font first."""
|
||||
"""Installed font families (fontconfig, the registry on Windows, the font
|
||||
files themselves on macOS), with the bundled font first."""
|
||||
if IsWindows or IsMac:
|
||||
if IsMac:
|
||||
import MacSupport as Support
|
||||
else:
|
||||
import WindowsSupport as Support
|
||||
|
||||
Families = Support.FontFamilies()
|
||||
Families.discard(BundledFontFamily)
|
||||
return [BundledFontFamily] + sorted(Families, key=str.lower)
|
||||
try:
|
||||
Result = subprocess.run(
|
||||
["fc-list", ":", "family"], capture_output=True, text=True, timeout=5, env=ChildEnvironment(),
|
||||
@@ -90,7 +101,7 @@ def ShowSettingsDialog(Page: ft.Page, Config: AppConfig, Programs: list[Program]
|
||||
Categories: dict[str, ft.Control] = {
|
||||
"Shared": ft.Column(
|
||||
[
|
||||
_Note("Shared by every SaraSuite program (~/.config/SaraSuite/Config.toml)."),
|
||||
_Note(f"Shared by every SaraSuite program ({SharedConfigPath})."),
|
||||
UseSharedSwitch,
|
||||
ft.Divider(height=1, color=Palette.Surface1),
|
||||
SharedTheme,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Windows stand-ins for what SaraSuite uses on Linux:
|
||||
|
||||
Linux Windows
|
||||
.desktop entry Start Menu shortcut (.lnk)
|
||||
systemd --user service shortcut in the Startup folder, started and stopped
|
||||
through Programs/ServiceRun.py (Services.py)
|
||||
system python3 the py launcher / python.exe, else winget installs it
|
||||
fc-list the Fonts registry key
|
||||
|
||||
Only imported on Windows (pylnk3, winreg).
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from Core import DataHome
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
|
||||
# Child processes of a windowed program would each flash a console window.
|
||||
NoWindow = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
|
||||
PythonVersion = "3.14" # what winget installs when there's no Python yet
|
||||
_WingetId = f"Python.Python.{PythonVersion}"
|
||||
|
||||
|
||||
# --- Shortcuts and icons ---------------------------------------------------------------
|
||||
|
||||
|
||||
def MakeIcon(Png: Path, Ico: Path) -> Optional[Path]:
|
||||
"""A .ico from one of the Sara icons (32x32 pixel art stored at 10x).
|
||||
Each size is a whole multiple of the 32x32 grid, so every art pixel
|
||||
stays square and crisp (same reasoning as the cards' 128px icons)."""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
Source = Image.open(Png).convert("RGBA")
|
||||
Grid = Source.resize((32, 32), Image.NEAREST)
|
||||
Sizes = [(Size, Size) for Size in (256, 128, 64, 32, 16)]
|
||||
Ico.parent.mkdir(parents=True, exist_ok=True)
|
||||
Grid.resize((256, 256), Image.NEAREST).save(Ico, format="ICO", sizes=Sizes)
|
||||
return Ico
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def CreateShortcut(
|
||||
Link: Path, Target: Path, Arguments: str = "", WorkingDir: Optional[Path] = None,
|
||||
Icon: Optional[Path] = None, Description: str = "",
|
||||
) -> None:
|
||||
import pylnk3
|
||||
|
||||
Link.parent.mkdir(parents=True, exist_ok=True)
|
||||
pylnk3.for_file(
|
||||
str(Target), str(Link), arguments=Arguments or None, description=Description or None,
|
||||
icon_file=str(Icon) if Icon else None, icon_index=0,
|
||||
work_dir=str(WorkingDir) if WorkingDir else None,
|
||||
)
|
||||
|
||||
|
||||
# --- Python -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _Usable(Candidate: str) -> bool:
|
||||
# The "python.exe" in WindowsApps is only a stub that opens the Microsoft Store.
|
||||
return bool(Candidate) and "WindowsApps" not in Candidate and Path(Candidate).is_file()
|
||||
|
||||
|
||||
def FindPython() -> Optional[str]:
|
||||
"""A real Python 3 to build program venvs from."""
|
||||
Launcher = shutil.which("py")
|
||||
if Launcher:
|
||||
try:
|
||||
Result = subprocess.run(
|
||||
[Launcher, "-3", "-c", "import sys; print(sys.executable)"],
|
||||
capture_output=True, text=True, timeout=30, creationflags=NoWindow,
|
||||
)
|
||||
if _Usable(Result.stdout.strip()):
|
||||
return Result.stdout.strip()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
for Name in ("python3", "python"):
|
||||
Found = shutil.which(Name) or ""
|
||||
if _Usable(Found):
|
||||
return Found
|
||||
# Per-user installs from python.org or winget, newest first.
|
||||
Installed = sorted((DataHome / "Programs" / "Python").glob("Python3*/python.exe"), reverse=True)
|
||||
return str(Installed[0]) if Installed else None
|
||||
|
||||
|
||||
def InstallPython(Log: LogFn) -> Optional[str]:
|
||||
"""Install the official Python for the current user with winget (built
|
||||
into Windows 10/11; no admin needed with --scope user)."""
|
||||
Winget = shutil.which("winget")
|
||||
if Winget is None:
|
||||
Log("winget isn't available. Install Python from https://www.python.org/downloads/ and try again.")
|
||||
return None
|
||||
Log(f"Python not found; installing Python {PythonVersion} with winget (just for you, no admin)")
|
||||
Command = [
|
||||
Winget, "install", "--id", _WingetId, "--exact", "--scope", "user", "--silent",
|
||||
"--accept-package-agreements", "--accept-source-agreements",
|
||||
]
|
||||
Log(f"$ {' '.join(Command)}")
|
||||
Process = subprocess.Popen(Command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
creationflags=NoWindow, errors="replace")
|
||||
for Line in Process.stdout:
|
||||
if Line.strip():
|
||||
Log(f" {Line.rstrip()}")
|
||||
Process.wait()
|
||||
return FindPython()
|
||||
|
||||
|
||||
# --- Fonts -----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def FontFamilies() -> set[str]:
|
||||
"""Installed font families, from the system and per-user Fonts keys
|
||||
("Arial Bold (TrueType)" -> "Arial")."""
|
||||
import winreg
|
||||
|
||||
Families: set[str] = set()
|
||||
Key = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts"
|
||||
for Root in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
|
||||
try:
|
||||
with winreg.OpenKey(Root, Key) as Fonts:
|
||||
Index = 0
|
||||
while True:
|
||||
try:
|
||||
Name = winreg.EnumValue(Fonts, Index)[0]
|
||||
except OSError:
|
||||
break
|
||||
Index += 1
|
||||
Name = re.sub(r"\s*\(.*\)$", "", Name)
|
||||
Name = re.sub(r"\s+(Bold|Italic|Light|Semibold|SemiBold|Black|Regular|Medium|Thin)\b.*$", "", Name)
|
||||
if Name and "&" not in Name:
|
||||
Families.add(Name.strip())
|
||||
except OSError:
|
||||
continue
|
||||
return Families
|
||||
|
||||
|
||||
def OpenPath(Target: Path) -> None:
|
||||
os.startfile(str(Target)) # type: ignore[attr-defined] # Windows only
|
||||
@@ -1,2 +1,4 @@
|
||||
flet>=1.0,<2.0
|
||||
tomlkit
|
||||
# macOS: app bundle icons (.icns); the Windows build bundles it for .ico.
|
||||
pillow; sys_platform == "darwin"
|
||||
Reference in new issue
Block a user