mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-13 18:18:12 +02:00
238 lines
8.4 KiB
Python
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))
|