Avalonia 11.3.17 + CefGlue.Next 152 in eigenem Ordner shell/. Startet den Manager (ManagerStarter, nach Core gezogen), holt ueber den lokalen Socket einen Einmal-Link und zeigt die WebUI angemeldet; Taskleistensymbol mit Zustand. ClawdDotNet ohne Schalter startet die Huelle, sonst den Browser. publish.py baut sie mit (--no-shell zum Weglassen), unter Linux mit den Chromium-Ressourcen neben libcef.so. --selftest fuer die Pruefung ohne Blick. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
10 KiB
Python
273 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Baut ClawdDotNet fuer eine Plattform und veroeffentlicht es im Deploymentcenter.
|
|
|
|
python deploy/publish.py --rid win-x64 --channel dev
|
|
python deploy/publish.py --rid linux-x64 --channel dev --dry-run
|
|
|
|
Drei Schritte, die einzeln zu leicht vergessen werden:
|
|
|
|
1. dotnet publish in ein frisches Verzeichnis. Frisch, weil der Packager alles
|
|
einpackt, was er vorfindet - Reste eines aelteren Laufs landeten sonst mit
|
|
im Paket.
|
|
2. update-agent dazulegen. Ohne ihn findet ResolveAgentPath() nichts, und die
|
|
Anwendung kann sich nicht selbst aktualisieren. Die Erstinstallation legt
|
|
ihn NICHT ins Zielverzeichnis - sie laeuft von dort, wo der Benutzer sie
|
|
hingelegt hat.
|
|
3. pack-and-deploy aufrufen.
|
|
|
|
Die Version kommt aus Directory.Build.props. Sie hier noch einmal anzugeben
|
|
waere eine zweite Pflegestelle - und der Packager bricht bei einer Abweichung
|
|
zur Assembly ohnehin ab.
|
|
"""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import urllib.request
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
PROJECT = REPO / "src" / "ClawdDotNet.Desktop" / "ClawdDotNet.Desktop.csproj"
|
|
MANAGER_PROJECT = REPO / "src" / "ClawdDotNet.ManagerApp" / "ClawdDotNet.ManagerApp.csproj"
|
|
SHELL_PROJECT = REPO / "src" / "ClawdDotNet.Shell" / "ClawdDotNet.Shell.csproj"
|
|
CONFIG = REPO / "deploy" / "packager.config.json"
|
|
PACKAGER = Path(
|
|
"J:/Softwareprojekte/Deploymentcenter/client-dotnet/Deploymentcenter.Packager"
|
|
"/bin/Release/net10.0/win-x64/pack-and-deploy.exe"
|
|
)
|
|
INSTALLER_BASE = "https://dc.mhdf.de/installer"
|
|
|
|
|
|
def read_version() -> str:
|
|
text = (REPO / "Directory.Build.props").read_text(encoding="utf-8")
|
|
match = re.search(r"<Version>([^<]+)</Version>", text)
|
|
if not match:
|
|
sys.exit("Directory.Build.props enthaelt kein <Version>.")
|
|
return match.group(1).strip()
|
|
|
|
|
|
def run(cmd: list[str], **kw) -> None:
|
|
print(" $", " ".join(str(c) for c in cmd))
|
|
result = subprocess.run(cmd, **kw)
|
|
if result.returncode != 0:
|
|
sys.exit(f"Abgebrochen (Rueckgabewert {result.returncode}): {cmd[0]}")
|
|
|
|
|
|
def publish(rid: str, out_dir: Path, include_shell: bool = True) -> None:
|
|
if out_dir.exists():
|
|
shutil.rmtree(out_dir)
|
|
out_dir.mkdir(parents=True)
|
|
|
|
# Eine Zeitmarke fuer beide Publish-Laeufe: Directory.Build.targets stempelt sonst
|
|
# jede Uebersetzung mit der aktuellen Uhrzeit, und der zweite Lauf baut Core und
|
|
# Licensing mit anderer Marke neu - dieselbe Fassung, aber nicht mehr bytegleich.
|
|
stamp = f"-p:BuildTimestamp={datetime.now():%Y-%m-%d %H:%M:%S}"
|
|
|
|
run([
|
|
"dotnet", "publish", str(PROJECT),
|
|
"-c", "Release", "-r", rid, "--self-contained", "false",
|
|
"-o", str(out_dir), "--nologo", "-v", "q", stamp,
|
|
])
|
|
|
|
# #588: Der Instanzmanager samt Weboberflaeche gehoert ins selbe Paket. Vorher kam
|
|
# ueber den Installer nur die Desktop-Anwendung an - keine Oberflaeche im Browser, und
|
|
# der Manager, der laut Konzept neben der Instanz liegt, fehlte ganz.
|
|
manager_dir = out_dir.parent / f"{out_dir.name}-manager"
|
|
if manager_dir.exists():
|
|
shutil.rmtree(manager_dir)
|
|
manager_dir.mkdir(parents=True)
|
|
|
|
run([
|
|
"dotnet", "publish", str(MANAGER_PROJECT),
|
|
"-c", "Release", "-r", rid, "--self-contained", "false",
|
|
"-o", str(manager_dir), "--nologo", "-v", "q", stamp,
|
|
])
|
|
merge_manager(manager_dir, out_dir)
|
|
|
|
if include_shell:
|
|
publish_shell(rid, out_dir, stamp)
|
|
|
|
# #795: Die systemd-Vorlagen gehoeren ins Linux-Paket - sonst steht die Anleitung in
|
|
# einem Repository, das auf der Zielmaschine niemand hat.
|
|
if rid.startswith("linux"):
|
|
units = out_dir / "systemd"
|
|
units.mkdir(exist_ok=True)
|
|
for unit in sorted((REPO / "deploy" / "systemd").glob("*.service")):
|
|
shutil.copy2(unit, units / unit.name)
|
|
print(f" systemd-Vorlagen: {len(list(units.glob('*.service')))} nach systemd/")
|
|
|
|
|
|
def publish_shell(rid: str, out_dir: Path, stamp: str) -> None:
|
|
"""#764: Die Huelle - Fenster mit eingebettetem Chromium - nach shell/.
|
|
|
|
Eigener Ordner, weil sie Avalonia 11.3 mitbringt (CefGlue ist dagegen gebaut) und der
|
|
Instanzprozess weder Avalonia noch Chromium laden soll. Kein merge_manager: Die
|
|
Bibliotheken der Huelle sollen gerade NICHT neben denen der Instanz liegen.
|
|
|
|
build statt publish: Das Chromium-Laufzeitpaket kopiert seine Dateien nur im
|
|
AfterBuild-Ziel mit gesetzter Plattform; ein publish liess sie aus (23 MB statt
|
|
~370 MB, und kein libcef). Unter Linux sucht Chromium icudtl.dat und die .pak-Dateien
|
|
neben libcef.so, das Paket legt sie nach Resources/ - sie werden hochgezogen.
|
|
"""
|
|
shell_dir = out_dir / "shell"
|
|
if shell_dir.exists():
|
|
shutil.rmtree(shell_dir)
|
|
|
|
run([
|
|
"dotnet", "build", str(SHELL_PROJECT),
|
|
"-c", "Release", "-r", rid, "--self-contained", "false",
|
|
"-o", str(shell_dir), "--nologo", "-v", "q", stamp,
|
|
])
|
|
|
|
resources = shell_dir / "Resources"
|
|
if rid.startswith("linux") and resources.is_dir():
|
|
for item in resources.iterdir():
|
|
target = shell_dir / item.name
|
|
if target.exists():
|
|
shutil.rmtree(target) if target.is_dir() else target.unlink()
|
|
shutil.move(str(item), str(target))
|
|
resources.rmdir()
|
|
|
|
for pdb in shell_dir.rglob("*.pdb"):
|
|
pdb.unlink()
|
|
|
|
size = sum(f.stat().st_size for f in shell_dir.rglob("*") if f.is_file())
|
|
print(f" Huelle: {size / 1_048_576:.1f} MB nach shell/")
|
|
|
|
|
|
def merge_manager(src: Path, dst: Path) -> None:
|
|
"""Legt den Manager neben die Instanz.
|
|
|
|
Beide Programme teilen viele Bibliotheken. Liegt eine schon da, muss sie bytegleich
|
|
sein - sonst ersetzte der zweite Publish die Fassung des ersten still, und eines der
|
|
beiden Programme liefe mit einer Bibliothek, gegen die es nicht gebaut wurde. Das
|
|
faellt nicht beim Bauen auf, sondern beim Kunden.
|
|
"""
|
|
conflicts = []
|
|
copied = 0
|
|
for f in sorted(src.rglob("*")):
|
|
if not f.is_file():
|
|
continue
|
|
rel = f.relative_to(src)
|
|
target = dst / rel
|
|
if target.exists():
|
|
if target.read_bytes() != f.read_bytes():
|
|
conflicts.append(str(rel))
|
|
continue
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(f, target)
|
|
copied += 1
|
|
|
|
if conflicts:
|
|
sys.exit("Instanz und Manager liefern unterschiedliche Fassungen derselben Datei:\n "
|
|
+ "\n ".join(conflicts[:30]))
|
|
|
|
shutil.rmtree(src)
|
|
print(f" Instanzmanager: {copied} Datei(en) neben die Instanz gelegt")
|
|
|
|
|
|
def fetch_agent(rid: str, out_dir: Path, agent_file: Path | None = None) -> None:
|
|
"""Holt den update-agent und prueft die Pruefsumme.
|
|
|
|
Bewusst das ausgelieferte Binary statt eines selbst gebauten: Es ist
|
|
dasselbe, das die Erstinstallation verwendet, und wird zentral gepflegt.
|
|
Ohne Pruefsummenvergleich waere das ein Download, der spaeter fremden Code
|
|
ausfuehrt - genau der Pfad, den eine Signatur schuetzen soll.
|
|
|
|
agent_file ersetzt nur den Download, nicht die Pruefung: Die Ablage liefert
|
|
zeitweise um 100 KB/s, und urllib blieb dabei am 2026-09-14 ohne Meldung
|
|
stehen. Eine vorhandene Kopie muss dieselbe Pruefsumme tragen wie das
|
|
Manifest - sonst ist sie nicht das ausgelieferte Binary.
|
|
"""
|
|
with urllib.request.urlopen(f"{INSTALLER_BASE}/installer.json", timeout=30) as r:
|
|
manifest = json.load(r)
|
|
|
|
entry = next((b for b in manifest.get("binaries", []) if b.get("platform") == rid), None)
|
|
if entry is None:
|
|
sys.exit(f"Kein update-agent fuer {rid} im Installer-Manifest.")
|
|
|
|
print(f" update-agent {manifest.get('version')} fuer {rid} ({entry['sizeBytes']} Bytes)")
|
|
|
|
if agent_file is not None:
|
|
print(f" aus Datei: {agent_file}")
|
|
payload = agent_file.read_bytes()
|
|
else:
|
|
with urllib.request.urlopen(f"{INSTALLER_BASE}/{entry['file']}", timeout=180) as r:
|
|
payload = r.read()
|
|
|
|
actual = hashlib.sha256(payload).hexdigest()
|
|
if actual != entry["sha256"].lower():
|
|
sys.exit(f"Pruefsumme weicht ab!\n erwartet: {entry['sha256']}\n erhalten: {actual}")
|
|
|
|
target = out_dir / ("update-agent.exe" if rid.startswith("win") else "update-agent")
|
|
target.write_bytes(payload)
|
|
|
|
if not rid.startswith("win"):
|
|
target.chmod(0o755)
|
|
|
|
print(f" Pruefsumme in Ordnung -> {target.name}")
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--rid", default="win-x64", help="win-x64, linux-x64, ...")
|
|
parser.add_argument("--channel", default="dev", choices=["dev", "beta", "prod"])
|
|
parser.add_argument("--changelog", default="")
|
|
parser.add_argument("--no-agent", action="store_true",
|
|
help="update-agent nicht mitliefern")
|
|
parser.add_argument("--agent-file", type=Path,
|
|
help="vorhandene Kopie des update-agent statt Download; "
|
|
"die Pruefsumme wird trotzdem gegen installer.json geprueft")
|
|
parser.add_argument("--no-shell", action="store_true",
|
|
help="die Huelle (Fenster mit Chromium, ~370 MB) nicht mitliefern")
|
|
parser.add_argument("--dry-run", action="store_true",
|
|
help="nur bauen und pruefen, nicht hochladen")
|
|
args = parser.parse_args()
|
|
|
|
version = read_version()
|
|
print(f"ClawdDotNet {version} | {args.rid} | Kanal {args.channel}")
|
|
|
|
out_dir = Path(tempfile.gettempdir()) / f"clawddotnet-publish-{args.rid}"
|
|
|
|
print("\n[1/3] dotnet publish")
|
|
publish(args.rid, out_dir, include_shell=not args.no_shell)
|
|
|
|
print("\n[2/3] update-agent")
|
|
if args.no_agent:
|
|
print(" uebersprungen (--no-agent)")
|
|
else:
|
|
fetch_agent(args.rid, out_dir, args.agent_file)
|
|
|
|
total = sum(f.stat().st_size for f in out_dir.rglob("*") if f.is_file())
|
|
print(f"\n {len(list(out_dir.rglob('*')))} Eintraege, {total / 1_048_576:.1f} MB")
|
|
|
|
if args.dry_run:
|
|
print(f"\n[3/3] uebersprungen (--dry-run). Ergebnis liegt in {out_dir}")
|
|
return
|
|
|
|
if not CONFIG.exists():
|
|
sys.exit(f"{CONFIG} fehlt. Vorlage: deploy/packager.config.example.json")
|
|
|
|
print("\n[3/3] pack-and-deploy")
|
|
cmd = [
|
|
str(PACKAGER), "--config", str(CONFIG),
|
|
"--project", "clawddotnet", "--version", version,
|
|
"--channel", args.channel, "--platform", args.rid,
|
|
"--publish-dir", str(out_dir),
|
|
]
|
|
if args.changelog:
|
|
cmd += ["--changelog", args.changelog]
|
|
|
|
run(cmd)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|