"""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 import SelfUpdate from Catalog import UpdateCatalog from Core import ( AppName, AppVersion, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataDir, BundledManifestsDir, DataHome, EffectiveSettings, LoadConfig, 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", BundledManifestsDir / "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() # Both lists are changed in place when the program list is updated from # the SaraSuite repo (Catalog.py), so everything holding them stays valid. Programs = LoadPrograms() Cards: list[ProgramCard] = [] CardList = ft.ListView(Cards, expand=True, spacing=12, padding=16) State = {"Busy": False, "Release": None} 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 BuildCards() -> None: Cards[:] = [ProgramCard(Program, OnInstall, OnUpdate, OnRemove, OnOptions) for Program in Programs] CardList.controls = Cards def RefreshAll() -> None: # A new program list from the SaraSuite repo first, then every # program's status, then whether there's a newer SaraSuite. if UpdateCatalog(Log): Programs[:] = LoadPrograms() BuildCards() CardList.update() for Card in Cards: RefreshCard(Card) CheckSelfUpdate() def CanSelfUpdate() -> bool: Latest = State["Release"] return Latest is not None and bool(Latest.AssetUrl) and SelfUpdate.RunningAppImage() is not None def CheckSelfUpdate() -> None: Latest = SelfUpdate.LatestRelease() State["Release"] = Latest if Latest is not None and SelfUpdate.IsNewer(Latest.Version) else None if State["Release"] is None: SelfUpdateButton.visible = False else: Version = State["Release"].Version.lstrip("v") # Only an AppImage can replace itself; from source, just say so. SelfUpdateButton.visible = True SelfUpdateButton.disabled = not CanSelfUpdate() SelfUpdateButton.content = f"Update SaraSuite to {Version}" SelfUpdateButton.tooltip = None if not SelfUpdateButton.disabled else "Running from source: pull and rebuild instead" SelfUpdateButton.update() def OnSelfUpdate(e) -> None: Latest = State["Release"] if Latest is None: return def Work() -> None: SelfUpdate.Install(Latest, Log) Page.show_dialog(ft.AlertDialog( title="SaraSuite updated", content=ft.Text(f"SaraSuite {Latest.Version.lstrip('v')} is installed. Restart now to use it?"), actions=[ ft.OutlinedButton(content="Later", style=ChipButtonStyle(), on_click=lambda e: Page.pop_dialog()), ft.OutlinedButton(content="Restart", style=ChipButtonStyle(), on_click=lambda e: Restart()), ], )) def Restart() -> None: SelfUpdate.Restart() Page.run_task(Page.window.destroy) RunOperation(None, Work) def SetBusy(Busy: bool, Working: ProgramCard | None = None) -> None: State["Busy"] = Busy RefreshButton.disabled = Busy SelfUpdateButton.disabled = Busy or not CanSelfUpdate() for Card in Cards: Card.SetBusy(Busy, Card is Working) Card.update() RefreshButton.update() SelfUpdateButton.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)), ), ) BuildCards() 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, ) SelfUpdateButton = ft.OutlinedButton( content="Update SaraSuite", style=ChipButtonStyle(), visible=False, on_click=OnSelfUpdate, ) 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.Text(AppVersion, size=12, color=Palette.Overlay0), ft.Container(expand=True), SelfUpdateButton, MenuButton, RefreshButton, SettingsButton, ]), padding=ft.Padding.symmetric(horizontal=16, vertical=8), bgcolor=Palette.Mantle, border=ft.Border.only(bottom=ft.BorderSide(1, Palette.Surface1)), ), CardList, 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))