"""SaraSuite entry point: installs, updates and removes Sara programs. With arguments it runs the command-line mode instead (see Cli.py).""" import importlib.util import os import shutil import sys import traceback from typing import Callable import flet as ft from Core import ( AppName, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataDir, DataHome, EffectiveSettings, LoadConfig, ManifestsDir, Palette, ) from Dialogs import ShowInstallDialog, ShowOptionsDialog, ShowRemoveDialog, ShowUpdateDialog from Installer import Apply, Fetch, FetchCommit, GetStatus, PlanConfigs, PlanRemoval, Remove, SourceVersion from Manifests import LoadPrograms from ProgramCard import ProgramCard 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" def _LoadFletIdentity(): # Lives in Programs/ (bundled as data) because installed programs get a # copy of the same file next to them. Spec = importlib.util.spec_from_file_location("FletIdentity", ManifestsDir / "FletIdentity.py") Module = importlib.util.module_from_spec(Spec) Spec.loader.exec_module(Module) return Module def _SetMenuEntry(Enabled: bool) -> None: if not Enabled: _MenuEntry.unlink(missing_ok=True) _MenuIcon.unlink(missing_ok=True) return _MenuIcon.parent.mkdir(parents=True, exist_ok=True) shutil.copy(AssetsDir / "Icon.png", _MenuIcon) _MenuEntry.parent.mkdir(parents=True, exist_ok=True) _MenuEntry.write_text( "[Desktop Entry]\nType=Application\nName=SaraSuite\n" "Comment=Install, update and remove Sara programs\n" f"Exec={_AppImage}\nIcon={_MenuIcon}\nTerminal=false\n" f"Categories=System;\nStartupNotify=true\nStartupWMClass=srhtnin.{AppName}\n" ) def Main(Page: ft.Page) -> None: Config = LoadConfig() Appearance = EffectiveSettings(Config) # shared SaraSuite settings, or SaraSuite's own ApplyAppearance(Appearance) # must run before anything reads Palette.X Page.title = AppName Page.theme_mode = ft.ThemeMode.DARK Page.theme = BuildTheme() Page.dark_theme = BuildTheme() Page.fonts = {BundledFontFamily: "fonts/Fixedsys_Excelsior_Regular.ttf"} Page.window.title_bar_hidden = Appearance.HideWindowTitleBar Page.padding = 0 Page.bgcolor = Palette.Base Page.window.width = 960 Page.window.height = 720 LogView = ft.ListView(expand=True, auto_scroll=True, spacing=0, padding=8) def Log(Line: str) -> None: if Line.startswith("=="): Color = Palette.Mauve elif Line.startswith("Error"): Color = Palette.Red elif Line.startswith(("$", " ")): Color = Palette.Overlay0 else: Color = Palette.Text LogView.controls.append(ft.Text(Line, size=12, color=Color, selectable=True)) if len(LogView.controls) > _MaxLogLines: del LogView.controls[: len(LogView.controls) - _MaxLogLines] LogView.update() Programs = LoadPrograms() Cards: list[ProgramCard] = [] State = {"Busy": False} def RefreshCard(Card: ProgramCard) -> None: try: Current = GetStatus(Card.Program) Leftovers = PlanRemoval(Card.Program) except Exception as Error: Log(f"Error checking {Card.Program.Name}: {Error}") return Card.ShowStatus(Current, Leftovers, State["Busy"]) Card.update() def RefreshAll() -> None: for Card in Cards: RefreshCard(Card) def SetBusy(Busy: bool, Working: ProgramCard | None = None) -> None: State["Busy"] = Busy RefreshButton.disabled = Busy for Card in Cards: Card.SetBusy(Busy, Card is Working) Card.update() RefreshButton.update() def RunOperation(Card: ProgramCard | None, Work: Callable[[], None]) -> None: """Run Work in a background thread with every card locked, then refresh the card's status. Only one operation runs at a time.""" if State["Busy"]: return SetBusy(True, Card) def Worker() -> None: try: Work() except Exception as Error: Log(f"Error: {Error}") for Line in traceback.format_exc().rstrip().splitlines(): Log(f" {Line}") finally: SetBusy(False) RefreshAll() Page.run_thread(Worker) def OnInstall(Card: ProgramCard) -> None: Program = Card.Program ShowInstallDialog( Page, Program, lambda Options, Answers: RunOperation(Card, lambda: Apply(Program, Log, Options, Answers)), ) def OnUpdate(Card: ProgramCard) -> None: # Download the new commit and plan the config merges first, so the # dialog shows exactly what the update will do; Apply then installs # that same download. def Prepare() -> None: Program = Card.Program Source = Fetch(Program, Log) Current = GetStatus(Program) Plans = PlanConfigs(Program, Source.Dir, Current.Receipt.Answers) SetBusy(False) ShowUpdateDialog( Page, Program, Current, SourceVersion(Program, Source.Dir), Source.Commit, Plans, lambda: RunOperation(Card, lambda: Apply(Program, Log, Source=Source)), ) RunOperation(Card, Prepare) def OnRemove(Card: ProgramCard) -> None: Program = Card.Program Installed = Card.CurrentStatus is not None and Card.CurrentStatus.Installed ShowRemoveDialog( Page, Program, PlanRemoval(Program), Installed, lambda RemoveConfigs, RemoveRuntime: RunOperation( Card, lambda: Remove(Program, RemoveConfigs, RemoveRuntime, Log), ), ) def OnOptions(Card: ProgramCard) -> None: # Reapplies the installed commit with the new options, so turning # e.g. the file picker on or off doesn't also update the program. Program = Card.Program Current = Card.CurrentStatus if Current is None or Current.Receipt is None: return Commit = Current.Receipt.Commit ShowOptionsDialog( Page, Program, Current, lambda Options: RunOperation( Card, lambda: Apply(Program, Log, Options=Options, Source=FetchCommit(Program, Commit, Log)), ), ) for Program in Programs: Cards.append(ProgramCard(Program, OnInstall, OnUpdate, OnRemove, OnOptions)) def ToggleMenuEntry(e) -> None: _SetMenuEntry(not _MenuEntry.exists()) MenuButton.content = "Remove from app menu" if _MenuEntry.exists() else "Add to app menu" MenuButton.update() MenuButton = ft.OutlinedButton( content="Remove from app menu" if _MenuEntry.exists() else "Add to app menu", style=ChipButtonStyle(), visible=bool(_AppImage), on_click=ToggleMenuEntry, ) RefreshButton = ft.IconButton( icon=ft.Icons.REFRESH, icon_color=Palette.Mauve, tooltip="Check for updates", on_click=lambda e: RunOperation(None, RefreshAll), ) SettingsButton = ft.IconButton( icon=ft.Icons.SETTINGS_OUTLINED, icon_color=Palette.Mauve, tooltip="Settings", # The git host may have changed, so re-check every program afterwards. on_click=lambda e: ShowSettingsDialog(Page, Config, Programs, lambda: RunOperation(None, RefreshAll)), ) Page.add( ft.Column( expand=True, spacing=0, controls=[ ft.Container( content=ft.Row([ ft.Text(AppName, size=24, color=Palette.Mauve), ft.Container(expand=True), MenuButton, RefreshButton, SettingsButton, ]), padding=ft.Padding.symmetric(horizontal=16, vertical=8), bgcolor=Palette.Mantle, border=ft.Border.only(bottom=ft.BorderSide(1, Palette.Surface1)), ), ft.ListView(Cards, expand=True, spacing=12, padding=16), ft.Container( content=LogView, height=200, bgcolor=Palette.Crust, border=ft.Border.only(top=ft.BorderSide(1, Palette.Surface1)), ), ], ) ) Page.run_thread(RefreshAll) if __name__ == "__main__": if len(sys.argv) > 1: 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}")) ft.run(Main, assets_dir=str(AssetsDir))