Files
SaraSuite/Main.py
T

315 lines
12 KiB
Python

"""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))