Files
PortProtonQt/dev-scripts/test-appimage-update-server.py
Boria138 269151c00a
Code check / Check code (push) Successful in 2m50s
fix: use real AppImage update progress
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-07-10 20:48:35 +05:00

251 lines
8.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""Run PortProtonQt against a fake AppImage update server."""
import argparse
import http.server
import os
import shutil
import subprocess
import sys
import tempfile
import threading
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
APPIMAGE_MAGIC_OFFSET = 8
APPIMAGE_TYPE_2_MAGIC = b"AI\x02"
TEST_APPIMAGE_SIZE_BYTES = 128 * 1024 * 1024
HTTP_COPY_BUFFER_SIZE = 1024 * 1024
OLD_APPIMAGE_FILLER = b"OLD-APPIMAGE-BLOCK\n"
UPDATED_APPIMAGE_FILLER = b"UPDATED-APPIMAGE-BLOCK\n"
class UpdateHandler(http.server.BaseHTTPRequestHandler):
"""Serve fake update metadata and the new AppImage."""
test_dir = Path()
server_url = ""
old_version = "0.1.12"
new_version = "1.2.0"
new_appimage_name = "PortProtonQt-1.2.0-x86_64.AppImage"
def log_message(self, fmt, *args):
print(f"[server] {fmt % args}")
def do_GET(self):
if self.path.endswith(".zsync"):
self._send_zsync()
elif self.path.startswith("/download/"):
self._send_download()
else:
self.send_error(404)
def _send_download(self) -> None:
filename = self.path.split("/download/", 1)[1]
path = self.test_dir / filename
if not path.exists():
self.send_error(404, f"Not found: {filename}")
return
self._send_file(path)
def _send_zsync(self) -> None:
filename = self.path.rsplit("/", 1)[-1]
path = self.test_dir / filename
if not path.exists():
self.send_error(404, f"Not found: {filename}")
return
self._send_file(path)
def _send_file(self, path: Path) -> None:
file_size = path.stat().st_size
byte_range = self._parse_range(file_size)
start = 0
end = file_size - 1
if byte_range is not None:
start, end = byte_range
self.send_response(206)
self.send_header("Content-Range", f"bytes {start}-{end}/{file_size}")
else:
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(end - start + 1))
self.end_headers()
with open(path, "rb") as appimage_file:
appimage_file.seek(start)
remaining = end - start + 1
while remaining > 0:
chunk = appimage_file.read(min(HTTP_COPY_BUFFER_SIZE, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
def _parse_range(self, file_size: int) -> tuple[int, int] | None:
range_header = self.headers.get("Range", "")
if not range_header.startswith("bytes="):
return None
range_spec = range_header.removeprefix("bytes=").split(",", 1)[0].strip()
start_text, separator, end_text = range_spec.partition("-")
if not separator:
return None
try:
if not start_text:
length = int(end_text)
return max(file_size - length, 0), file_size - 1
start = int(start_text)
end = int(end_text) if end_text else file_size - 1
except ValueError:
return None
if start < 0 or end < start or start >= file_size:
return None
return start, min(end, file_size - 1)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("old_version", nargs="?", default="0.1.12")
parser.add_argument("new_version", nargs="?", default="1.2.0")
parser.add_argument("--port", type=int, default=0)
parser.add_argument("--keep", action="store_true")
parser.add_argument("--version", default=None,
help="Simulate current app version (e.g. --version 0.1.8)")
return parser.parse_args()
def write_appimage_base(path: Path) -> None:
true_path = shutil.which("true") or "/usr/bin/true"
shutil.copyfile(true_path, path)
path.chmod(0o755)
def update_info(server_url: str, new_appimage_name: str) -> str:
return f"zsync|{server_url}/{new_appimage_name}.zsync"
def finalize_appimage(path: Path, test_dir: Path, update_info_text: str, filler: bytes) -> None:
update_info_file = test_dir / f"{path.name}.upd_info"
update_info_file.write_text(update_info_text, encoding="utf-8")
subprocess.run(
["objcopy", "--add-section", f".upd_info={update_info_file}", str(path)],
check=True,
)
with open(path, "r+b") as appimage_file:
appimage_file.seek(APPIMAGE_MAGIC_OFFSET)
appimage_file.write(APPIMAGE_TYPE_2_MAGIC)
appimage_file.seek(0, os.SEEK_END)
while appimage_file.tell() < TEST_APPIMAGE_SIZE_BYTES:
appimage_file.write(
filler[:TEST_APPIMAGE_SIZE_BYTES - appimage_file.tell()]
)
path.chmod(0o755)
def write_zsync(test_dir: Path, server_url: str, new_appimage_name: str) -> None:
new_appimage = test_dir / new_appimage_name
zsync_file = test_dir / f"{new_appimage_name}.zsync"
download_url = f"{server_url}/download/{new_appimage_name}"
subprocess.run(
[
"zsyncmake",
"-e",
"-u",
download_url,
"-o",
str(zsync_file),
"-f",
new_appimage_name,
str(new_appimage),
],
check=True,
)
def start_server(args: argparse.Namespace, test_dir: Path, new_appimage_name: str):
server = http.server.HTTPServer(("127.0.0.1", args.port), UpdateHandler)
server_url = f"http://127.0.0.1:{server.server_port}"
UpdateHandler.test_dir = test_dir
UpdateHandler.server_url = server_url
UpdateHandler.old_version = args.old_version
UpdateHandler.new_version = args.new_version
UpdateHandler.new_appimage_name = new_appimage_name
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, server_url
def python_command() -> str:
venv_python = REPO_ROOT / ".venv" / "bin" / "python"
if venv_python.exists():
return str(venv_python)
return sys.executable
def run_gui(env: dict[str, str]) -> int:
proc = subprocess.Popen(
[python_command(), "-m", "portprotonqt.app"],
cwd=str(REPO_ROOT),
env=env,
)
return proc.wait()
def main() -> None:
args = parse_args()
if args.version:
args.old_version = args.version
test_dir = Path(tempfile.mkdtemp(prefix="ppqt-appimage-update-"))
old_appimage = test_dir / f"PortProtonQt-{args.old_version}-x86_64.AppImage"
new_appimage_name = f"PortProtonQt-{args.new_version}-x86_64.AppImage"
new_appimage = test_dir / new_appimage_name
server = None
try:
write_appimage_base(old_appimage)
write_appimage_base(new_appimage)
server, server_url = start_server(args, test_dir, new_appimage_name)
update_info_text = update_info(server_url, new_appimage_name)
finalize_appimage(
old_appimage,
test_dir,
update_info_text,
OLD_APPIMAGE_FILLER,
)
finalize_appimage(
new_appimage,
test_dir,
update_info_text,
UPDATED_APPIMAGE_FILLER,
)
write_zsync(test_dir, server_url, new_appimage_name)
env = os.environ.copy()
env["APPIMAGE"] = str(old_appimage)
env["PORTPROTONQT_VERSION"] = args.old_version
print("=== AppImage update GUI test ===")
print(f"Test dir : {test_dir}")
print(f"Old AppImage : {old_appimage}")
print(f"New AppImage : {new_appimage}")
print(f"AppImage size : {TEST_APPIMAGE_SIZE_BYTES // 1024 // 1024} MiB")
print(f"HTTP server : {server_url}")
print(f"Update info : {update_info_text}")
print("")
print("In GUI: wait for the update dialog, press Update, watch progress.")
print("After success, old AppImage should be replaced by the new file.")
print("")
raise SystemExit(run_gui(env))
finally:
if server is not None:
server.shutdown()
if args.keep:
print(f"Keeping test dir: {test_dir}")
else:
shutil.rmtree(test_dir, ignore_errors=True)
if __name__ == "__main__":
main()