#!/usr/bin/env python3 from __future__ import annotations import json import os import queue import re import shutil import subprocess import sys import threading import time import urllib.error import urllib.parse import urllib.request from dataclasses import dataclass from getpass import getpass from pathlib import Path from typing import Callable try: import tkinter as tk from tkinter import messagebox, ttk except ImportError: tk = None messagebox = None ttk = None REPO_ROOT = Path(__file__).resolve().parents[1] GRADLE_FILE = REPO_ROOT / "android" / "app" / "build.gradle" DEFAULT_JAVA_HOME = "/Applications/Android Studio.app/Contents/jbr/Contents/Home" DEFAULT_API_BASE_URL = "https://iwanttoheal.phenomrom.com" DEFAULT_GITEA_URL = "https://git.whoagland.com" DEFAULT_GITEA_OWNER = "phenom" DEFAULT_GITEA_REPO = "i-want-to-heal" DEFAULT_BRANCH = "main" DEFAULT_TRUENAS_PATH = "/mnt/usbssds/apps/iwanttoheal/app" DEFAULT_DB_PATH = "/mnt/usbssds/apps/iwanttoheal/data/game.db" class ReleaseError(RuntimeError): pass @dataclass(frozen=True) class ReleaseConfig: version: str token: str api_base_url: str java_home: str gitea_url: str gitea_owner: str gitea_repo: str branch: str truenas_path: str truenas_db_path: str restart_command: str run_build: bool run_commit_push: bool run_release: bool run_truenas_pull: bool run_truenas_backup: bool run_restart: bool def read_current_version() -> str: text = GRADLE_FILE.read_text() match = re.search(r'versionName\s+"([^"]+)"', text) return match.group(1) if match else "" def run_command( args: list[str], *, cwd: Path, env: dict[str, str] | None, log: Callable[[str], None], ) -> None: log(f"$ {' '.join(args)}") proc = subprocess.Popen( args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) assert proc.stdout is not None for line in proc.stdout: log(line.rstrip("\n")) code = proc.wait() if code != 0: raise ReleaseError(f"Command failed ({code}): {' '.join(args)}") def update_gradle_version(version: str, log: Callable[[str], None]) -> int: text = GRADLE_FILE.read_text() code_match = re.search(r"versionCode\s+(\d+)", text) if not code_match: raise ReleaseError(f"Could not find versionCode in {GRADLE_FILE}") next_code = int(code_match.group(1)) + 1 text = re.sub(r"versionCode\s+\d+", f"versionCode {next_code}", text, count=1) text = re.sub(r'versionName\s+"[^"]+"', f'versionName "{version}"', text, count=1) GRADLE_FILE.write_text(text) log(f"Android version set: versionName {version}, versionCode {next_code}") return next_code def build_apk(config: ReleaseConfig, log: Callable[[str], None]) -> Path: update_gradle_version(config.version, log) env = os.environ.copy() env["JAVA_HOME"] = config.java_home env["PATH"] = f"{config.java_home}/bin:{env.get('PATH', '')}" env["VITE_API_BASE_URL"] = config.api_base_url run_command(["npm", "run", "android:sync"], cwd=REPO_ROOT, env=env, log=log) run_command(["./gradlew", "clean", "assembleDebug"], cwd=REPO_ROOT / "android", env=env, log=log) apk_source = REPO_ROOT / "android" / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk" if not apk_source.exists(): raise ReleaseError(f"APK not found: {apk_source}") apk_target = REPO_ROOT / f"IWantToHeal-Thor-v{config.version}.apk" apk_target.write_bytes(apk_source.read_bytes()) log(f"APK copied: {apk_target.name} ({apk_target.stat().st_size:,} bytes)") return apk_target def commit_and_push(config: ReleaseConfig, log: Callable[[str], None]) -> None: message = f"Android build v{config.version}" run_command(["git", "add", "."], cwd=REPO_ROOT, env=None, log=log) run_command(["git", "commit", "-m", message], cwd=REPO_ROOT, env=None, log=log) run_command(["git", "push", "origin", config.branch], cwd=REPO_ROOT, env=None, log=log) def gitea_request( config: ReleaseConfig, method: str, path: str, *, body: bytes | None = None, content_type: str | None = None, ) -> tuple[int, bytes]: url = config.gitea_url.rstrip("/") + path headers = {"Authorization": f"token {config.token}"} if content_type: headers["Content-Type"] = content_type req = urllib.request.Request(url, data=body, headers=headers, method=method) try: with urllib.request.urlopen(req) as resp: return resp.status, resp.read() except urllib.error.HTTPError as exc: data = exc.read() return exc.code, data except urllib.error.URLError as exc: raise ReleaseError(f"{method} {url} failed: {exc.reason}") from exc def gitea_upload_asset(config: ReleaseConfig, path: str, apk_path: Path) -> tuple[int, bytes]: curl = shutil.which("curl") if curl is None: raise ReleaseError("curl is required for Gitea asset uploads") url = config.gitea_url.rstrip("/") + path proc = subprocess.run( [ curl, "--silent", "--show-error", "--retry", "3", "--retry-all-errors", "--retry-delay", "2", "--request", "POST", url, "--header", f"Authorization: token {config.token}", "--form", f"attachment=@{apk_path}", "--write-out", "\n%{http_code}", ], check=False, capture_output=True, ) if proc.returncode != 0: detail = (proc.stderr or proc.stdout).decode("utf-8", "replace").strip() raise ReleaseError(f"Gitea asset upload failed: curl exited {proc.returncode}: {detail}") try: body, status_text = proc.stdout.rsplit(b"\n", 1) return int(status_text), body except ValueError as exc: output = proc.stdout.decode("utf-8", "replace") raise ReleaseError(f"Gitea asset upload returned malformed curl output: {output}") from exc def parse_json_response(status: int, data: bytes, action: str) -> dict: try: parsed = json.loads(data.decode("utf-8") or "{}") except json.JSONDecodeError as exc: raise ReleaseError(f"{action} failed ({status}): {data.decode('utf-8', 'replace')}") from exc if status >= 400: raise ReleaseError(f"{action} failed ({status}): {parsed}") if not isinstance(parsed, dict): raise ReleaseError(f"{action} returned unexpected JSON: {parsed}") return parsed def find_or_create_release(config: ReleaseConfig, log: Callable[[str], None]) -> int: repo_path = f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}" tag = f"v{config.version}" tag_path = repo_path + "/releases/tags/" + urllib.parse.quote(tag, safe="") status, data = gitea_request(config, "GET", tag_path) if status == 200: release = parse_json_response(status, data, "Find release") release_id = release.get("id") if not release_id: raise ReleaseError(f"Existing release missing id: {release}") log(f"Gitea release exists: {tag} (id {release_id})") return int(release_id) if status != 404: parse_json_response(status, data, "Find release") payload = { "tag_name": tag, "target_commitish": config.branch, "name": tag, "body": f"I Want to Heal Android build v{config.version}", "draft": False, "prerelease": False, } status, data = gitea_request( config, "POST", repo_path + "/releases", body=json.dumps(payload).encode("utf-8"), content_type="application/json", ) release = parse_json_response(status, data, "Create release") release_id = release.get("id") if not release_id: raise ReleaseError(f"Created release missing id: {release}") log(f"Gitea release created: {tag} (id {release_id})") return int(release_id) def upload_release_asset(config: ReleaseConfig, log: Callable[[str], None]) -> None: apk_path = REPO_ROOT / f"IWantToHeal-Thor-v{config.version}.apk" if not apk_path.exists(): raise ReleaseError(f"APK not found for release upload: {apk_path}") release_id = find_or_create_release(config, log) asset_name = urllib.parse.quote(apk_path.name) path = ( f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}" f"/releases/{release_id}/assets?name={asset_name}" ) status, data = gitea_upload_asset(config, path, apk_path) if status == 409: raise ReleaseError(f"Release asset already exists: {apk_path.name}") parse_json_response(status, data, "Upload release asset") log(f"Gitea asset uploaded: {apk_path.name}") def update_truenas(config: ReleaseConfig, log: Callable[[str], None]) -> None: app_path = Path(config.truenas_path) if not app_path.exists(): raise ReleaseError(f"TrueNAS path not found: {app_path}") run_command(["git", "pull"], cwd=app_path, env=None, log=log) if config.run_truenas_backup: db_path = Path(config.truenas_db_path) if not db_path.exists(): raise ReleaseError(f"TrueNAS database not found: {db_path}") stamp = time.strftime("%Y%m%d-%H%M%S") backup_path = db_path.with_name(f"game-before-update-{stamp}.db") backup_path.write_bytes(db_path.read_bytes()) log(f"TrueNAS database backup copied: {backup_path}") if config.run_restart: if not config.restart_command.strip(): raise ReleaseError("Restart command is empty") run_command(["sh", "-lc", config.restart_command], cwd=app_path, env=None, log=log) def validate_config(config: ReleaseConfig) -> None: if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", config.version): raise ReleaseError("Version must look like 1.1.2") if config.run_release and not config.token: raise ReleaseError("Gitea token required for release step") if config.run_restart and not config.run_truenas_pull: raise ReleaseError("Restart needs TrueNAS update step enabled") def prompt_text(label: str, default: str = "", *, secret: bool = False) -> str: prompt = f"{label}" if default: prompt += f" [{default}]" prompt += ": " try: value = getpass(prompt) if secret else input(prompt) except EOFError: value = "" return value.strip() or default def prompt_bool(label: str, default: bool) -> bool: suffix = "Y/n" if default else "y/N" try: value = input(f"{label} [{suffix}]: ").strip().lower() except EOFError: value = "" if not value: return default return value in {"y", "yes", "true", "1"} def run_release_steps(config: ReleaseConfig, log: Callable[[str], None]) -> None: if config.run_build: build_apk(config, log) if config.run_commit_push: commit_and_push(config, log) if config.run_release: upload_release_asset(config, log) if config.run_truenas_pull: update_truenas(config, log) def cli_main() -> int: print("Tkinter not available. CLI mode.") current_version = read_current_version() config = ReleaseConfig( version=prompt_text("Version", current_version), token=prompt_text("Gitea token", secret=True), api_base_url=prompt_text("API base URL", DEFAULT_API_BASE_URL), java_home=prompt_text("JAVA_HOME", DEFAULT_JAVA_HOME), gitea_url=prompt_text("Gitea URL", DEFAULT_GITEA_URL), gitea_owner=prompt_text("Gitea owner", DEFAULT_GITEA_OWNER), gitea_repo=prompt_text("Gitea repo", DEFAULT_GITEA_REPO), branch=prompt_text("Git branch", DEFAULT_BRANCH), truenas_path=prompt_text("TrueNAS app path", DEFAULT_TRUENAS_PATH), truenas_db_path=prompt_text("TrueNAS DB path", DEFAULT_DB_PATH), restart_command=prompt_text("Restart command"), run_build=prompt_bool("Build APK", True), run_commit_push=prompt_bool("Commit + push", True), run_release=prompt_bool("Create Gitea release", True), run_truenas_pull=prompt_bool("TrueNAS pull", True), run_truenas_backup=prompt_bool("Backup DB", True), run_restart=prompt_bool("Restart", False), ) validate_config(config) run_release_steps(config, print) print("Release steps complete") return 0 if tk is not None and ttk is not None and messagebox is not None: class ReleaseApp(ttk.Frame): def __init__(self, root: tk.Tk) -> None: super().__init__(root, padding=12) self.root = root self.log_queue: queue.Queue[tuple[str, str]] = queue.Queue() self.worker: threading.Thread | None = None self.vars: dict[str, tk.Variable] = {} self.grid(sticky="nsew") root.title("I Want to Heal Release Tool") root.geometry("920x720") root.columnconfigure(0, weight=1) root.rowconfigure(0, weight=1) self.columnconfigure(1, weight=1) self.rowconfigure(13, weight=1) self._build_form() self.after(100, self._drain_log_queue) def _var(self, name: str, value: object = "") -> tk.Variable: if isinstance(value, bool): var: tk.Variable = tk.BooleanVar(value=value) else: var = tk.StringVar(value=str(value)) self.vars[name] = var return var def _build_form(self) -> None: current_version = read_current_version() fields = [ ("Version", "version", current_version), ("Gitea token", "token", ""), ("API base URL", "api_base_url", DEFAULT_API_BASE_URL), ("JAVA_HOME", "java_home", DEFAULT_JAVA_HOME), ("Gitea URL", "gitea_url", DEFAULT_GITEA_URL), ("Gitea owner", "gitea_owner", DEFAULT_GITEA_OWNER), ("Gitea repo", "gitea_repo", DEFAULT_GITEA_REPO), ("Git branch", "branch", DEFAULT_BRANCH), ("TrueNAS app path", "truenas_path", DEFAULT_TRUENAS_PATH), ("TrueNAS DB path", "truenas_db_path", DEFAULT_DB_PATH), ("Restart command", "restart_command", ""), ] for row, (label, name, value) in enumerate(fields): ttk.Label(self, text=label).grid(row=row, column=0, sticky="w", pady=3) entry = ttk.Entry(self, textvariable=self._var(name, value), show="*" if name == "token" else "") entry.grid(row=row, column=1, sticky="ew", pady=3) checks = ttk.Frame(self) checks.grid(row=11, column=0, columnspan=2, sticky="ew", pady=(10, 4)) check_data = [ ("Build APK", "run_build", True), ("Commit + push", "run_commit_push", True), ("Create Gitea release", "run_release", True), ("TrueNAS pull", "run_truenas_pull", True), ("Backup DB", "run_truenas_backup", True), ("Restart", "run_restart", False), ] for index, (text, name, value) in enumerate(check_data): ttk.Checkbutton(checks, text=text, variable=self._var(name, value)).grid( row=0, column=index, padx=(0, 14), sticky="w" ) buttons = ttk.Frame(self) buttons.grid(row=12, column=0, columnspan=2, sticky="ew", pady=(6, 8)) self.run_button = ttk.Button(buttons, text="Run Selected Steps", command=self.run_release) self.run_button.pack(side="left") ttk.Button(buttons, text="Start Admin Server", command=self.start_admin).pack(side="left", padx=8) self.log_text = tk.Text(self, height=18, wrap="word") self.log_text.grid(row=13, column=0, columnspan=2, sticky="nsew") scroll = ttk.Scrollbar(self, command=self.log_text.yview) scroll.grid(row=13, column=2, sticky="ns") self.log_text.configure(yscrollcommand=scroll.set) def config_from_form(self) -> ReleaseConfig: get = lambda name: str(self.vars[name].get()).strip() get_bool = lambda name: bool(self.vars[name].get()) return ReleaseConfig( version=get("version"), token=get("token"), api_base_url=get("api_base_url"), java_home=get("java_home"), gitea_url=get("gitea_url"), gitea_owner=get("gitea_owner"), gitea_repo=get("gitea_repo"), branch=get("branch"), truenas_path=get("truenas_path"), truenas_db_path=get("truenas_db_path"), restart_command=get("restart_command"), run_build=get_bool("run_build"), run_commit_push=get_bool("run_commit_push"), run_release=get_bool("run_release"), run_truenas_pull=get_bool("run_truenas_pull"), run_truenas_backup=get_bool("run_truenas_backup"), run_restart=get_bool("run_restart"), ) def log(self, message: str) -> None: self.log_queue.put(("log", message)) def _drain_log_queue(self) -> None: try: while True: kind, message = self.log_queue.get_nowait() if kind == "done": self.run_button.configure(state="normal") if message: messagebox.showinfo("Release tool", message) elif kind == "error": self.run_button.configure(state="normal") messagebox.showerror("Release failed", message) else: self.log_text.insert("end", message + "\n") self.log_text.see("end") except queue.Empty: pass self.after(100, self._drain_log_queue) def run_release(self) -> None: if self.worker and self.worker.is_alive(): return try: config = self.config_from_form() validate_config(config) except ReleaseError as exc: messagebox.showerror("Invalid settings", str(exc)) return self.run_button.configure(state="disabled") self.log_text.delete("1.0", "end") self.worker = threading.Thread(target=self._run_release_worker, args=(config,), daemon=True) self.worker.start() def _run_release_worker(self, config: ReleaseConfig) -> None: try: run_release_steps(config, self.log) self.log_queue.put(("done", "Release steps complete")) except Exception as exc: self.log_queue.put(("error", str(exc))) def start_admin(self) -> None: if self.worker and self.worker.is_alive(): messagebox.showerror("Busy", "Release steps are still running") return self.log_text.delete("1.0", "end") self.run_button.configure(state="disabled") self.worker = threading.Thread(target=self._start_admin_worker, daemon=True) self.worker.start() def _start_admin_worker(self) -> None: try: self.log("Admin URL: http://127.0.0.1:4174") run_command(["npm", "run", "admin:start"], cwd=REPO_ROOT, env=None, log=self.log) self.log_queue.put(("done", "Admin server stopped")) except Exception as exc: self.log_queue.put(("error", str(exc))) def main() -> int: if not GRADLE_FILE.exists(): print(f"Missing Gradle file: {GRADLE_FILE}", file=sys.stderr) return 1 if tk is None: return cli_main() root = tk.Tk() ReleaseApp(root) root.mainloop() return 0 if __name__ == "__main__": raise SystemExit(main())