"""SaraSuite entry point: installs, updates and removes Sara programs. With arguments it runs the command-line mode instead (see Cli.py).""" import os import shutil import sys import traceback from typing import Callable import flet as ft from Core import ( AppName, ApplyTheme, AssetsDir, BuildTheme, ChipButtonStyle, DataDir, DataHome, FontFamily, LoadConfig, Palette, ) from Dialogs import ShowInstallDialog, ShowRemoveDialog, ShowUpdateDialog from Installer import Apply, Fetch, GetStatus, PlanConfigs, PlanRemoval, Remove, SourceVersion from Manifests import LoadPrograms from ProgramCard import ProgramCard _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 _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" "Categories=System;Settings;\nStartupNotify=true\n" ) def Main(Page: ft.Page) -> None: Config = LoadConfig() ApplyTheme(Config.Theme) # 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 = {FontFamily: "fonts/Fixedsys_Excelsior_Regular.ttf"} 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), ), ) for Program in Programs: Cards.append(ProgramCard(Program, OnInstall, OnUpdate, OnRemove)) 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), ) 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, ]), 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:])) ft.run(Main, assets_dir=str(AssetsDir))