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