"""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 pathlib import Path from typing import Callable import flet as ft import SelfUpdate from Catalog import UpdateCatalog from Core import ( AppName, AppVersion, IsWindows, StartMenuDir, 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 packaged (AppImage / .exe), SaraSuite can put itself in the # app menu: a desktop entry on Linux, a Start Menu shortcut on Windows. _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 _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 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) _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 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) 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 = f"Remove from {_MenuLabel}" if _MenuEntry.exists() else f"Add to {_MenuLabel}" MenuButton.update() MenuButton = ft.OutlinedButton( content=f"Remove from {_MenuLabel}" if _MenuEntry.exists() else f"Add to {_MenuLabel}", 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: 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:])) SelfUpdate.CleanUpOldExe() if not IsWindows: # 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))