Fix pale Wi-Fi/Bluetooth tray icons, das-panel-icon help, publisher and deployer (0.9.5)

- Light theme uses Papirus-Light: the panel icons of Papirus are #dfdfdf,
  made for dark panels (xsettings, theme index, login screen).
- das-panel-icon help/-h/--help print usage instead of setting an icon;
  unknown options are rejected.
- New packages publisher (publish) and deployer (deploy) on the desktop
  image: config in ~/.config/<name>/ (neutral example copied on first run),
  history in ~/.local/state/<name>/.
- das-theme 0.3.0, das-settings 0.6.0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
das
2026-10-25 14:44:36 +02:00
co-authored by Claude Sonnet 5.5
parent ea62ec7a26
commit 62b8b98086
42 changed files with 4063 additions and 11 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Das
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
# publisher launcher: the config lives in ~/.config/publisher/publish.toml (copied from the example on the first run).
cfg=${XDG_CONFIG_HOME:-$HOME/.config}/publisher/publish.toml
if [ ! -f "$cfg" ]; then
mkdir -p "${cfg%/*}" && cp /usr/share/das/publisher/publish.toml.example "$cfg" || exit 1
echo "publish: created $cfg, edit [server] there" >&2
fi
PYTHONPATH=/usr/lib/das/publisher exec python3 -c 'import sys; from publisher.cli import main; sys.exit(main())' --config "$cfg" "$@"
@@ -0,0 +1,2 @@
"""Publish apk, AppImage and binaries to the apps page of the home Tomcat over ssh + rsync."""
__version__ = "0.3.0"
@@ -0,0 +1,173 @@
"""Command-line mode: the same pipeline as the TUI, for scripts and quick publishing."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from . import __version__, config, detect, packages, pipeline, remote
from .pipeline import Options, Plan
SIGN = {"new": ("+", "32"), "replace": ("~", "33"), "meta": ("~", "36"), "notes": ("~", "36"),
"delete": ("-", "31"), "same": ("=", "2"), "skip": ("!", "33")}
COLOR = True
def paint(text: str, code: str) -> str:
return f"\033[{code}m{text}\033[0m" if COLOR else text
def print_plan(plan: Plan) -> None:
a = plan.app
print(f"\n{a.name} {a.src} -> {plan.server.target}:{plan.server.apps_dir}"
f" {('rev ' + plan.git_rev) if plan.git_rev else ''}")
for i in plan.items:
sign, code = SIGN[i.action]
print(paint(f" {sign} {i.describe(a)}", code))
for n in plan.notes:
print(f" ! {n}")
def confirm(question: str, yes: bool) -> bool:
if yes:
return True
if not sys.stdin.isatty():
print("not a terminal: pass --yes to confirm", file=sys.stderr)
return False
return input(f"{question} [y/N] ").strip().lower() in ("y", "yes", "т", "так")
def notes_text(args) -> str:
if args.notes_file:
return Path(args.notes_file).expanduser().read_text()
return (args.notes or "").replace("\\n", "\n")
def options(args) -> Options:
return Options(build=not args.no_build, force=args.force, prune=not args.no_prune, meta=not args.no_meta,
notes=notes_text(args))
def cmd_list(cfg, args):
st, others = pipeline.status_all(cfg)
style = {"newer": "1;32", "unpublished": "33", "older": "33", "error": "31", "no build": "2"}
for a in cfg.apps:
s = st[a.name]
print(f"{a.name:22} " + paint(f"{s.state:12}", style.get(s.state, "0"))
+ f" local: {s.local or '-':32} site: {s.remote or '-'}")
if others:
print(f"\nnot in {cfg.path.name}: {', '.join(others)}")
def cmd_publish(cfg, args):
names = [a.name for a in cfg.apps] if args.all else args.apps
if not names:
print("name apps or pass --all", file=sys.stderr)
return 1
if args.all:
st, _ = pipeline.status_all(cfg)
names = [n for n in names if st[n].state in ("newer", "unpublished")]
print(f"newer locally: {', '.join(names) or 'none'}")
rc = 0
for name in names:
plan = pipeline.preview(cfg, cfg.app(name), options(args), log=print)
print_plan(plan)
if args.dry_run or not plan.changes:
continue
if not confirm(f"publish {name}?", args.yes):
rc = rc or 1
continue
res = pipeline.execute(plan, log=print)
rc = rc if res.ok else 2
return rc
def cmd_versions(cfg, args):
for p in pipeline.versions(cfg, cfg.app(args.app)):
print(f"{p.version:14} {p.kind.label:9} {p.size / 1048576:8.1f} MB {p.file}")
return 0
def cmd_remove(cfg, args):
app = cfg.app(args.app)
if not confirm(f"delete {app.name} v{args.version} from the site?", args.yes):
return 1
res = pipeline.remove(cfg, app, args.version, log=print)
print(res.message)
return 0 if res.ok else 2
def cmd_add(cfg, args):
app = detect.new_app(Path(args.path), args.name)
print(config.app_to_toml(app))
try:
builds, notes = packages.resolve(app)
for b in builds:
print(f"# found {b.path.name} -> {b.site_name(app.name)}")
for n in notes:
print(f"# {n}")
except packages.PackageError as e:
print(f"# {e}")
if confirm(f"append to {cfg.path}?", args.yes):
config.append_apps(cfg.path, [app])
return 0
def main(argv=None) -> int:
p = argparse.ArgumentParser(prog="publish", description="Publish apk, AppImage and binaries to the apps page "
"of the home Tomcat. No command: start the TUI.")
p.add_argument("--config", default=None, help="publish.toml (default: next to publish.py)")
p.add_argument("--no-color", action="store_true")
p.add_argument("-V", "--version", action="version", version=__version__)
sub = p.add_subparsers(dest="cmd")
sub.add_parser("list", help="local and published versions of the apps")
for name, hlp in (("publish", "preview and publish apps"), ("preview", "show what a publish would change")):
s = sub.add_parser(name, help=hlp)
s.add_argument("apps", nargs="*")
s.add_argument("-a", "--all", action="store_true", help="every app with a newer local build")
s.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation")
s.add_argument("-n", "--dry-run", action="store_true", default=name == "preview")
s.add_argument("-m", "--notes", help="what's new in this version (\\n for new lines)")
s.add_argument("--notes-file", help="what's new, from a file")
s.add_argument("--force", action="store_true", help="publish an older build, overwrite changed files")
s.add_argument("--no-build", action="store_true")
s.add_argument("--no-prune", action="store_true", help="keep versions beyond `keep`")
s.add_argument("--no-meta", action="store_true", help="do not upload the description and the icon")
s = sub.add_parser("versions", help="published packages of an app")
s.add_argument("app")
s = sub.add_parser("remove", help="unpublish a version (all its packages and notes)")
s.add_argument("app")
s.add_argument("version")
s.add_argument("-y", "--yes", action="store_true")
s = sub.add_parser("add", help="add a local project to publish.toml")
s.add_argument("path")
s.add_argument("--name")
s.add_argument("-y", "--yes", action="store_true")
sub.add_parser("check", help="validate publish.toml")
sub.add_parser("tui", help="start the TUI (default)")
args = p.parse_args(argv)
global COLOR
COLOR = sys.stdout.isatty() and not args.no_color
cfg_path = Path(args.config) if args.config else Path(__file__).resolve().parent.parent / "publish.toml"
try:
cfg = config.load(cfg_path)
except (OSError, config.ConfigError) as e:
print(f"config: {e}", file=sys.stderr)
return 1
if args.cmd in (None, "tui"):
from .tui import run
return run(cfg)
if args.cmd == "check":
print(f"{cfg_path}: {len(cfg.apps)} apps, OK")
return 0
handlers = {"list": cmd_list, "publish": cmd_publish, "preview": cmd_publish, "versions": cmd_versions,
"remove": cmd_remove, "add": cmd_add}
try:
return handlers[args.cmd](cfg, args) or 0
except (config.ConfigError, pipeline.PublishError, remote.RemoteError, OSError) as e:
print(f"error: {e}", file=sys.stderr)
return 1
except KeyboardInterrupt:
return 130
@@ -0,0 +1,187 @@
"""publish.toml: server settings and the registry of published apps."""
from __future__ import annotations
import os
import re
import tomllib
from dataclasses import dataclass, field, fields
from pathlib import Path
# <AppName> as apps.jsp groups it: no "_<digit>", "-v<digit>", ... inside (that starts the version)
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.\-]{0,63}$")
# Version lines in build files: versionName = "1.2.0" (gradle), Version = "0.10.0" (Go), version = "0.1.0"
DEFAULT_VERSION_RE = r"""(?:versionName|Version|version)\s*[=:]?\s*["']([0-9][^"']*)["']"""
class ConfigError(ValueError):
pass
@dataclass
class Server:
host: str = "192.168.88.60"
user: str = "das"
port: int = 22
# Package storage of apps.jsp: only download.jsp serves it
apps_dir: str = "/home/das/app/docker/tomcat/data/webapps/ttt/WEB-INF/apps"
# Requested on the server after an upload; the file name is appended (URL-encoded)
check_url: str = "http://localhost:8010/download.jsp?f="
state_dir: str = "/home/das/app_deploy"
keep: int = 3 # versions of each package type per app; 0 = keep all
local_roots: list[str] = field(default_factory=lambda: ["~/Dev/Projects"])
@property
def target(self) -> str:
return f"{self.user}@{self.host}"
@dataclass
class App:
name: str
src: str
# globs relative to src; each one gives one package (the newest match)
packages: list[str] = field(default_factory=list)
# version for files without _v<version> in their name, and a staleness check for the others
version_file: str = ""
version_regex: str = ""
build: str = ""
build_env: dict[str, str] = field(default_factory=dict)
# local files uploaded as <name>.txt (title + description) and <name>.png|.webp (icon)
info: str = ""
icon: str = ""
keep: int = -1 # -1 = server keep
@property
def src_path(self) -> Path:
return Path(os.path.expanduser(self.src))
def local(self, rel: str) -> Path:
p = Path(os.path.expanduser(rel))
return p if p.is_absolute() else self.src_path / p
@dataclass
class Config:
path: Path
server: Server
apps: list[App]
def app(self, name: str) -> App:
for a in self.apps:
if a.name.lower() == name.lower():
return a
raise ConfigError(f"unknown app: {name}")
def keep(self, app: App) -> int:
return self.server.keep if app.keep < 0 else app.keep
def _check_types(obj, data: dict, where: str):
known = {f.name: f for f in fields(obj)}
for k, v in data.items():
if k not in known:
raise ConfigError(f"{where}: unknown key '{k}'")
default = getattr(obj, k)
if isinstance(default, bool):
ok = isinstance(v, bool)
elif isinstance(default, int):
ok = isinstance(v, int) and not isinstance(v, bool)
elif isinstance(default, list):
ok = isinstance(v, list) and all(isinstance(x, str) for x in v)
elif isinstance(default, dict):
ok = isinstance(v, dict) and all(isinstance(x, str) for x in v.values())
else:
ok = isinstance(v, str)
if not ok:
raise ConfigError(f"{where}: '{k}' has wrong type")
setattr(obj, k, v)
def validate_server(s: Server) -> None:
d = s.apps_dir.rstrip("/")
if not d.startswith("/") or d.count("/") < 3 or ".." in Path(d).parts:
raise ConfigError(f"[server]: unsafe apps_dir '{s.apps_dir}'")
if s.keep < 0:
raise ConfigError("[server]: keep must be >= 0")
def validate_app(app: App) -> None:
w = f"app '{app.name}'"
if not NAME_RE.match(app.name) or re.search(r"[_-]v?\d", app.name):
raise ConfigError(f"{w}: bad name (letters, digits, '_.-'; no '_<digit>' or '_v<digit>')")
if not app.src:
raise ConfigError(f"{w}: src is required")
if not app.packages:
raise ConfigError(f"{w}: packages (globs relative to src) are required")
for g in app.packages:
if not g.strip() or ".." in Path(g).parts:
raise ConfigError(f"{w}: bad package glob '{g}'")
if app.version_regex:
try:
if re.compile(app.version_regex).groups < 1:
raise ConfigError(f"{w}: version_regex needs a (group)")
except re.error as e:
raise ConfigError(f"{w}: version_regex: {e}") from None
if app.icon and not app.icon.lower().endswith((".png", ".webp")):
raise ConfigError(f"{w}: icon must be .png or .webp")
if app.keep < -1:
raise ConfigError(f"{w}: keep must be >= 0")
def load(path: str | Path) -> Config:
path = Path(path)
with open(path, "rb") as f:
try:
data = tomllib.load(f)
except tomllib.TOMLDecodeError as e:
raise ConfigError(f"{path}: {e}") from None
server = Server()
_check_types(server, data.get("server", {}), "[server]")
validate_server(server)
apps, seen = [], set()
for i, raw in enumerate(data.get("app", [])):
raw = dict(raw)
try:
app = App(name=raw.pop("name"), src=raw.pop("src"))
except KeyError as e:
raise ConfigError(f"app #{i + 1}: missing {e}") from None
_check_types(app, raw, f"app '{app.name}'")
validate_app(app)
if app.name.lower() in seen:
raise ConfigError(f"duplicate app '{app.name}'")
seen.add(app.name.lower())
apps.append(app)
return Config(path, server, apps)
def _toml_value(v) -> str:
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, int):
return str(v)
if isinstance(v, list):
return "[" + ", ".join(_toml_value(x) for x in v) + "]"
if isinstance(v, dict):
return "{ " + ", ".join(f"{k} = {_toml_value(x)}" for k, x in v.items()) + " }"
s = str(v).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
return f'"{s}"'
def app_to_toml(app: App) -> str:
"""[[app]] block with the non-default keys only."""
blank = App(name="", src="")
lines = ["[[app]]"]
for f in fields(app):
v = getattr(app, f.name)
if f.name in ("name", "src") or v != getattr(blank, f.name):
lines.append(f"{f.name} = {_toml_value(v)}")
return "\n".join(lines) + "\n"
def append_apps(path: Path, apps: list[App]) -> None:
"""Append blocks to the file: hand-written comments and order are kept."""
text = path.read_text() if path.exists() else ""
for a in apps:
validate_app(a)
text = text.rstrip("\n") + "\n\n" + app_to_toml(a)
path.write_text(text)
@@ -0,0 +1,81 @@
"""Guess an [[app]] block for a local project: Android (+ electron desktop), Go, or any dir with builds."""
from __future__ import annotations
import os
import re
from pathlib import Path
from . import packages
from .config import App
_ROOT_NAME = re.compile(r"""rootProject\.name\s*=\s*["']([^"']+)["']""")
_GO_VERSION = re.compile(r'Version\s*=\s*"')
def _rel(p: Path, base: Path) -> str:
return str(p.relative_to(base))
def _home(p: Path) -> str:
home = Path.home()
return "~/" + str(p.relative_to(home)) if p.is_relative_to(home) else str(p)
def _site_name(raw: str) -> str:
"""A name apps.jsp groups correctly: no spaces, no _<digit>."""
name = re.sub(r"[^A-Za-z0-9_.\-]+", "", raw)
return re.sub(r"[_-]v?(?=\d)", "", name) or "App"
def _name_from_builds(files: list[Path]) -> str:
for f in files:
m = packages.LOCAL_VERSION.search(f.name)
if m and m.start() > 0:
return f.name[:m.start()]
return ""
def _first(base: Path, *names: str) -> Path | None:
for n in names:
if (base / n).is_file():
return base / n
return None
def new_app(path: Path, name: str | None = None) -> App:
path = path.expanduser().resolve()
# no build command is guessed: Android Studio signs into app/release, gradle into app/build/outputs
globs, builds, version_file = [], [], ""
gradle = _first(path, "app/build.gradle.kts", "app/build.gradle")
if gradle:
version_file = _rel(gradle, path)
# both places of a signed release APK: the best build of the two is published
for d in ("app/release", "app/build/outputs/apk/release"):
globs.append(f"{d}/*.apk")
builds += sorted((path / d).glob("*.apk"))
dist = path / "desktop/dist"
if dist.is_dir():
for ext in (".AppImage", ".deb", ".rpm", ".exe", ".msi", ".dmg"):
if any(dist.glob(f"*{ext}")):
globs.append(f"desktop/dist/*{ext}")
else:
go_version = next((p for p in path.glob("internal/version/*.go")
if _GO_VERSION.search(p.read_text(errors="replace"))), None)
if go_version:
version_file = _rel(go_version, path)
elif (path / "pyproject.toml").is_file():
version_file = "pyproject.toml"
for d in ("build", "dist", "bin", "release"):
for f in sorted((path / d).glob("*")) if (path / d).is_dir() else []:
if f.is_file() and packages.kind_of(f.name, os.access(f, os.X_OK)):
builds.append(f)
k = packages.kind_of(f.name)
globs.append(f"{d}/*{k.ext}" if k and k.ext else f"{d}/{f.name}")
globs = list(dict.fromkeys(globs))
if not name:
settings = _first(path, "settings.gradle.kts", "settings.gradle")
m = _ROOT_NAME.search(settings.read_text(errors="replace")) if settings else None
name = _name_from_builds(builds) or (m.group(1) if m else "") or path.name
return App(name=_site_name(name), src=_home(path), packages=globs, version_file=version_file)
@@ -0,0 +1,152 @@
"""Package types and names, the same rules as WEB-INF/incl/packages.jspf and apps.jsp on the server."""
from __future__ import annotations
import glob
import os
import re
from dataclasses import dataclass
from pathlib import Path
from .config import DEFAULT_VERSION_RE, App
@dataclass(frozen=True)
class Kind:
ext: str # as written in published names; "" = executable binary
label: str
@property
def executable(self) -> bool:
return self.ext in ("", ".AppImage")
# Order of apps.jsp download buttons. ".tar.gz" is a compound extension, so it must stay last:
# kind_of() returns the first endswith match and nothing else ends in ".tar.gz".
KINDS = (Kind(".apk", "APK"), Kind(".AppImage", "AppImage"), Kind(".deb", "DEB"), Kind(".rpm", "RPM"),
Kind(".exe", "EXE"), Kind(".msi", "MSI"), Kind(".dmg", "DMG"), Kind(".tar.gz", "tar.gz"))
BINARY = Kind("", "binary")
NOT_PACKAGE = re.compile(r"(?i).*\.(txt|md|png|webp|jpe?g|svg|sh|jsp|html?|json|ya?ml|zip|log)$")
# Server side: apps.jsp PKG_NAME
SITE_NAME = re.compile(r"(?i)^(.+?)[_-]v?(\d+(?:\.\d+)*[\w.\-]*)$")
# Local build names: Foo_v1.2.0.apk, Foo_v1.2.0-release.apk, Foo-1.5-release.apk, Foo_v0.7.0_linux_x86_64.AppImage
LOCAL_VERSION = re.compile(r"[_-]v(\d+(?:\.\d+)*)|[_-](\d+(?:\.\d+)+)")
def kind_of(name: str, executable: bool = True) -> Kind | None:
if name.startswith("."): # also rsync temp files of an upload in progress
return None
lower = name.lower()
for k in KINDS:
if lower.endswith(k.ext.lower()):
return k
return BINARY if executable and not NOT_PACKAGE.match(name) else None
def version_key(v: str) -> tuple[int, ...]:
"""Numeric-aware, like compareVersions() in apps.jsp: 1.10.0 > 1.9.3, 1.0 == 1.0.0."""
parts = [int(x) for x in re.split(r"\D+", v) if x]
while parts and parts[-1] == 0:
parts.pop()
return tuple(parts)
def site_name(app: str, version: str, kind: Kind) -> str:
return f"{app}_v{version}{kind.ext}"
@dataclass
class Published:
"""A package file in apps_dir."""
file: str
app: str
version: str
kind: Kind
size: int = 0
mtime: float = 0.0
def parse_published(file: str, executable: bool, size: int = 0, mtime: float = 0.0) -> Published | None:
kind = kind_of(file, executable)
if kind is None:
return None
base = file[:len(file) - len(kind.ext)] if kind.ext else file
m = SITE_NAME.match(base)
return Published(file, m.group(1) if m else base, m.group(2) if m else "", kind, size, mtime)
@dataclass
class Local:
"""A local build chosen for publishing."""
path: Path
kind: Kind
version: str
glob: str
def site_name(self, app: str) -> str:
return site_name(app, self.version, self.kind)
class PackageError(ValueError):
pass
def file_version(path: Path) -> str:
stem = path.name
kind = kind_of(path.name)
if kind and kind.ext:
stem = stem[:len(stem) - len(kind.ext)]
m = LOCAL_VERSION.search(stem)
return (m.group(1) or m.group(2)) if m else ""
def source_version(app: App) -> str:
"""Version from app.version_file, '' when not configured."""
if not app.version_file:
return ""
path = app.local(app.version_file)
try:
text = path.read_text(errors="replace")
except OSError as e:
raise PackageError(f"version_file: {e}") from None
m = re.search(app.version_regex or DEFAULT_VERSION_RE, text)
if not m:
raise PackageError(f"no version found in {path}")
return m.group(1).strip()
def resolve(app: App) -> tuple[list[Local], list[str]]:
"""The best build of each package type over all globs (highest version, then the newest file),
and notes about missing or suspicious ones. Several globs of one type are alternative places
of the same build, e.g. app/release (Android Studio) and app/build/outputs/apk/release (gradle)."""
notes = []
src_version = source_version(app)
by_kind: dict[Kind, list[Local]] = {}
for pattern in app.packages:
for p in sorted(glob.glob(str(app.local(pattern)))):
path = Path(p)
if not path.is_file():
continue
kind = kind_of(path.name, os.access(path, os.X_OK))
if kind is not None:
by_kind.setdefault(kind, []).append(Local(path, kind, file_version(path) or src_version, pattern))
for pattern in app.packages:
expected = kind_of(Path(pattern).name)
if expected not in by_kind and not any(b.glob == pattern for bs in by_kind.values() for b in bs):
notes.append(f"no build matches {pattern}")
found = []
for kind in [k for k in (*KINDS, BINARY) if k in by_kind]:
builds = {b.path.resolve(): b for b in by_kind[kind]}.values() # globs may overlap
best = max(builds, key=lambda b: (version_key(b.version), b.path.stat().st_mtime))
if not best.version:
raise PackageError(f"{best.path.name}: no version in the name and no version_file")
twins = [b for b in builds if b is not best and version_key(b.version) == version_key(best.version)
and b.path.stat().st_size != best.path.stat().st_size]
if twins:
notes.append(f"{len(twins) + 1} different builds of {kind.label} v{best.version}: the newest is used, "
f"{best.path.relative_to(app.src_path) if best.path.is_relative_to(app.src_path) else best.path}")
if src_version and version_key(best.version) < version_key(src_version):
notes.append(f"{best.path.name} is v{best.version}, the source is v{src_version}: rebuild?")
found.append(best)
return found, notes
@@ -0,0 +1,407 @@
"""Publish steps: preview() builds a Plan (nothing changes on the server), execute() applies it."""
from __future__ import annotations
import os
import hashlib
import subprocess
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from urllib.parse import quote
from . import packages, remote
from .config import App, Config
from .packages import Kind, Local, Published, version_key
# Das OS package: /usr/lib is read-only, so the history goes to the user's state dir
HISTORY = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") / "publisher" / "history.log"
# Plan item actions; the first five change the server
NEW, REPLACE, META, NOTES, DELETE, SAME, SKIP = "new", "replace", "meta", "notes", "delete", "same", "skip"
CHANGING = (NEW, REPLACE, META, NOTES, DELETE)
class PublishError(RuntimeError):
pass
@dataclass
class Options:
build: bool = True
force: bool = False # publish an older version, overwrite a published file with other content
prune: bool = True # delete versions beyond keep
meta: bool = True # upload changed <App>.txt / icon
notes: str = "" # "what's new" of the published version
@dataclass
class Item:
action: str
target: str # file name in apps_dir
local: Path | None = None
kind: Kind | None = None
version: str = ""
reason: str = ""
@property
def is_package(self) -> bool:
return self.kind is not None
def describe(self, app: App) -> str:
"""'<local file, relative to the project> -> <target>' and size/reason, for plan listings."""
src = ""
if self.local:
rel = self.local.relative_to(app.src_path) if self.local.is_relative_to(app.src_path) else self.local
src = f"{rel} -> " if str(rel) != self.target else ""
size = f" ({self.local.stat().st_size / 1048576:.1f} MB)" if self.local and self.is_package else ""
return f"{src}{self.target}{size}" + (f" [{self.reason}]" if self.reason else "")
@dataclass
class Plan:
cfg: Config
app: App
opts: Options
items: list[Item] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
git_rev: str = ""
@property
def server(self):
return self.cfg.server
@property
def changes(self) -> list[Item]:
return [i for i in self.items if i.action in CHANGING]
@dataclass
class Result:
ok: bool
message: str
@dataclass
class RemoteState:
# name -> (size, mtime, executable)
files: dict[str, tuple[int, float, bool]] = field(default_factory=dict)
hashes: dict[str, str] = field(default_factory=dict)
def published(self, app: str | None = None) -> list[Published]:
"""Packages of an app (all packages for None), grouped case-insensitively like apps.jsp."""
out = []
for name, (size, mtime, ex) in self.files.items():
p = packages.parse_published(name, ex, size, mtime)
if p and (app is None or p.app.lower() == app.lower()):
out.append(p)
return out
_STATE = r'''
[ -d "$DIR" ] || { echo "no such dir: $DIR" >&2; exit 3; }
find "$DIR" -maxdepth 1 -type f -printf 'F %s %T@ %m %f\n'
cd "$DIR" && for n in "${HASH[@]}"; do [ -f "$n" ] && sha256sum -- "$n" | sed 's/^/H /'; done
true
'''
def remote_state(cfg: Config, hash_names: list[str] = ()) -> RemoteState:
out = remote.run(cfg.server, _STATE, DIR=cfg.server.apps_dir, HASH=list(hash_names))
st = RemoteState()
for line in out.splitlines():
if line.startswith("F "):
_, size, mtime, mode, name = line.split(" ", 4)
st.files[name] = (int(size), float(mtime), int(mode, 8) & 0o111 != 0)
elif line.startswith("H "):
_, sha, name = line.split(None, 2)
st.hashes[name] = sha
return st
def sha256(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def git_rev(src: Path) -> str:
try:
rev = subprocess.run(["git", "-C", str(src), "rev-parse", "--short", "HEAD"], capture_output=True,
text=True, timeout=10).stdout.strip()
if not rev:
return ""
dirty = subprocess.run(["git", "-C", str(src), "status", "--porcelain"], capture_output=True, text=True,
timeout=10).stdout.strip()
return rev + ("-dirty" if dirty else "")
except (OSError, subprocess.SubprocessError):
return ""
def meta_files(app: App) -> list[tuple[Path, str]]:
"""(local file, name in apps_dir) of the description and the icon."""
out = []
if app.info:
out.append((app.local(app.info), f"{app.name}.txt"))
if app.icon:
icon = app.local(app.icon)
out.append((icon, f"{app.name}{icon.suffix.lower()}"))
return out
def notes_name(app: str, version: str) -> str:
return f"{app}_v{version}.txt"
def prune(published: list[Published], keep: int) -> list[Published]:
"""Packages beyond the newest `keep` versions of each type."""
if keep <= 0:
return []
out = []
for kind in {p.kind for p in published}:
same = sorted((p for p in published if p.kind == kind), key=lambda p: (version_key(p.version), p.mtime),
reverse=True)
out += same[keep:]
return out
def preview(cfg: Config, app: App, opts: Options, log=print) -> Plan:
src = app.src_path
if not src.exists():
raise PublishError(f"local source not found: {src}")
if opts.build and app.build:
log(f"== build: {app.build}")
remote.run_local(app.build, src if src.is_dir() else src.parent, log, app.build_env)
try:
builds, notes = packages.resolve(app)
except packages.PackageError as e:
raise PublishError(str(e)) from None
if not builds:
raise PublishError("nothing to publish: " + "; ".join(notes))
meta = [(p, n) for p, n in meta_files(app) if opts.meta]
for p, _ in meta:
if not p.is_file():
notes.append(f"no local {p}")
meta = [(p, n) for p, n in meta if p.is_file()]
log(f"== probe {cfg.server.target}:{cfg.server.apps_dir}")
st = remote_state(cfg, [b.site_name(app.name) for b in builds] + [n for _, n in meta])
published = st.published(app.name)
plan = Plan(cfg, app, opts, notes=notes, git_rev=git_rev(src if src.is_dir() else src.parent))
for b in builds:
plan.items.append(_package_item(app, b, st, published, opts))
added = [i for i in plan.items if i.action in (NEW, REPLACE)]
for path, name in meta:
if st.hashes.get(name) != sha256(path):
plan.items.append(Item(META, name, path, reason="update" if name in st.files else "new"))
if opts.notes.strip():
for v in sorted({b.version for b in builds}, key=version_key):
name = notes_name(app.name, v)
plan.items.append(Item(NOTES, name, version=v, reason="update" if name in st.files else "new"))
if opts.prune:
after = [p for p in published if p.file not in {i.target for i in added}]
after += [Published(i.target, app.name, i.version, i.kind, mtime=float("inf")) for i in added]
gone = prune(after, cfg.keep(app))
for p in gone:
plan.items.append(Item(DELETE, p.file, kind=p.kind, version=p.version, reason=f"keep {cfg.keep(app)}"))
left = {version_key(p.version) for p in after if p not in gone}
for v in sorted({p.version for p in gone}, key=version_key):
n = notes_name(app.name, v)
if version_key(v) not in left and n in st.files:
plan.items.append(Item(DELETE, n, version=v, reason="notes of a deleted version"))
if not added:
plan.notes.append("no new packages" + ("" if opts.force else " (--force publishes older or changed builds)"))
if plan.git_rev.endswith("-dirty"):
plan.notes.append("working tree has uncommitted changes")
return plan
def _package_item(app: App, b: Local, st: RemoteState, published: list[Published], opts: Options) -> Item:
target = b.site_name(app.name)
item = Item(NEW, target, b.path, b.kind, b.version)
if target in st.files:
if st.hashes.get(target) == sha256(b.path):
item.action, item.reason = SAME, "already published"
elif opts.force:
item.action, item.reason = REPLACE, "same name, other content"
else:
item.action, item.reason = SKIP, "published with other content: --force overwrites"
return item
newest = max((p for p in published if p.kind == b.kind), key=lambda p: version_key(p.version), default=None)
if newest and version_key(b.version) <= version_key(newest.version):
what = "older than" if version_key(b.version) < version_key(newest.version) else "same version as"
if opts.force:
item.reason = f"{what} {newest.file}"
else:
item.action, item.reason = SKIP, f"{what} {newest.file}"
return item
_WRITE = r'''
umask 022
printf '%s\n' "$TEXT" > "$DIR/.$NAME.tmp" && mv -f "$DIR/.$NAME.tmp" "$DIR/$NAME" && echo "wrote $NAME"
'''
_DELETE = r'''
cd "$DIR" || exit 1
for n in "${NAMES[@]}"; do rm -f -- "$n" && echo "deleted $n"; done
'''
_CHECK = r'''
rc=0
for i in "${!URLS[@]}"; do
h=$(curl -sI --max-time 15 "${URLS[$i]}" | tr -d '\r')
code=$(printf '%s\n' "$h" | head -1 | cut -d' ' -f2)
len=$(printf '%s\n' "$h" | grep -i '^content-length:' | tail -1 | awk '{print $2}')
if [ "$code" = 200 ] && [ "$len" = "${SIZES[$i]}" ]; then
echo "${NAMES[$i]}: HTTP 200, $len bytes"
else
echo "ERROR: ${NAMES[$i]}: HTTP ${code:-none}, ${len:-?} bytes (expected ${SIZES[$i]})"; rc=1
fi
done
exit $rc
'''
def _delete(cfg: Config, names: list[str], log) -> None:
if any("/" in n or n.startswith(".") or not n for n in names):
raise PublishError(f"refusing to delete {names}")
remote.run(cfg.server, _DELETE, log, DIR=cfg.server.apps_dir, NAMES=names)
def check(cfg: Config, files: list[tuple[str, int]], log) -> bool:
"""The site serves each file with the right size (asked on the server)."""
if not files or not cfg.server.check_url:
return True
try:
remote.run(cfg.server, _CHECK, log, URLS=[cfg.server.check_url + quote(n) for n, _ in files],
NAMES=[n for n, _ in files], SIZES=[s for _, s in files])
except remote.RemoteError:
return False
return True
def _record(cfg: Config, app: App, action: str, result: str, files: list[str], rev: str, log) -> None:
ts = datetime.now().isoformat(timespec="seconds")
line = f"{ts} {app.name} {action} {result} rev={rev or '-'} {','.join(files) or '-'}"
try:
remote.run(cfg.server, 'mkdir -p "$STATE" && printf "%s\\n" "$LINE" >> "$STATE/publish.log"',
STATE=cfg.server.state_dir, LINE=line)
except remote.RemoteError as e:
log(f"WARN: publish.log not written: {e}")
HISTORY.parent.mkdir(parents=True, exist_ok=True)
with open(HISTORY, "a") as f:
f.write(line + "\n")
def execute(plan: Plan, log=print) -> Result:
cfg, app, d = plan.cfg, plan.app, plan.server.apps_dir
done: list[str] = []
try:
# notes first: the page shows them together with the new package
for i in plan.items:
if i.action == NOTES:
remote.run(cfg.server, _WRITE, log, DIR=d, NAME=i.target, TEXT=plan.opts.notes.strip())
done.append(i.target)
for i in plan.items:
if i.action in (NEW, REPLACE, META):
size = i.local.stat().st_size
log(f"== upload {i.local.name} -> {i.target} ({size / 1048576:.1f} MB)")
mode = "755" if i.kind and i.kind.executable else "644"
remote.upload(cfg.server, str(i.local), f"{d}/{i.target}", mode, log)
done.append(i.target)
gone = [i.target for i in plan.items if i.action == DELETE]
if gone:
log("== prune")
_delete(cfg, gone, log)
done += [f"-{n}" for n in gone]
uploaded = [(i.target, i.local.stat().st_size) for i in plan.items if i.action in (NEW, REPLACE)]
ok = True
if uploaded and cfg.server.check_url:
log("== check")
ok = check(cfg, uploaded, log)
except (remote.RemoteError, PublishError, OSError) as e:
log(f"ERROR: {e}")
_record(cfg, app, "publish", "FAILED", done, plan.git_rev, log)
return Result(False, str(e))
_record(cfg, app, "publish", "ok" if ok else "UNCHECKED", done, plan.git_rev, log)
msg = "published" if ok else "published, but the site does not serve it correctly"
log(f"== {msg}")
return Result(ok, msg)
def versions(cfg: Config, app: App) -> list[Published]:
"""Published packages of the app, newest first."""
pub = remote_state(cfg).published(app.name)
return sorted(pub, key=lambda p: (version_key(p.version), -packages.KINDS.index(p.kind)
if p.kind in packages.KINDS else 0), reverse=True)
def remove(cfg: Config, app: App, version: str, log=print) -> Result:
"""Unpublish one version: all its packages and its notes."""
st = remote_state(cfg)
names = [p.file for p in st.published(app.name) if version_key(p.version) == version_key(version)]
if not names:
return Result(False, f"{app.name} v{version} is not published")
n = notes_name(app.name, version)
if n in st.files:
names.append(n)
try:
_delete(cfg, names, log)
except (remote.RemoteError, PublishError) as e:
log(f"ERROR: {e}")
return Result(False, str(e))
_record(cfg, app, "remove", "ok", [f"-{x}" for x in names], "", log)
return Result(True, f"removed {', '.join(names)}")
@dataclass
class Status:
state: str # current | newer | unpublished | older | no build | error
local: str = "" # "apk 1.2.0, AppImage 1.2.0"
remote: str = ""
def _summary(pairs: list[tuple[Kind, str]]) -> str:
return ", ".join(f"{k.label} {v}" for k, v in sorted(pairs, key=lambda x: version_key(x[1]), reverse=True))
def status_all(cfg: Config) -> tuple[dict[str, Status], list[str]]:
"""name -> Status (versions only, contents are compared by preview), and published apps
that are not in publish.toml."""
st = remote_state(cfg)
pub = st.published()
out = {}
for a in cfg.apps:
mine = [p for p in pub if p.app.lower() == a.name.lower()]
latest: dict[Kind, Published] = {}
for p in mine:
if p.kind not in latest or version_key(p.version) > version_key(latest[p.kind].version):
latest[p.kind] = p
rem = _summary([(k, p.version) for k, p in latest.items()])
try:
builds, _ = packages.resolve(a)
except packages.PackageError as e:
out[a.name] = Status("error", str(e), rem)
continue
loc = _summary([(b.kind, b.version) for b in builds])
if not builds:
state = "no build"
elif not latest:
state = "unpublished"
else:
cmp = [version_key(b.version) > version_key(latest[b.kind].version) if b.kind in latest else True
for b in builds]
older = [b.kind in latest and version_key(b.version) < version_key(latest[b.kind].version)
for b in builds]
state = "newer" if any(cmp) else ("older" if any(older) else "current")
out[a.name] = Status(state, loc, rem)
known = {a.name.lower() for a in cfg.apps}
others = sorted({p.app for p in pub if p.app.lower() not in known}, key=str.lower)
return out, others
@@ -0,0 +1,74 @@
"""ssh / rsync wrappers. One multiplexed ssh connection is reused by all calls of a run."""
from __future__ import annotations
import os
import shlex
import subprocess
from typing import Callable
from .config import Server
Log = Callable[[str], None]
class RemoteError(RuntimeError):
pass
def ssh_opts(server: Server) -> list[str]:
return ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-o", "ControlMaster=auto",
"-o", f"ControlPath={os.path.expanduser('~/.ssh')}/cm-%r@%h:%p", "-o", "ControlPersist=120",
"-p", str(server.port)]
def _stream(cmd: list[str], log: Log | None, stdin: str | None = None, cwd=None, env=None) -> tuple[int, str]:
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE if stdin is not None else subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=cwd, env=env,
errors="replace")
if stdin is not None:
proc.stdin.write(stdin)
proc.stdin.close()
out = []
for line in proc.stdout:
line = line.rstrip("\n")
out.append(line)
if log:
log(line)
return proc.wait(), "\n".join(out)
def shell_vars(**values) -> str:
"""Bash assignments for passing values into a remote script."""
lines = []
for k, v in values.items():
if isinstance(v, (list, tuple)):
lines.append(f"{k}=({' '.join(shlex.quote(str(x)) for x in v)})")
else:
lines.append(f"{k}={shlex.quote(str(v))}")
return "\n".join(lines) + "\n"
def run(server: Server, script: str, log: Log | None = None, check: bool = True, **values) -> str:
"""Run a bash script on the server; values become shell variables in front of it."""
body = "set -uo pipefail\n" + shell_vars(**values) + script
rc, out = _stream(["ssh", *ssh_opts(server), server.target, "bash", "-s"], log, stdin=body)
if check and rc != 0:
raise RemoteError(f"remote command failed (exit {rc})" + (f": {out.splitlines()[-1]}" if out else ""))
return out
def run_local(cmd: str, cwd, log: Log | None, env: dict[str, str] | None = None) -> None:
full_env = {**os.environ, **{k: os.path.expandvars(os.path.expanduser(v)) for k, v in (env or {}).items()}}
rc, _ = _stream(["bash", "-c", cmd], log, cwd=cwd, env=full_env)
if rc != 0:
raise RemoteError(f"local command failed (exit {rc}): {cmd}")
def upload(server: Server, src: str, dest: str, mode: str, log: Log | None = None) -> None:
"""Copy one file. rsync writes a hidden temp file and renames it, so the site never lists a partial
package; the server time becomes the publish time (the "new" badge of apps.jsp)."""
cmd = ["rsync", "--checksum", "--perms", f"--chmod=F{mode}", "--mkpath", "--out-format=sent %n %b bytes",
"-e", shlex.join(["ssh", *ssh_opts(server)]), src, f"{server.target}:{dest}"]
rc, out = _stream(cmd, log)
if rc != 0:
raise RemoteError(f"rsync failed (exit {rc})" + (f": {out.splitlines()[-1]}" if out else ""))
@@ -0,0 +1,466 @@
"""Textual UI: app list with local and published versions, preview → confirm → publish, remove versions, add."""
from __future__ import annotations
import os
import shlex
import subprocess
from pathlib import Path
from rich.markup import escape
from rich.text import Text
from textual import on, work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.coordinate import Coordinate
from textual.screen import ModalScreen
from textual.theme import Theme
from textual.widgets import Button, Checkbox, DataTable, Footer, Input, Label, OptionList, RichLog, Static, TextArea
from . import __version__, config, detect, pipeline, remote
from .config import Config
from .packages import Published
from .pipeline import Options, Plan
# Classic CGA palette of Norton / Midnight Commander (same as Deployer)
BLACK, BLUE, CYAN, GRAY, WHITE, YELLOW = "#000000", "#0000AA", "#00AAAA", "#AAAAAA", "#FFFFFF", "#FFFF55"
MIDNIGHT = Theme(
name="midnight", primary=CYAN, secondary=BLUE, accent=YELLOW, warning=YELLOW, error="#FF5555",
success="#55FF55", foreground=GRAY, background=BLUE, surface=BLUE, panel=CYAN, dark=True,
variables={
"border": GRAY, "border-blurred": GRAY,
"block-cursor-foreground": BLACK, "block-cursor-background": CYAN, "block-cursor-text-style": "none",
"block-cursor-blurred-foreground": BLACK, "block-cursor-blurred-background": CYAN,
"block-cursor-blurred-text-style": "none", "block-hover-background": BLUE,
"footer-background": BLACK, "footer-foreground": WHITE, "footer-key-foreground": WHITE,
"footer-key-background": BLACK, "footer-description-foreground": BLACK,
"footer-description-background": CYAN, "footer-item-background": BLACK,
"input-cursor-background": BLACK, "input-cursor-foreground": CYAN,
"scrollbar": CYAN, "scrollbar-background": BLUE, "scrollbar-corner-color": BLUE,
},
)
STATE_STYLE = {"newer": "bold #55FF55", "current": "#55FF55", "unpublished": "#FFFF55", "older": "#FFFF55",
"error": "bold #FF5555", "no build": GRAY}
ITEM_STYLE = {"new": ("+", "#55FF55"), "replace": ("~", "#FFFF55"), "meta": ("~", "#55FFFF"),
"notes": ("~", "#55FFFF"), "delete": ("-", "bold #FF5555"), "same": ("=", GRAY),
"skip": ("!", "#FFFF55")}
def line_text(line: str) -> Text:
style = ""
if line.startswith("== "):
style = "bold #55FFFF"
elif line.startswith(("ERROR", "error:")) or "failed" in line.lower():
style = "bold #FF5555"
elif line.startswith("WARN"):
style = "#FFFF55"
return Text(line, style=style)
def item_line(i: pipeline.Item, app: config.App) -> tuple[str, str]:
sign, style = ITEM_STYLE[i.action]
return f"{sign} {i.describe(app).replace(' -> ', ' → ', 1)}", style
class ConfirmPlan(ModalScreen[bool]):
BINDINGS = [Binding("escape,n", "dismiss(False)", "Cancel"), Binding("y", "dismiss(True)", "Publish")]
def __init__(self, plan: Plan):
super().__init__()
self.plan = plan
def compose(self) -> ComposeResult:
p = self.plan
body = Text()
body.append(f"{p.app.src}\n→ {p.server.target}:{p.server.apps_dir}\n", style="bold")
if p.git_rev:
body.append(f"git {p.git_rev}\n")
body.append("\n")
for i in p.items:
text, style = item_line(i, p.app)
body.append(text + "\n", style=style)
if p.opts.notes.strip():
body.append("\nWhat's new:\n", style="bold")
body.append(p.opts.notes.strip() + "\n")
for n in p.notes:
body.append(f"! {n}\n", style="bold #FFFF55")
with Vertical(id="dialog"):
yield Label(Text(f" Publish {p.app.name} "), id="dialog-title")
with VerticalScroll():
yield Static(body)
with Horizontal(id="buttons"):
yield Button("Publish (y)", id="yes", variant="success")
yield Button("Cancel (n)", id="no")
@on(Button.Pressed)
def pressed(self, e: Button.Pressed) -> None:
self.dismiss(e.button.id == "yes")
class Ask(ModalScreen[bool]):
BINDINGS = [Binding("escape,n", "dismiss(False)", "No"), Binding("y", "dismiss(True)", "Yes")]
def __init__(self, title: str, text: str):
super().__init__()
self.title_, self.text = title, text
def compose(self) -> ComposeResult:
with Vertical(id="dialog", classes="small"):
yield Label(Text(f" {self.title_} "), id="dialog-title")
with VerticalScroll():
yield Static(self.text)
with Horizontal(id="buttons"):
yield Button("Yes (y)", id="yes", variant="warning")
yield Button("No (n)", id="no")
@on(Button.Pressed)
def pressed(self, e: Button.Pressed) -> None:
self.dismiss(e.button.id == "yes")
class PickVersion(ModalScreen[str | None]):
BINDINGS = [Binding("escape", "dismiss(None)", "Cancel")]
def __init__(self, app_name: str, pub: list[Published]):
super().__init__()
self.app_name = app_name
# one line per version: all its packages are removed together
self.versions = list(dict.fromkeys(p.version for p in pub))
self.lines = [f"v{v:12} " + ", ".join(f"{p.kind.label} {p.size / 1048576:.1f} MB"
for p in pub if p.version == v) for v in self.versions]
def compose(self) -> ComposeResult:
with Vertical(id="dialog", classes="small"):
yield Label(Text(f" {self.app_name}: published versions (Enter removes) "), id="dialog-title")
yield OptionList(*self.lines)
@on(OptionList.OptionSelected)
def picked(self, e: OptionList.OptionSelected) -> None:
self.dismiss(self.versions[e.option_index])
class AddApp(ModalScreen[config.App | None]):
BINDINGS = [Binding("escape", "dismiss(None)", "Cancel")]
def compose(self) -> ComposeResult:
with Vertical(id="dialog"):
yield Label(" New app ", id="dialog-title")
yield Label("Local project dir:")
yield Input(placeholder="~/Dev/Projects/android/MyApp", id="path")
yield Label("Name on the site (empty = detect):")
yield Input(id="name")
yield Static("", id="toml")
with Horizontal(id="buttons"):
yield Button("Detect", id="detect")
yield Button("Add", id="add", variant="success")
yield Button("Cancel", id="cancel")
def build(self) -> config.App | None:
raw = self.query_one("#path", Input).value.strip()
path = Path(os.path.expanduser(raw)) if raw else None
if not path or not path.is_dir():
self.query_one("#toml", Static).update(Text("dir not found", style="bold #FF5555"))
return None
app = detect.new_app(path, self.query_one("#name", Input).value.strip() or None)
self.query_one("#toml", Static).update(Text(config.app_to_toml(app)))
return app
@on(Button.Pressed)
def pressed(self, e: Button.Pressed) -> None:
if e.button.id == "cancel":
self.dismiss(None)
elif e.button.id == "detect":
self.build()
else:
app = self.build()
if app:
try:
config.validate_app(app)
except config.ConfigError as err:
self.query_one("#toml", Static).update(Text(str(err), style="bold #FF5555"))
return
self.dismiss(app)
class PublisherApp(App):
TITLE = f"Publisher {__version__}"
CSS = """
Screen { background: $background; }
#top { height: 1fr; }
#apps { width: 3fr; border: double $border; border-title-color: $accent; }
#apps > .datatable--header { background: $panel; color: #000000; text-style: bold; }
#apps > .datatable--header-hover { background: $panel; color: #000000; }
#apps > .datatable--header-cursor { background: $panel; color: #000000; }
#side { width: 2fr; border: double $border; border-title-color: $accent; padding: 0 1; }
#info { height: auto; margin-bottom: 1; }
#side Checkbox { background: $background; border: none; padding: 0; height: 1; }
#side Label { margin-top: 1; }
#notes { height: 6; background: $background; border: round $border; }
#log { height: 14; border: double $border; border-title-color: $accent; background: $background; }
ModalScreen { align: center middle; background: $background 60%; }
#dialog { width: 100; max-width: 95%; height: 80%; background: $panel; color: #000000;
border: double #FFFFFF; padding: 0 1; }
#dialog.small { height: auto; max-height: 80%; }
#dialog VerticalScroll { height: 1fr; background: $background; color: $foreground; }
#dialog.small VerticalScroll { height: auto; max-height: 20; }
#dialog-title { width: 100%; content-align: center middle; color: #0000AA; text-style: bold; }
#dialog Input { margin-bottom: 1; }
#dialog OptionList { height: auto; max-height: 20; }
#toml { height: auto; background: $background; color: $foreground; }
#buttons { height: 3; align: center middle; }
#buttons Button { margin: 0 2; }
Toast { background: #000000; color: #FFFFFF; }
"""
BINDINGS = [
Binding("f2", "edit", "Edit"),
Binding("f3", "preview", "Preview"),
Binding("f5", "publish", "Publish"),
Binding("f6", "refresh", "Refresh"),
Binding("f7", "add", "Add"),
Binding("f8", "versions", "Versions"),
Binding("f10", "quit", "Quit"),
]
def __init__(self, cfg: Config):
super().__init__()
self.cfg = cfg
self.status: dict[str, pipeline.Status] = {}
self.busy = False
# ---------- layout ----------
def compose(self) -> ComposeResult:
with Horizontal(id="top"):
yield DataTable(id="apps", cursor_type="row", zebra_stripes=False)
with VerticalScroll(id="side"):
yield Static(id="info")
yield Checkbox("Build locally", True, id="o_build")
yield Checkbox("Upload description / icon", True, id="o_meta")
yield Checkbox("Delete versions beyond keep", True, id="o_prune")
yield Checkbox("Force: older build, overwrite", False, id="o_force")
yield Label("What's new in this version:")
yield TextArea(id="notes", soft_wrap=True)
yield RichLog(id="log", wrap=True, markup=False, max_lines=5000)
yield Footer()
def on_mount(self) -> None:
self.register_theme(MIDNIGHT)
self.theme = "midnight"
t = self.query_one("#apps", DataTable)
t.border_title = f"Apps → {self.cfg.server.target}"
t.add_columns("Name", "State", "Local", "Site")
self.query_one("#side").border_title = "Details / options"
self.query_one("#log").border_title = "Output"
self.fill_table()
self.action_refresh()
def fill_table(self) -> None:
t = self.query_one("#apps", DataTable)
row = t.cursor_row
t.clear()
for a in self.cfg.apps:
s = self.status.get(a.name, pipeline.Status("…"))
t.add_row(a.name, Text(s.state, style=STATE_STYLE.get(s.state, "")), s.local, s.remote, key=a.name)
if self.cfg.apps:
t.move_cursor(row=min(row, len(self.cfg.apps) - 1))
self.show_details()
def current(self) -> config.App | None:
t = self.query_one("#apps", DataTable)
if not self.cfg.apps or t.row_count == 0:
return None
key = t.coordinate_to_cell_key(Coordinate(t.cursor_row, 0)).row_key.value
return self.cfg.app(key)
@on(DataTable.RowHighlighted)
def show_details(self) -> None:
a = self.current()
info = self.query_one("#info", Static)
if not a:
info.update("No apps: F7 add one")
return
lines = [f"[b]{escape(a.name)}[/b]", f"src: {escape(a.src)}"]
lines += [f" {escape(g)}" for g in a.packages]
if a.version_file:
lines.append(f"version: {escape(a.version_file)}")
if a.build:
lines.append(f"build: {escape(a.build)}")
meta = ", ".join(x for x in (a.info, a.icon) if x)
if meta:
lines.append(f"meta: {escape(meta)}")
lines.append(f"keep: {self.cfg.keep(a) or 'all'}")
info.update("\n".join(lines))
self.query_one("#o_build", Checkbox).disabled = not a.build
def options(self) -> Options:
v = lambda i: self.query_one(f"#{i}", Checkbox).value # noqa: E731
return Options(build=v("o_build"), meta=v("o_meta"), prune=v("o_prune"), force=v("o_force"),
notes=self.query_one("#notes", TextArea).text)
# ---------- output ----------
def log_line(self, line: str) -> None:
self.query_one("#log", RichLog).write(line_text(line))
def tlog(self, line: str) -> None:
"""Log from a worker thread."""
self.call_from_thread(self.log_line, line)
def start(self) -> bool:
if self.busy:
self.notify("Another operation is running", severity="warning")
return False
self.busy = True
return True
def done(self) -> None:
self.busy = False
# ---------- actions ----------
def action_refresh(self) -> None:
self.refresh_status()
@work(thread=True, group="status", exclusive=True)
def refresh_status(self) -> None:
try:
st, others = pipeline.status_all(self.cfg)
except remote.RemoteError as e:
self.tlog(f"ERROR: status: {e}")
return
if others:
self.tlog(f"published, not in {self.cfg.path.name}: {', '.join(others)}")
self.call_from_thread(self._set_status, st)
def _set_status(self, st) -> None:
self.status = st
self.fill_table()
def action_preview(self) -> None:
a = self.current()
if a and self.start():
self.do_preview(a, self.options(), then_publish=False)
def action_publish(self) -> None:
a = self.current()
if a and self.start():
self.do_preview(a, self.options(), then_publish=True)
@work(thread=True, group="op")
def do_preview(self, a: config.App, opts: Options, then_publish: bool) -> None:
try:
self.tlog(f"== preview {a.name}")
plan = pipeline.preview(self.cfg, a, opts, log=self.tlog)
except (pipeline.PublishError, remote.RemoteError, OSError) as e:
self.tlog(f"ERROR: {e}")
self.call_from_thread(self.done)
return
for i in plan.items:
self.tlog(" " + item_line(i, a)[0])
for n in plan.notes:
self.tlog(f"WARN: {n}")
if not then_publish or not plan.changes:
if then_publish:
self.tlog("nothing to publish")
self.call_from_thread(self.done)
return
def answered(ok: bool | None) -> None:
if ok:
self.do_execute(plan)
else:
self.log_line("cancelled")
self.done()
self.call_from_thread(self.push_screen, ConfirmPlan(plan), answered)
@work(thread=True, group="op")
def do_execute(self, plan: Plan) -> None:
res = pipeline.execute(plan, log=self.tlog)
self.call_from_thread(self.after_execute, plan, res)
def after_execute(self, plan: Plan, res: pipeline.Result) -> None:
self.done()
self.notify(f"{plan.app.name}: {res.message}", severity="information" if res.ok else "error", timeout=8)
if res.ok:
self.query_one("#notes", TextArea).text = ""
self.action_refresh()
def action_versions(self) -> None:
a = self.current()
if a and self.start():
self.load_versions(a)
@work(thread=True, group="op")
def load_versions(self, a: config.App) -> None:
try:
pub = pipeline.versions(self.cfg, a)
except remote.RemoteError as e:
pub = []
self.tlog(f"ERROR: {e}")
self.call_from_thread(self._pick_version, a, pub)
def _pick_version(self, a: config.App, pub: list[Published]) -> None:
if not pub:
self.done()
self.notify(f"{a.name}: nothing published", severity="warning")
return
def picked(version: str | None) -> None:
if not version:
self.done()
return
def confirmed(ok: bool | None) -> None:
if ok:
self.do_remove(a, version)
else:
self.done()
self.push_screen(Ask("Remove", f"Delete {a.name} v{version} (all its packages and notes) "
"from the site?"), confirmed)
self.push_screen(PickVersion(a.name, pub), picked)
@work(thread=True, group="op")
def do_remove(self, a: config.App, version: str) -> None:
res = pipeline.remove(self.cfg, a, version, log=self.tlog)
self.tlog(f"== {res.message}")
self.call_from_thread(self._removed, a, res)
def _removed(self, a: config.App, res: pipeline.Result) -> None:
self.done()
self.notify(f"{a.name}: {res.message}", severity="information" if res.ok else "error", timeout=8)
self.action_refresh()
def action_edit(self) -> None:
if self.busy:
return
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "nano"
with self.suspend():
subprocess.call([*shlex.split(editor), str(self.cfg.path)])
self.reload()
def reload(self) -> None:
try:
self.cfg = config.load(self.cfg.path)
except (OSError, config.ConfigError) as e:
self.log_line(f"ERROR: config: {e} (old config kept)")
return
self.log_line(f"== config reloaded: {len(self.cfg.apps)} apps")
self.fill_table()
self.action_refresh()
def action_add(self) -> None:
def added(app: config.App | None) -> None:
if app:
if any(x.name.lower() == app.name.lower() for x in self.cfg.apps):
self.notify(f"{app.name} already exists", severity="error")
return
config.append_apps(self.cfg.path, [app])
self.log_line(f"== added {app.name}: review it with F2, then F3 preview")
self.reload()
self.push_screen(AddApp(), added)
def run(cfg: Config) -> int:
PublisherApp(cfg).run()
return 0
@@ -0,0 +1,20 @@
# Publisher registry. Edit by hand or append with `publish add <path>`.
#
# Packages land in apps_dir as <name>_v<version><ext> (the format of apps.jsp):
# .apk, .AppImage, .deb, .rpm, .exe, .msi, .dmg, .tar.gz, or no extension for an executable Linux binary.
# packages: globs relative to src; of all matches the best build of each package type is published
# (the highest version, then the newest file), so one type may come from several places
# version_file: version for builds without _v<version> in the file name (and a "rebuild?" check)
# version_regex: its (group); default finds versionName = "..", Version = "..", version = ".."
# info: local file uploaded as <name>.txt (first line: title, the rest: description)
# icon: local .png / .webp uploaded as <name>.png / .webp
# Common keys: build, build_env, keep (versions of each package type; server default)
[server]
host = "server.example.com"
user = "user"
apps_dir = "/home/user/app/docker/tomcat/data/webapps/ttt/WEB-INF/apps"
check_url = "http://localhost:8010/download.jsp?f="
state_dir = "/home/user/app_deploy"
keep = 3
local_roots = ["~/Projects"]
+217
View File
@@ -0,0 +1,217 @@
import os
from pathlib import Path
import pytest
from publisher import config, detect, packages, pipeline
from publisher.packages import BINARY, KINDS, Published
APK, APPIMAGE, TARGZ = KINDS[0], KINDS[1], KINDS[-1]
SERVER = """
[server]
host = "h"
apps_dir = "/srv/www/apps"
"""
def write(tmp_path: Path, body: str) -> Path:
p = tmp_path / "publish.toml"
p.write_text(SERVER + body)
return p
def touch(path: Path, data: bytes = b"x", exe: bool = False) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
if exe:
path.chmod(0o755)
return path
def test_load(tmp_path):
cfg = config.load(write(tmp_path, """
[[app]]
name = "Foo"
src = "~/x"
packages = ["app/release/*.apk"]
keep = 1
"""))
a = cfg.app("foo")
assert a.src_path == Path.home() / "x"
assert cfg.keep(a) == 1
assert a.local("/abs/f") == Path("/abs/f")
@pytest.mark.parametrize("body, msg", [
('name = "Foo_v1"\nsrc = "x"\npackages = ["a"]', "bad name"),
('name = "Foo 2"\nsrc = "x"\npackages = ["a"]', "bad name"),
('name = "Foo"\nsrc = "x"', "packages"),
('name = "Foo"\nsrc = "x"\npackages = ["../a"]', "glob"),
('name = "Foo"\nsrc = "x"\npackages = ["a"]\nicon = "i.jpg"', "icon"),
('name = "Foo"\nsrc = "x"\npackages = ["a"]\nversion_regex = "v.*"', "group"),
('name = "Foo"\nsrc = "x"\npackages = ["a"]\ntypo = 1', "unknown key"),
('name = "Foo"\nsrc = "x"\npackages = "a"', "wrong type"),
])
def test_validation(tmp_path, body, msg):
with pytest.raises(config.ConfigError, match=msg):
config.load(write(tmp_path, "[[app]]\n" + body))
def test_unsafe_apps_dir(tmp_path):
p = tmp_path / "publish.toml"
p.write_text('[server]\napps_dir = "/srv"\n')
with pytest.raises(config.ConfigError, match="apps_dir"):
config.load(p)
def test_append_round_trip(tmp_path):
p = write(tmp_path, "# keep me\n")
app = config.App(name="n", src="~/p", packages=["a/*.apk"], build_env={"JAVA_HOME": "~/jdk"}, build='echo "hi"')
config.append_apps(p, [app])
assert "# keep me" in p.read_text()
loaded = config.load(p).app("n")
assert loaded.build == 'echo "hi"' and loaded.build_env == {"JAVA_HOME": "~/jdk"}
@pytest.mark.parametrize("name, version", [
("Foo_v1.2.0.apk", "1.2.0"), ("Foo_v1.2.0-release.apk", "1.2.0"), ("Foo-1.5-release.apk", "1.5"),
("ExtMess_v0.7.0_linux_x86_64.AppImage", "0.7.0"), ("ExtMess_v0.7.0_linux_amd64.deb", "0.7.0"),
("Stylometer_v0.9.0_linux.tar.gz", "0.9.0"),
("app-release.apk", ""), ("multitext", ""),
])
def test_file_version(name, version):
assert packages.file_version(Path(name)) == version
@pytest.mark.parametrize("file, exe, app, version, kind", [
("DasWeather_v1.4.0.apk", False, "DasWeather", "1.4.0", APK),
("WhereAreYou_v1.1.0.AppImage", False, "WhereAreYou", "1.1.0", APPIMAGE),
("multitext_v0.6", True, "multitext", "0.6", BINARY),
("Foo_v1.0-release.apk", False, "Foo", "1.0-release", APK),
("Stilometer_Linux_v0.9.0.tar.gz", False, "Stilometer_Linux", "0.9.0", TARGZ),
])
def test_parse_published(file, exe, app, version, kind):
p = packages.parse_published(file, exe)
assert (p.app, p.version, p.kind) == (app, version, kind)
@pytest.mark.parametrize("file, exe", [("README.txt", True), ("Foo.png", False), ("multitext_v0.6", False),
(".Foo_v1.apk.Xy12", False), ("run.sh", True)])
def test_not_packages(file, exe):
assert packages.parse_published(file, exe) is None
def test_version_key():
assert packages.version_key("1.10.0") > packages.version_key("1.9.3")
assert packages.version_key("1.0") == packages.version_key("1.0.0")
assert packages.version_key("1.2.0-release") == packages.version_key("1.2.0")
def test_resolve(tmp_path):
touch(tmp_path / "app/release/Foo_v1.9.0.apk")
touch(tmp_path / "app/release/Foo_v1.10.0-release.apk")
touch(tmp_path / "build/foo", exe=True)
touch(tmp_path / "app/build.gradle.kts", b'android {\n versionName = "1.11.0"\n}\n')
app = config.App(name="Foo", src=str(tmp_path), packages=["app/release/*.apk", "build/foo", "dist/*.deb"],
version_file="app/build.gradle.kts")
builds, notes = packages.resolve(app)
assert [(b.site_name("Foo"), b.version) for b in builds] == [("Foo_v1.10.0.apk", "1.10.0"),
("Foo_v1.11.0", "1.11.0")]
assert any("rebuild?" in n for n in notes) and any("dist/*.deb" in n for n in notes)
def test_resolve_best_of_several_places(tmp_path):
touch(tmp_path / "app/release/Foo_v1.0.apk", b"old")
touch(tmp_path / "app/build/outputs/apk/release/Foo_v1.1.apk", b"new")
app = config.App(name="Foo", src=str(tmp_path),
packages=["app/release/*.apk", "app/build/outputs/apk/release/*.apk"])
builds, notes = packages.resolve(app)
assert [b.path.name for b in builds] == ["Foo_v1.1.apk"] and notes == []
def test_resolve_same_version_newest_wins(tmp_path):
a = touch(tmp_path / "app/release/Foo_v1.0.apk", b"studio")
b = touch(tmp_path / "app/build/outputs/apk/release/Foo_v1.0.apk", b"gradle!")
os.utime(a, (1000, 1000))
app = config.App(name="Foo", src=str(tmp_path), packages=["app/release/*.apk", "app/build/outputs/apk/release/*.apk"])
builds, notes = packages.resolve(app)
assert builds[0].path == b
assert any("2 different builds" in n and "app/build/outputs" in n for n in notes)
def test_resolve_needs_a_version(tmp_path):
touch(tmp_path / "build/foo", exe=True)
with pytest.raises(packages.PackageError, match="no version"):
packages.resolve(config.App(name="Foo", src=str(tmp_path), packages=["build/foo"]))
def test_prune():
pub = [Published(f"F_v{v}.apk", "F", v, APK) for v in ("1.0", "1.10", "1.9", "1.2")]
pub.append(Published("F_v1.0.AppImage", "F", "1.0", APPIMAGE))
assert sorted(p.file for p in pipeline.prune(pub, 2)) == ["F_v1.0.apk", "F_v1.2.apk"]
assert pipeline.prune(pub, 0) == []
def fake_state(monkeypatch, files: dict[str, tuple[int, bool]], hashes: dict[str, str]):
def state(cfg, hash_names=()):
st = pipeline.RemoteState({n: (s, 1.0, ex) for n, (s, ex) in files.items()})
st.hashes = {n: h for n, h in hashes.items() if n in hash_names}
return st
monkeypatch.setattr(pipeline, "remote_state", state)
monkeypatch.setattr(pipeline, "git_rev", lambda src: "")
def test_preview(tmp_path, monkeypatch):
apk = touch(tmp_path / "app/release/Foo_v1.3.0.apk", b"new")
img = touch(tmp_path / "dist/Foo_v1.3.0_linux_x86_64.AppImage", b"img")
info = touch(tmp_path / "site.txt", b"Foo\nabout")
fake_state(monkeypatch, {
"Foo_v1.0.0.apk": (1, False), "Foo_v1.1.0.apk": (1, False), "Foo_v1.2.0.apk": (1, False),
"Foo_v1.0.0.txt": (1, False), "Foo_v1.3.0.AppImage": (1, True), "Foo.txt": (1, False),
"Other_v9.apk": (1, False),
}, {"Foo_v1.3.0.AppImage": pipeline.sha256(img), "Foo.txt": pipeline.sha256(info)})
cfg = config.load(write(tmp_path, f'[[app]]\nname = "Foo"\nsrc = "{tmp_path}"\n'
'packages = ["app/release/*.apk", "dist/*.AppImage"]\ninfo = "site.txt"\n'))
plan = pipeline.preview(cfg, cfg.app("Foo"), pipeline.Options(notes="fixes"), log=lambda s: None)
got = {(i.action, i.target) for i in plan.items}
assert got == {("new", "Foo_v1.3.0.apk"), ("same", "Foo_v1.3.0.AppImage"), ("notes", "Foo_v1.3.0.txt"),
("delete", "Foo_v1.0.0.apk"), ("delete", "Foo_v1.0.0.txt")}
assert next(i for i in plan.items if i.target == "Foo_v1.3.0.apk").local == apk
def test_preview_older_and_changed(tmp_path, monkeypatch):
touch(tmp_path / "Foo_v1.0.apk", b"old")
touch(tmp_path / "Foo_v2.0.deb", b"changed")
fake_state(monkeypatch, {"Foo_v1.1.apk": (1, False), "Foo_v2.0.deb": (1, False)}, {"Foo_v2.0.deb": "0" * 64})
cfg = config.load(write(tmp_path, f'[[app]]\nname = "Foo"\nsrc = "{tmp_path}"\npackages = ["*.apk", "*.deb"]\n'))
plan = pipeline.preview(cfg, cfg.app("Foo"), pipeline.Options(), log=lambda s: None)
assert [i.action for i in plan.items] == ["skip", "skip"] and not plan.changes
plan = pipeline.preview(cfg, cfg.app("Foo"), pipeline.Options(force=True, prune=False), log=lambda s: None)
assert [i.action for i in plan.items] == ["new", "replace"]
def test_detect_android(tmp_path):
touch(tmp_path / "settings.gradle.kts", b'rootProject.name = "DasPressureAndMedicines"\n')
touch(tmp_path / "app/build.gradle.kts", b'versionName = "3.13.0"\n')
touch(tmp_path / "app/release/DasPressureMedicines_v3.13.0.apk")
touch(tmp_path / "desktop/dist/DasPressureMedicines_v3.13.0_linux_x86_64.AppImage", exe=True)
app = detect.new_app(tmp_path)
assert app.name == "DasPressureMedicines"
assert app.packages == ["app/release/*.apk", "app/build/outputs/apk/release/*.apk", "desktop/dist/*.AppImage"]
assert app.version_file == "app/build.gradle.kts"
config.validate_app(app)
def test_detect_go(tmp_path):
touch(tmp_path / "internal/version/version.go", b'package version\n\nconst (\n\tVersion = "0.10.0"\n)\n')
touch(tmp_path / "build/multitext", exe=True)
app = detect.new_app(tmp_path, "multitext")
assert (app.packages, app.version_file) == (["build/multitext"], "internal/version/version.go")
builds, _ = packages.resolve(app)
assert builds[0].site_name("multitext") == "multitext_v0.10.0"
def test_site_name_cleanup():
assert detect._site_name("Das Pressure 2") == "DasPressure2"
assert os.path.basename(detect._home(Path.home() / "a")) == "a"