mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-15 10:33:17 +02:00
Self-updates and program list from the repo
This commit is contained in:
1 parent
d9da8438c2
commit
e4272bd606
9 files changed
+416
-15
No files matched your search
+67
@@ -0,0 +1,67 @@
|
|||||||
|
"""The program catalog: the Programs/ folder (manifests, templates, icons),
|
||||||
|
fetched from the SaraSuite repo itself, so adding a program or fixing an
|
||||||
|
install recipe only takes a push, not a new AppImage.
|
||||||
|
|
||||||
|
The latest commit of the SaraSuite repo is checked the same way programs
|
||||||
|
are (Sources.py). When it changed, its Programs/ folder is downloaded into
|
||||||
|
~/.cache/SaraSuite/Catalog/. LoadPrograms() then reads that, falling back
|
||||||
|
to the AppImage's bundled copy when there's no catalog yet, when it can't be
|
||||||
|
loaded, or when running from source: there, the local Programs/ folder
|
||||||
|
always wins, so unpushed manifest edits work while developing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from Core import BundledManifestsDir, CacheHome, IsFrozen, LoadConfig
|
||||||
|
from Sources import ClearDownloads, Download, LatestCommit, SourceError
|
||||||
|
|
||||||
|
CatalogDir = CacheHome / "SaraSuite" / "Catalog"
|
||||||
|
_CommitFile = CatalogDir / "Commit"
|
||||||
|
_DownloadName = "SaraSuiteCatalog"
|
||||||
|
|
||||||
|
|
||||||
|
def ManifestsDir() -> Path:
|
||||||
|
if IsFrozen and (CatalogDir / "Programs").is_dir():
|
||||||
|
return CatalogDir / "Programs"
|
||||||
|
return BundledManifestsDir
|
||||||
|
|
||||||
|
|
||||||
|
def CatalogCommit() -> str:
|
||||||
|
return _CommitFile.read_text().strip() if _CommitFile.is_file() else ""
|
||||||
|
|
||||||
|
|
||||||
|
def UpdateCatalog(Log: Optional[Callable[[str], None]] = None) -> bool:
|
||||||
|
"""Fetch the SaraSuite repo's Programs/ if it has a new commit. Returns
|
||||||
|
True when the catalog changed (the program list should be reloaded).
|
||||||
|
Does nothing when running from source, and quietly keeps the current
|
||||||
|
catalog when the git host can't be reached."""
|
||||||
|
if not IsFrozen:
|
||||||
|
return False
|
||||||
|
Log = Log or (lambda Line: None)
|
||||||
|
Url = f"{LoadConfig().GitHost.rstrip('/')}/SaraSuite"
|
||||||
|
try:
|
||||||
|
Commit = LatestCommit(Url)
|
||||||
|
except SourceError:
|
||||||
|
return False
|
||||||
|
if Commit == CatalogCommit():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
Source = Download(_DownloadName, Url, Commit, Log) / "Programs"
|
||||||
|
if not Source.is_dir():
|
||||||
|
return False
|
||||||
|
Staging = CatalogDir.with_name("Catalog.new")
|
||||||
|
shutil.rmtree(Staging, ignore_errors=True)
|
||||||
|
shutil.copytree(Source, Staging / "Programs", ignore=shutil.ignore_patterns("__pycache__"))
|
||||||
|
(Staging / "Commit").write_text(Commit)
|
||||||
|
shutil.rmtree(CatalogDir, ignore_errors=True)
|
||||||
|
Staging.rename(CatalogDir)
|
||||||
|
except (SourceError, OSError) as Error:
|
||||||
|
Log(f"Couldn't update the program list: {Error}")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
ClearDownloads(_DownloadName)
|
||||||
|
Log(f"Program list updated to SaraSuite commit {Commit[:7]}")
|
||||||
|
return True
|
||||||
@@ -5,12 +5,16 @@
|
|||||||
SaraSuite update <Name>
|
SaraSuite update <Name>
|
||||||
SaraSuite options <Name> [--option Id=on|off ...] (no --option: list them)
|
SaraSuite options <Name> [--option Id=on|off ...] (no --option: list them)
|
||||||
SaraSuite remove <Name> [--configs] [--keep-runtime]
|
SaraSuite remove <Name> [--configs] [--keep-runtime]
|
||||||
|
SaraSuite self-update (AppImage only)
|
||||||
|
|
||||||
Install starts from your defaults (Settings, or the manifest's) unless set.
|
Install starts from your defaults (Settings, or the manifest's) unless set.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
|
import SelfUpdate
|
||||||
|
from Catalog import UpdateCatalog
|
||||||
|
from Core import AppVersion
|
||||||
from Installer import Apply, DefaultAnswers, DefaultOptions, FetchCommit, GetStatus, Remove
|
from Installer import Apply, DefaultAnswers, DefaultOptions, FetchCommit, GetStatus, Remove
|
||||||
from Manifests import LoadPrograms
|
from Manifests import LoadPrograms
|
||||||
|
|
||||||
@@ -45,8 +49,19 @@ def Run(Arguments: list[str]) -> int:
|
|||||||
RemoveCommand.add_argument("Name")
|
RemoveCommand.add_argument("Name")
|
||||||
RemoveCommand.add_argument("--configs", action="store_true")
|
RemoveCommand.add_argument("--configs", action="store_true")
|
||||||
RemoveCommand.add_argument("--keep-runtime", action="store_true")
|
RemoveCommand.add_argument("--keep-runtime", action="store_true")
|
||||||
|
Commands.add_parser("self-update")
|
||||||
Args = Parser.parse_args(Arguments)
|
Args = Parser.parse_args(Arguments)
|
||||||
|
|
||||||
|
if Args.Command == "self-update":
|
||||||
|
Latest = SelfUpdate.LatestRelease()
|
||||||
|
if Latest is None or not SelfUpdate.IsNewer(Latest.Version):
|
||||||
|
print(f"SaraSuite {AppVersion} is the latest.")
|
||||||
|
return 0
|
||||||
|
SelfUpdate.Install(Latest, print)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
UpdateCatalog(print) # the program list from the SaraSuite repo (AppImage only)
|
||||||
|
|
||||||
Programs = {Program.Name.lower(): Program for Program in LoadPrograms()}
|
Programs = {Program.Name.lower(): Program for Program in LoadPrograms()}
|
||||||
if Args.Command == "list":
|
if Args.Command == "list":
|
||||||
for Program in Programs.values():
|
for Program in Programs.values():
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import flet as ft
|
|||||||
import tomlkit
|
import tomlkit
|
||||||
|
|
||||||
AppName = "SaraSuite"
|
AppName = "SaraSuite"
|
||||||
|
# Compared with the latest Gitea release to offer self-updates (SelfUpdate.py).
|
||||||
|
# Bump it, then run Packaging/Release.py, to publish a new SaraSuite.
|
||||||
|
AppVersion = "1.1.0"
|
||||||
|
IsFrozen = bool(getattr(sys, "frozen", False)) # running from the AppImage
|
||||||
# Bundled in assets/fonts and always registered, so it works as FontFamily
|
# Bundled in assets/fonts and always registered, so it works as FontFamily
|
||||||
# without being installed. Any other FontFamily is looked up among the
|
# without being installed. Any other FontFamily is looked up among the
|
||||||
# system's installed fonts.
|
# system's installed fonts.
|
||||||
@@ -45,7 +49,7 @@ DownloadsDir = CacheHome / "SaraSuite" / "Downloads" # extracted source archive
|
|||||||
# Bundled read-only files (program manifests, fonts). Inside a PyInstaller
|
# Bundled read-only files (program manifests, fonts). Inside a PyInstaller
|
||||||
# build they're unpacked to sys._MEIPASS rather than next to the code.
|
# build they're unpacked to sys._MEIPASS rather than next to the code.
|
||||||
BundleDir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
|
BundleDir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
|
||||||
ManifestsDir = BundleDir / "Programs"
|
BundledManifestsDir = BundleDir / "Programs" # the AppImage's own copy; see Catalog.py
|
||||||
AssetsDir = BundleDir / "assets"
|
AssetsDir = BundleDir / "assets"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ from typing import Callable
|
|||||||
|
|
||||||
import flet as ft
|
import flet as ft
|
||||||
|
|
||||||
|
import SelfUpdate
|
||||||
|
from Catalog import UpdateCatalog
|
||||||
from Core import (
|
from Core import (
|
||||||
AppName, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataDir,
|
AppName, AppVersion, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataDir,
|
||||||
DataHome, EffectiveSettings, LoadConfig, ManifestsDir, Palette,
|
BundledManifestsDir, DataHome, EffectiveSettings, LoadConfig, Palette,
|
||||||
)
|
)
|
||||||
from Dialogs import ShowInstallDialog, ShowOptionsDialog, ShowRemoveDialog, ShowUpdateDialog
|
from Dialogs import ShowInstallDialog, ShowOptionsDialog, ShowRemoveDialog, ShowUpdateDialog
|
||||||
from Installer import Apply, Fetch, FetchCommit, GetStatus, PlanConfigs, PlanRemoval, Remove, SourceVersion
|
from Installer import Apply, Fetch, FetchCommit, GetStatus, PlanConfigs, PlanRemoval, Remove, SourceVersion
|
||||||
@@ -31,7 +33,7 @@ _MenuIcon = DataDir / "SaraSuite.png"
|
|||||||
def _LoadFletIdentity():
|
def _LoadFletIdentity():
|
||||||
# Lives in Programs/ (bundled as data) because installed programs get a
|
# Lives in Programs/ (bundled as data) because installed programs get a
|
||||||
# copy of the same file next to them.
|
# copy of the same file next to them.
|
||||||
Spec = importlib.util.spec_from_file_location("FletIdentity", ManifestsDir / "FletIdentity.py")
|
Spec = importlib.util.spec_from_file_location("FletIdentity", BundledManifestsDir / "FletIdentity.py")
|
||||||
Module = importlib.util.module_from_spec(Spec)
|
Module = importlib.util.module_from_spec(Spec)
|
||||||
Spec.loader.exec_module(Module)
|
Spec.loader.exec_module(Module)
|
||||||
return Module
|
return Module
|
||||||
@@ -85,9 +87,12 @@ def Main(Page: ft.Page) -> None:
|
|||||||
del LogView.controls[: len(LogView.controls) - _MaxLogLines]
|
del LogView.controls[: len(LogView.controls) - _MaxLogLines]
|
||||||
LogView.update()
|
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()
|
Programs = LoadPrograms()
|
||||||
Cards: list[ProgramCard] = []
|
Cards: list[ProgramCard] = []
|
||||||
State = {"Busy": False}
|
CardList = ft.ListView(Cards, expand=True, spacing=12, padding=16)
|
||||||
|
State = {"Busy": False, "Release": None}
|
||||||
|
|
||||||
def RefreshCard(Card: ProgramCard) -> None:
|
def RefreshCard(Card: ProgramCard) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -99,17 +104,70 @@ def Main(Page: ft.Page) -> None:
|
|||||||
Card.ShowStatus(Current, Leftovers, State["Busy"])
|
Card.ShowStatus(Current, Leftovers, State["Busy"])
|
||||||
Card.update()
|
Card.update()
|
||||||
|
|
||||||
|
def BuildCards() -> None:
|
||||||
|
Cards[:] = [ProgramCard(Program, OnInstall, OnUpdate, OnRemove, OnOptions) for Program in Programs]
|
||||||
|
CardList.controls = Cards
|
||||||
|
|
||||||
def RefreshAll() -> None:
|
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:
|
for Card in Cards:
|
||||||
RefreshCard(Card)
|
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:
|
def SetBusy(Busy: bool, Working: ProgramCard | None = None) -> None:
|
||||||
State["Busy"] = Busy
|
State["Busy"] = Busy
|
||||||
RefreshButton.disabled = Busy
|
RefreshButton.disabled = Busy
|
||||||
|
SelfUpdateButton.disabled = Busy or not CanSelfUpdate()
|
||||||
for Card in Cards:
|
for Card in Cards:
|
||||||
Card.SetBusy(Busy, Card is Working)
|
Card.SetBusy(Busy, Card is Working)
|
||||||
Card.update()
|
Card.update()
|
||||||
RefreshButton.update()
|
RefreshButton.update()
|
||||||
|
SelfUpdateButton.update()
|
||||||
|
|
||||||
def RunOperation(Card: ProgramCard | None, Work: Callable[[], None]) -> None:
|
def RunOperation(Card: ProgramCard | None, Work: Callable[[], None]) -> None:
|
||||||
"""Run Work in a background thread with every card locked, then
|
"""Run Work in a background thread with every card locked, then
|
||||||
@@ -180,8 +238,7 @@ def Main(Page: ft.Page) -> None:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
for Program in Programs:
|
BuildCards()
|
||||||
Cards.append(ProgramCard(Program, OnInstall, OnUpdate, OnRemove, OnOptions))
|
|
||||||
|
|
||||||
def ToggleMenuEntry(e) -> None:
|
def ToggleMenuEntry(e) -> None:
|
||||||
_SetMenuEntry(not _MenuEntry.exists())
|
_SetMenuEntry(not _MenuEntry.exists())
|
||||||
@@ -195,6 +252,10 @@ def Main(Page: ft.Page) -> None:
|
|||||||
on_click=ToggleMenuEntry,
|
on_click=ToggleMenuEntry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SelfUpdateButton = ft.OutlinedButton(
|
||||||
|
content="Update SaraSuite", style=ChipButtonStyle(), visible=False, on_click=OnSelfUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
RefreshButton = ft.IconButton(
|
RefreshButton = ft.IconButton(
|
||||||
icon=ft.Icons.REFRESH,
|
icon=ft.Icons.REFRESH,
|
||||||
icon_color=Palette.Mauve,
|
icon_color=Palette.Mauve,
|
||||||
@@ -217,7 +278,9 @@ def Main(Page: ft.Page) -> None:
|
|||||||
ft.Container(
|
ft.Container(
|
||||||
content=ft.Row([
|
content=ft.Row([
|
||||||
ft.Text(AppName, size=24, color=Palette.Mauve),
|
ft.Text(AppName, size=24, color=Palette.Mauve),
|
||||||
|
ft.Text(AppVersion, size=12, color=Palette.Overlay0),
|
||||||
ft.Container(expand=True),
|
ft.Container(expand=True),
|
||||||
|
SelfUpdateButton,
|
||||||
MenuButton,
|
MenuButton,
|
||||||
RefreshButton,
|
RefreshButton,
|
||||||
SettingsButton,
|
SettingsButton,
|
||||||
@@ -226,7 +289,7 @@ def Main(Page: ft.Page) -> None:
|
|||||||
bgcolor=Palette.Mantle,
|
bgcolor=Palette.Mantle,
|
||||||
border=ft.Border.only(bottom=ft.BorderSide(1, Palette.Surface1)),
|
border=ft.Border.only(bottom=ft.BorderSide(1, Palette.Surface1)),
|
||||||
),
|
),
|
||||||
ft.ListView(Cards, expand=True, spacing=12, padding=16),
|
CardList,
|
||||||
ft.Container(
|
ft.Container(
|
||||||
content=LogView,
|
content=LogView,
|
||||||
height=200,
|
height=200,
|
||||||
|
|||||||
+15
-7
@@ -11,7 +11,7 @@ from typing import Optional
|
|||||||
import tomlkit
|
import tomlkit
|
||||||
|
|
||||||
from Core import (
|
from Core import (
|
||||||
CacheHome, ConfigHome, CurrentDesktop, DataHome, Home, LoadConfig, ManifestsDir, ProgramsDir,
|
CacheHome, ConfigHome, CurrentDesktop, DataHome, Home, LoadConfig, ProgramsDir,
|
||||||
SharedConfigDir,
|
SharedConfigDir,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -230,14 +230,22 @@ def LoadProgram(Folder: Path) -> Program:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _LoadFrom(Folder: Path) -> list[Program]:
|
||||||
|
return [LoadProgram(Child) for Child in sorted(Folder.iterdir()) if (Child / "Manifest.toml").exists()]
|
||||||
|
|
||||||
|
|
||||||
def LoadPrograms() -> list[Program]:
|
def LoadPrograms() -> list[Program]:
|
||||||
"""Alphabetical, with each program's children (Parent = its Name) right
|
"""Alphabetical, with each program's children (Parent = its Name) right
|
||||||
after it. A child whose parent isn't there keeps its own place."""
|
after it. A child whose parent isn't there keeps its own place.
|
||||||
Loaded = [
|
Read from the catalog fetched from the SaraSuite repo (Catalog.py) when
|
||||||
LoadProgram(Folder)
|
there is one, falling back to the bundled copy if that can't be loaded
|
||||||
for Folder in sorted(ManifestsDir.iterdir())
|
(e.g. it uses a manifest feature this SaraSuite doesn't know yet)."""
|
||||||
if (Folder / "Manifest.toml").exists()
|
from Catalog import BundledManifestsDir, ManifestsDir
|
||||||
]
|
|
||||||
|
try:
|
||||||
|
Loaded = _LoadFrom(ManifestsDir())
|
||||||
|
except Exception:
|
||||||
|
Loaded = _LoadFrom(BundledManifestsDir)
|
||||||
Names = {Current.Name for Current in Loaded}
|
Names = {Current.Name for Current in Loaded}
|
||||||
Ordered = []
|
Ordered = []
|
||||||
for Current in Loaded:
|
for Current in Loaded:
|
||||||
|
|||||||
Executable
+120
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Publish a SaraSuite release: build the AppImage, create the Gitea release
|
||||||
|
v<AppVersion> (from Core.py) with it attached, and copy it to the Shared
|
||||||
|
folder. Running SaraSuites then offer to update themselves (SelfUpdate.py).
|
||||||
|
|
||||||
|
Packaging/Release.py # after bumping AppVersion in Core.py
|
||||||
|
Packaging/Release.py --notes "What changed"
|
||||||
|
|
||||||
|
Everything must be committed and pushed first, so the release's tag points
|
||||||
|
at code that's actually on Gitea. The Gitea token comes from git's own
|
||||||
|
credential store (the same one `git push` uses), so there's nothing to set up.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
RepoDir = Path(__file__).resolve().parent.parent
|
||||||
|
AppImage = RepoDir / "dist" / "SaraSuite-x86_64.AppImage"
|
||||||
|
SharedDir = Path("/mnt/files/Shared/SaraSuite")
|
||||||
|
|
||||||
|
|
||||||
|
def Git(*Args: str) -> str:
|
||||||
|
return subprocess.run(["git", *Args], cwd=RepoDir, capture_output=True, text=True, check=True).stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def Fail(Message: str) -> None:
|
||||||
|
sys.exit(f"Release.py: {Message}")
|
||||||
|
|
||||||
|
|
||||||
|
def Token(Host: str) -> str:
|
||||||
|
Result = subprocess.run(
|
||||||
|
["git", "credential", "fill"], input=f"protocol=https\nhost={Host}\n\n",
|
||||||
|
capture_output=True, text=True, env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
|
||||||
|
)
|
||||||
|
Found = re.search(r"^password=(.+)$", Result.stdout, re.MULTILINE)
|
||||||
|
if not Found:
|
||||||
|
Fail(f"no Gitea token for {Host} in git's credential store; push once so git saves it")
|
||||||
|
return Found[1]
|
||||||
|
|
||||||
|
|
||||||
|
def Api(Method: str, Url: str, TokenValue: str, Body: bytes | None = None, ContentType: str = "application/json"):
|
||||||
|
Request = urllib.request.Request(Url, data=Body, method=Method, headers={
|
||||||
|
"Authorization": f"token {TokenValue}", "Content-Type": ContentType, "Accept": "application/json",
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(Request, timeout=300) as Response:
|
||||||
|
return json.loads(Response.read() or b"null")
|
||||||
|
except urllib.error.HTTPError as Error:
|
||||||
|
if Error.code == 404:
|
||||||
|
return None
|
||||||
|
Fail(f"{Method} {Url} failed: {Error.code} {Error.read().decode(errors='replace')[:300]}")
|
||||||
|
|
||||||
|
|
||||||
|
def Main() -> None:
|
||||||
|
Parser = argparse.ArgumentParser(description="Publish a SaraSuite release on Gitea.")
|
||||||
|
Parser.add_argument("--notes", default="", help="release notes (default: the commits since the last release)")
|
||||||
|
Args = Parser.parse_args()
|
||||||
|
|
||||||
|
Version = re.search(r'^AppVersion = "([^"]+)"', (RepoDir / "Core.py").read_text(), re.MULTILINE)[1]
|
||||||
|
Tag = f"v{Version}"
|
||||||
|
|
||||||
|
# origin is e.g. https://git.srhtnin.garden/sarah/SaraSuite.git
|
||||||
|
Origin = urlsplit(Git("remote", "get-url", "origin"))
|
||||||
|
Owner, Repo = Origin.path.strip("/").removesuffix(".git").split("/")[-2:]
|
||||||
|
ApiBase = f"{Origin.scheme}://{Origin.netloc}/api/v1/repos/{Owner}/{Repo}"
|
||||||
|
|
||||||
|
if Git("status", "--porcelain"):
|
||||||
|
Fail("there are uncommitted changes; commit (and push) first")
|
||||||
|
Branch = Git("rev-parse", "--abbrev-ref", "HEAD")
|
||||||
|
Git("fetch", "--quiet", "--tags", "origin")
|
||||||
|
Head = Git("rev-parse", "HEAD")
|
||||||
|
if Head != Git("rev-parse", f"origin/{Branch}"):
|
||||||
|
Fail(f"{Branch} isn't pushed (or is behind origin); push first")
|
||||||
|
TokenValue = Token(Origin.netloc)
|
||||||
|
if Api("GET", f"{ApiBase}/releases/tags/{Tag}", TokenValue) is not None:
|
||||||
|
Fail(f"{Tag} is already released; bump AppVersion in Core.py first")
|
||||||
|
|
||||||
|
Notes = Args.notes
|
||||||
|
if not Notes:
|
||||||
|
Previous = Git("tag", "--list", "v*", "--sort=-v:refname").splitlines()
|
||||||
|
Range = f"{Previous[0]}..HEAD" if Previous else "HEAD"
|
||||||
|
Notes = Git("log", "--format=- %s", Range) or "- First release with self-updates."
|
||||||
|
|
||||||
|
print(f"== Building SaraSuite {Version}")
|
||||||
|
subprocess.run(["Packaging/BuildAppImage.sh"], cwd=RepoDir, check=True,
|
||||||
|
env={**os.environ, "PATH": f"{Path.home()}/.local/bin:{os.environ['PATH']}"})
|
||||||
|
|
||||||
|
print(f"== Creating release {Tag}")
|
||||||
|
Created = Api("POST", f"{ApiBase}/releases", TokenValue, json.dumps({
|
||||||
|
"tag_name": Tag, "target_commitish": Head, "name": f"SaraSuite {Version}", "body": Notes,
|
||||||
|
}).encode())
|
||||||
|
|
||||||
|
print(f"== Uploading {AppImage.name} ({AppImage.stat().st_size // 1_000_000} MB)")
|
||||||
|
Boundary = uuid.uuid4().hex
|
||||||
|
Body = (
|
||||||
|
f"--{Boundary}\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"{AppImage.name}\"\r\n"
|
||||||
|
f"Content-Type: application/octet-stream\r\n\r\n"
|
||||||
|
).encode() + AppImage.read_bytes() + f"\r\n--{Boundary}--\r\n".encode()
|
||||||
|
Api("POST", f"{ApiBase}/releases/{Created['id']}/assets?name={AppImage.name}", TokenValue, Body,
|
||||||
|
f"multipart/form-data; boundary={Boundary}")
|
||||||
|
|
||||||
|
if SharedDir.is_dir():
|
||||||
|
shutil.copy2(AppImage, SharedDir / AppImage.name)
|
||||||
|
print(f"== Copied to {SharedDir}")
|
||||||
|
Git("fetch", "--quiet", "--tags", "origin") # the new tag, for the next release's notes
|
||||||
|
print(f"== Released {Tag}: {Origin.scheme}://{Origin.netloc}/{Owner}/{Repo}/releases/tag/{Tag}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
Main()
|
||||||
@@ -30,6 +30,23 @@ GitHost = "https://git.srhtnin.garden/sarah"
|
|||||||
|
|
||||||
The only thing a program needs from the system is `python3` (for its venv).
|
The only thing a program needs from the system is `python3` (for its venv).
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
- **Programs** update from their own repos (see Sources above).
|
||||||
|
- **The program list** (`Programs/`: manifests, templates, icons) is fetched from this repo
|
||||||
|
whenever it has a new commit, so adding a program or fixing a recipe only takes a push. It's
|
||||||
|
cached in `~/.cache/SaraSuite/Catalog/`; the AppImage's bundled copy is the fallback. Running
|
||||||
|
from source always uses the local `Programs/`.
|
||||||
|
- **SaraSuite itself** checks this repo's latest Gitea release. When it's newer than its own
|
||||||
|
`AppVersion`, an "Update SaraSuite" button downloads the new AppImage, swaps it in place (same
|
||||||
|
path, so the app menu entry keeps working) and restarts. From a terminal:
|
||||||
|
`SaraSuite-x86_64.AppImage self-update`.
|
||||||
|
|
||||||
|
To publish a new SaraSuite: bump `AppVersion` in `Core.py`, commit and push, then run
|
||||||
|
`Packaging/Release.py` (optionally `--notes "..."`). It builds the AppImage, creates the release
|
||||||
|
`v<AppVersion>` with the AppImage attached, and copies it to the Shared folder. The Gitea token
|
||||||
|
comes from git's credential store, the same one `git push` uses.
|
||||||
|
|
||||||
## Shared settings
|
## Shared settings
|
||||||
|
|
||||||
Settings every SaraSuite program shares live in `~/.config/SaraSuite/Config.toml`:
|
Settings every SaraSuite program shares live in `~/.config/SaraSuite/Config.toml`:
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""SaraSuite updating itself from its Gitea releases.
|
||||||
|
|
||||||
|
Packaging/Release.py publishes a release (tag v<AppVersion>) with the
|
||||||
|
AppImage attached. Here SaraSuite asks Gitea for the latest release, and if
|
||||||
|
it's newer than AppVersion and SaraSuite runs as an AppImage, downloads it
|
||||||
|
next to the running one and renames it into place (allowed while it's
|
||||||
|
running), then restarts into it. The AppImage keeps its path, so its app
|
||||||
|
menu entry keeps working.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from Core import AppName, AppVersion, ChildEnvironment, LoadConfig
|
||||||
|
from Sources import GetBytes, GetJson, SourceError
|
||||||
|
|
||||||
|
AssetName = f"{AppName}-x86_64.AppImage"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Release:
|
||||||
|
Version: str
|
||||||
|
AssetUrl: str # empty if the release has no AppImage attached
|
||||||
|
|
||||||
|
|
||||||
|
def RunningAppImage() -> Optional[Path]:
|
||||||
|
Value = os.environ.get("APPIMAGE", "")
|
||||||
|
return Path(Value) if Value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _VersionTuple(Version: str) -> tuple[int, ...]:
|
||||||
|
Parts = []
|
||||||
|
for Part in Version.strip().lstrip("vV").split("."):
|
||||||
|
Digits = "".join(Char for Char in Part if Char.isdigit())
|
||||||
|
Parts.append(int(Digits) if Digits else 0)
|
||||||
|
return tuple(Parts)
|
||||||
|
|
||||||
|
|
||||||
|
def IsNewer(Version: str) -> bool:
|
||||||
|
return _VersionTuple(Version) > _VersionTuple(AppVersion)
|
||||||
|
|
||||||
|
|
||||||
|
def LatestRelease() -> Optional[Release]:
|
||||||
|
"""The latest published release, or None if there's none or the git host
|
||||||
|
can't be reached. GitHost looks like https://host/owner; the API lives at
|
||||||
|
https://host/api/v1."""
|
||||||
|
Parts = urlsplit(LoadConfig().GitHost.rstrip("/"))
|
||||||
|
Owner = Parts.path.strip("/").split("/")[-1]
|
||||||
|
Url = f"{Parts.scheme}://{Parts.netloc}/api/v1/repos/{Owner}/{AppName}/releases/latest"
|
||||||
|
try:
|
||||||
|
Data = GetJson(Url)
|
||||||
|
except SourceError:
|
||||||
|
return None
|
||||||
|
Asset = next((Item for Item in Data.get("assets", []) if Item.get("name") == AssetName), None)
|
||||||
|
return Release(Data.get("tag_name", ""), Asset.get("browser_download_url", "") if Asset else "")
|
||||||
|
|
||||||
|
|
||||||
|
def Install(Latest: Release, Log: Callable[[str], None]) -> Path:
|
||||||
|
"""Replace the running AppImage with Latest's. Raises OSError/SourceError."""
|
||||||
|
Target = RunningAppImage()
|
||||||
|
if Target is None:
|
||||||
|
raise OSError("SaraSuite isn't running as an AppImage, so it can't replace itself.")
|
||||||
|
if not Latest.AssetUrl:
|
||||||
|
raise OSError(f"Release {Latest.Version} has no {AssetName} attached.")
|
||||||
|
Log(f"== Updating {AppName} {AppVersion} -> {Latest.Version.lstrip('v')}")
|
||||||
|
Log(f"Downloading {Latest.AssetUrl}")
|
||||||
|
Data = GetBytes(Latest.AssetUrl)
|
||||||
|
if not Data.startswith(b"\x7fELF"):
|
||||||
|
raise OSError("The download isn't an AppImage; leaving the current one alone.")
|
||||||
|
# Same folder, so the rename is atomic; renaming over a running AppImage
|
||||||
|
# is fine (overwriting it in place isn't: "Text file busy").
|
||||||
|
Temporary = Target.with_name(Target.name + ".new")
|
||||||
|
Temporary.write_bytes(Data)
|
||||||
|
Temporary.chmod(0o755)
|
||||||
|
Temporary.replace(Target)
|
||||||
|
Log(f"== {AppName} updated; it's the new version from the next start")
|
||||||
|
return Target
|
||||||
|
|
||||||
|
|
||||||
|
def Restart() -> None:
|
||||||
|
"""Start the (new) AppImage; the caller then closes this one."""
|
||||||
|
Target = RunningAppImage()
|
||||||
|
if Target is None:
|
||||||
|
return
|
||||||
|
Environment = ChildEnvironment()
|
||||||
|
for Key in ("APPIMAGE", "APPDIR", "ARGV0", "OWD"): # the new AppImage sets its own
|
||||||
|
Environment.pop(Key, None)
|
||||||
|
subprocess.Popen([str(Target)], env=Environment, start_new_session=True,
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
+14
@@ -40,6 +40,20 @@ def _Get(Url: str) -> bytes:
|
|||||||
raise SourceError(f"Couldn't reach {Url}: {Error}") from Error
|
raise SourceError(f"Couldn't reach {Url}: {Error}") from Error
|
||||||
|
|
||||||
|
|
||||||
|
def GetBytes(Url: str) -> bytes:
|
||||||
|
return _Get(Url)
|
||||||
|
|
||||||
|
|
||||||
|
def GetJson(Url: str):
|
||||||
|
"""A JSON API response (e.g. Gitea's latest release), or SourceError."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(_Get(Url))
|
||||||
|
except ValueError as Error:
|
||||||
|
raise SourceError(f"{Url} didn't return JSON: {Error}") from Error
|
||||||
|
|
||||||
|
|
||||||
def _RepoUrl(GitUrl: str) -> str:
|
def _RepoUrl(GitUrl: str) -> str:
|
||||||
return GitUrl.rstrip("/").removesuffix(".git")
|
return GitUrl.rstrip("/").removesuffix(".git")
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user