"""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//...) 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 import flet as ft 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.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. BundledFontFamily = "Fixedsys Excelsior" ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme Home = Path.home() 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" ConfigDir = SharedConfigDir / "SaraSuite" ConfigPath = ConfigDir / "Config.toml" # Settings shared by every SaraSuite program (theme, font, window title bar). # Same key names as the matching fields in each program's own Config.toml. SharedConfigPath = SharedConfigDir / "Config.toml" # Everything SaraSuite itself keeps about installed programs. Nothing in here # is user-edited, so it's all fair game to delete when a program is removed. DataDir = DataHome / "SaraSuite" ProgramsDir = DataDir / "Programs" # installed program files + venvs ReceiptsDir = DataDir / "Receipts" # what each install placed, for exact removal 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 # .pid and .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. BundleDir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent)) BundledManifestsDir = BundleDir / "Programs" # the AppImage's own copy; see Catalog.py AssetsDir = BundleDir / "assets" class Palette: """Live values for the active theme (see ApplyTheme). Same attribute names and SaraView role mapping as SaraFem's Palette.""" Mauve = "#cba6f7" Red = "#f38ba8" Peach = "#fab387" Yellow = "#f9e2af" Green = "#a6e3a1" Blue = "#89b4fa" Text = "#cdd6f4" White = "#cdd6f4" Overlay0 = "#6c7086" Surface1 = "#45475a" Surface0 = "#313244" Base = "#1e1e2e" Mantle = "#181825" Crust = "#11111b" _ThemeRoleMap = { "Background": "Base", "Surface": "Mantle", "SurfaceAlt": "Surface0", "Border": "Surface1", "Text": "Text", "TextMuted": "Overlay0", "Black": "Crust", "White": "White", "Red": "Red", "Orange": "Peach", "Yellow": "Yellow", "Green": "Green", "Blue": "Blue", "Purple": "Mauve", } # 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/.toml. 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 try: RoleValues = tomlkit.parse(ThemeFile.read_text()).unwrap().get("Palette", {}) except tomlkit.exceptions.TOMLKitError: return for RoleName, AttrName in _ThemeRoleMap.items(): if RoleName in RoleValues: setattr(Palette, AttrName, RoleValues[RoleName]) @dataclass class AppConfig: # True: Theme, FontFamily and HideWindowTitleBar come from the shared # ~/.config/SaraSuite/Config.toml (edited in SaraFem's Settings, SaraSuite # tab). False: SaraSuite uses its own values below instead. UseSharedSettings: bool = True Theme: str = "CatppuccinMocha" # matches a file in ~/.config/SaraSuite/Themes/ FontFamily: str = BundledFontFamily HideWindowTitleBar: bool = False # Where the Sara repos live (Gitea on SpineS); manifests # use it as {GitHost}. GitHost: str = "https://git.srhtnin.garden/sarah" # What the install dialog starts with, per program, overriding the # manifest's defaults: {Program: {OptionId: ticked}} and # {Program: {QuestionId: answer}}. Set in Settings. OptionDefaults: dict[str, dict[str, bool]] = field(default_factory=dict) AnswerDefaults: dict[str, dict[str, str]] = field(default_factory=dict) def Save(self) -> None: ConfigDir.mkdir(parents=True, exist_ok=True) ConfigPath.write_text(tomlkit.dumps(asdict(self))) def LoadConfig() -> AppConfig: if not ConfigPath.exists(): Config = AppConfig() Config.Save() return Config try: Data = tomlkit.parse(ConfigPath.read_text()).unwrap() except tomlkit.exceptions.TOMLKitError: return AppConfig() KnownFields = set(AppConfig.__dataclass_fields__) return AppConfig(**{Key: Value for Key, Value in Data.items() if Key in KnownFields}) @dataclass class SharedSettings: """~/.config/SaraSuite/Config.toml, shared by every SaraSuite program. Same schema and fallback rules as SaraFem's SharedSettings.""" Theme: str = "CatppuccinMocha" FontFamily: str = BundledFontFamily HideWindowTitleBar: bool = False def Save(self) -> None: SharedConfigDir.mkdir(parents=True, exist_ok=True) SharedConfigPath.write_text(tomlkit.dumps(asdict(self))) def _OwnSettings(Config: AppConfig) -> SharedSettings: return SharedSettings(Config.Theme, Config.FontFamily, Config.HideWindowTitleBar) def LoadSharedSettings(Config: AppConfig) -> SharedSettings: """The shared file, created from this program's own values if missing.""" if not SharedConfigPath.exists(): Shared = _OwnSettings(Config) Shared.Save() return Shared Result = _OwnSettings(Config) # fallback for keys the file doesn't have try: Data = tomlkit.parse(SharedConfigPath.read_text()).unwrap() except tomlkit.exceptions.TOMLKitError: return Result for Key in SharedSettings.__dataclass_fields__: if Key in Data: setattr(Result, Key, Data[Key]) return Result def EffectiveSettings(Config: AppConfig) -> SharedSettings: return LoadSharedSettings(Config) if Config.UseSharedSettings else _OwnSettings(Config) def ApplyAppearance(Settings: SharedSettings) -> None: """Theme colours and font; must run before anything reads Palette.X.""" global ActiveFontFamily ApplyTheme(Settings.Theme) ActiveFontFamily = Settings.FontFamily or BundledFontFamily def ChildEnvironment() -> dict[str, str]: """The environment for child processes (systemctl, pip, python3 -m venv). A PyInstaller build points LD_LIBRARY_PATH at its own bundled libraries and keeps the original in LD_LIBRARY_PATH_ORIG; system tools need the original back or they can load the wrong libraries.""" Environment = dict(os.environ) if getattr(sys, "frozen", False): Original = Environment.pop("LD_LIBRARY_PATH_ORIG", None) if Original is not None: Environment["LD_LIBRARY_PATH"] = Original else: Environment.pop("LD_LIBRARY_PATH", None) 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 -portals.conf file name. Empty on Windows.""" return os.environ.get("XDG_CURRENT_DESKTOP", "").split(":")[0] def BuildTheme() -> ft.Theme: ColorScheme = ft.ColorScheme( primary=Palette.Mauve, on_primary=Palette.Crust, primary_container=Palette.Surface0, on_primary_container=Palette.Mauve, secondary=Palette.Mauve, on_secondary=Palette.Crust, secondary_container=Palette.Surface0, on_secondary_container=Palette.Mauve, surface=Palette.Base, on_surface=Palette.Text, on_surface_variant=Palette.Overlay0, surface_container=Palette.Mantle, surface_container_low=Palette.Mantle, surface_container_high=Palette.Surface0, surface_container_highest=Palette.Surface1, outline=Palette.Surface1, outline_variant=Palette.Surface1, error=Palette.Red, on_error=Palette.Crust, ) return ft.Theme( font_family=ActiveFontFamily, color_scheme=ColorScheme, use_material3=True, scaffold_bgcolor=Palette.Base, canvas_color=Palette.Base, dialog_theme=ft.DialogTheme( bgcolor=Palette.Mantle, shape=ft.RoundedRectangleBorder(radius=8, side=ft.BorderSide(1, Palette.Mauve)), title_text_style=ft.TextStyle(color=Palette.Mauve, size=18, font_family=ActiveFontFamily), ), ) def ChipButtonStyle(Accent: str = "") -> ft.ButtonStyle: """SaraView's button look: SurfaceAlt fill, 1px accent border, small radius. Accent defaults to Mauve; Remove buttons pass Palette.Red. Disabled buttons are greyed out (muted text, plain border, no fill) so it's obvious they can't be pressed.""" Disabled = ft.ControlState.DISABLED Default = ft.ControlState.DEFAULT return ft.ButtonStyle( color={Disabled: Palette.Overlay0, Default: Palette.Text}, bgcolor={Disabled: Palette.Mantle, Default: Palette.Surface0}, side={Disabled: ft.BorderSide(1, Palette.Surface1), Default: ft.BorderSide(1, Accent or Palette.Mauve)}, shape=ft.RoundedRectangleBorder(radius=4), )