mirror of
https://git.srhtnin.garden/sarah/SaraSuite.git
synced 2026-10-14 18:28:11 +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 options <Name> [--option Id=on|off ...] (no --option: list them)
|
||||
SaraSuite remove <Name> [--configs] [--keep-runtime]
|
||||
SaraSuite self-update (AppImage only)
|
||||
|
||||
Install starts from your defaults (Settings, or the manifest's) unless set.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
||||
import SelfUpdate
|
||||
from Catalog import UpdateCatalog
|
||||
from Core import AppVersion
|
||||
from Installer import Apply, DefaultAnswers, DefaultOptions, FetchCommit, GetStatus, Remove
|
||||
from Manifests import LoadPrograms
|
||||
|
||||
@@ -45,8 +49,19 @@ def Run(Arguments: list[str]) -> int:
|
||||
RemoveCommand.add_argument("Name")
|
||||
RemoveCommand.add_argument("--configs", action="store_true")
|
||||
RemoveCommand.add_argument("--keep-runtime", action="store_true")
|
||||
Commands.add_parser("self-update")
|
||||
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()}
|
||||
if Args.Command == "list":
|
||||
for Program in Programs.values():
|
||||
|
||||
@@ -14,6 +14,10 @@ import flet as ft
|
||||
import tomlkit
|
||||
|
||||
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
|
||||
# without being installed. Any other FontFamily is looked up among the
|
||||
# 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
|
||||
# build they're unpacked to sys._MEIPASS rather than next to the code.
|
||||
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"
|
||||
|
||||
|
||||
|
||||
@@ -10,9 +10,11 @@ from typing import Callable
|
||||
|
||||
import flet as ft
|
||||
|
||||
import SelfUpdate
|
||||
from Catalog import UpdateCatalog
|
||||
from Core import (
|
||||
AppName, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, ChipButtonStyle, DataDir,
|
||||
DataHome, EffectiveSettings, LoadConfig, ManifestsDir, Palette,
|
||||
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
|
||||
@@ -31,7 +33,7 @@ _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", ManifestsDir / "FletIdentity.py")
|
||||
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
|
||||
@@ -85,9 +87,12 @@ def Main(Page: ft.Page) -> None:
|
||||
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] = []
|
||||
State = {"Busy": False}
|
||||
CardList = ft.ListView(Cards, expand=True, spacing=12, padding=16)
|
||||
State = {"Busy": False, "Release": None}
|
||||
|
||||
def RefreshCard(Card: ProgramCard) -> None:
|
||||
try:
|
||||
@@ -99,17 +104,70 @@ def Main(Page: ft.Page) -> None:
|
||||
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
|
||||
@@ -180,8 +238,7 @@ def Main(Page: ft.Page) -> None:
|
||||
),
|
||||
)
|
||||
|
||||
for Program in Programs:
|
||||
Cards.append(ProgramCard(Program, OnInstall, OnUpdate, OnRemove, OnOptions))
|
||||
BuildCards()
|
||||
|
||||
def ToggleMenuEntry(e) -> None:
|
||||
_SetMenuEntry(not _MenuEntry.exists())
|
||||
@@ -195,6 +252,10 @@ def Main(Page: ft.Page) -> None:
|
||||
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,
|
||||
@@ -217,7 +278,9 @@ def Main(Page: ft.Page) -> None:
|
||||
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,
|
||||
@@ -226,7 +289,7 @@ def Main(Page: ft.Page) -> None:
|
||||
bgcolor=Palette.Mantle,
|
||||
border=ft.Border.only(bottom=ft.BorderSide(1, Palette.Surface1)),
|
||||
),
|
||||
ft.ListView(Cards, expand=True, spacing=12, padding=16),
|
||||
CardList,
|
||||
ft.Container(
|
||||
content=LogView,
|
||||
height=200,
|
||||
|
||||
+15
-7
@@ -11,7 +11,7 @@ from typing import Optional
|
||||
import tomlkit
|
||||
|
||||
from Core import (
|
||||
CacheHome, ConfigHome, CurrentDesktop, DataHome, Home, LoadConfig, ManifestsDir, ProgramsDir,
|
||||
CacheHome, ConfigHome, CurrentDesktop, DataHome, Home, LoadConfig, ProgramsDir,
|
||||
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]:
|
||||
"""Alphabetical, with each program's children (Parent = its Name) right
|
||||
after it. A child whose parent isn't there keeps its own place."""
|
||||
Loaded = [
|
||||
LoadProgram(Folder)
|
||||
for Folder in sorted(ManifestsDir.iterdir())
|
||||
if (Folder / "Manifest.toml").exists()
|
||||
]
|
||||
after it. A child whose parent isn't there keeps its own place.
|
||||
Read from the catalog fetched from the SaraSuite repo (Catalog.py) when
|
||||
there is one, falling back to the bundled copy if that can't be loaded
|
||||
(e.g. it uses a manifest feature this SaraSuite doesn't know yet)."""
|
||||
from Catalog import BundledManifestsDir, ManifestsDir
|
||||
|
||||
try:
|
||||
Loaded = _LoadFrom(ManifestsDir())
|
||||
except Exception:
|
||||
Loaded = _LoadFrom(BundledManifestsDir)
|
||||
Names = {Current.Name for Current in Loaded}
|
||||
Ordered = []
|
||||
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).
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
return GitUrl.rstrip("/").removesuffix(".git")
|
||||
|
||||
|
||||
Reference in new issue
Block a user