Files
SaraSuite/Main.py
T

238 lines
8.4 KiB
Python

"""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, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataDir,
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 _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;\nStartupNotify=true\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:]))
ft.run(Main, assets_dir=str(AssetsDir))