diff --git a/IWantToHeal-Thor-v1.1.3.apk b/IWantToHeal-Thor-v1.1.3.apk new file mode 100644 index 0000000..aec5b2e Binary files /dev/null and b/IWantToHeal-Thor-v1.1.3.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 8f97013..9ded95c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.warren.iwanttoheal" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 81 - versionName "1.1.2" + versionCode 82 + versionName "1.1.3" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/package.json b/package.json index ae8883c..710a8e5 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "android:open": "cap open android", "android:apk": "npm run android:sync && cd android && ./gradlew clean assembleDebug", "android:apk:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:apk", + "release:gui": "python3 scripts/release_game.py", + "release:cli": "python3 scripts/release_game_cli.py", "accounts:ip": "node scripts/manage-ip-allowance.mjs", "db:backup": "node scripts/backup-db.mjs", "db:init": "node scripts/init-db.mjs", diff --git a/scripts/__pycache__/release_game_cli.cpython-314.pyc b/scripts/__pycache__/release_game_cli.cpython-314.pyc new file mode 100644 index 0000000..9a63bc8 Binary files /dev/null and b/scripts/__pycache__/release_game_cli.cpython-314.pyc differ diff --git a/scripts/release_game.py b/scripts/release_game.py new file mode 100755 index 0000000..b1d6252 --- /dev/null +++ b/scripts/release_game.py @@ -0,0 +1,502 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import queue +import re +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 + + +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) + + boundary = f"----iwanttoheal{int(time.time() * 1000)}" + header = ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="attachment"; filename="{apk_path.name}"\r\n' + "Content-Type: application/vnd.android.package-archive\r\n\r\n" + ).encode("utf-8") + footer = f"\r\n--{boundary}--\r\n".encode("utf-8") + body = header + apk_path.read_bytes() + footer + 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_request( + config, + "POST", + path, + body=body, + content_type=f"multipart/form-data; boundary={boundary}", + ) + 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()) diff --git a/scripts/release_game_cli.py b/scripts/release_game_cli.py new file mode 100755 index 0000000..4bbbb79 --- /dev/null +++ b/scripts/release_game_cli.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +GRADLE_FILE = REPO_ROOT / "android" / "app" / "build.gradle" +JAVA_HOME = "/Applications/Android Studio.app/Contents/jbr/Contents/Home" +API_BASE_URL = "https://iwanttoheal.phenomrom.com" +GITEA_URL = "https://git.whoagland.com" +GITEA_OWNER = "phenom" +GITEA_REPO = "i-want-to-heal" +GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d" +BRANCH = "main" +TRUENAS_PATH = Path("/mnt/usbssds/apps/iwanttoheal/app") + + +def run(args: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> None: + print(f"$ {' '.join(args)}", flush=True) + subprocess.run(args, cwd=cwd, env=env, check=True) + + +def prompt_version() -> str: + current = GRADLE_FILE.read_text() + match = re.search(r'versionName\s+"([^"]+)"', current) + current_version = match.group(1) if match else "" + suffix = f" [{current_version}]" if current_version else "" + version = input(f"Version{suffix}: ").strip() or current_version + if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", version): + raise SystemExit("Version must look like 1.1.2") + return version + + +def update_gradle_version(version: str) -> None: + text = GRADLE_FILE.read_text() + code_match = re.search(r"versionCode\s+(\d+)", text) + if not code_match: + raise SystemExit(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) + print(f"Android version: {version}, versionCode: {next_code}") + + +def build_apk(version: str) -> Path: + update_gradle_version(version) + env = os.environ.copy() + env["JAVA_HOME"] = JAVA_HOME + env["PATH"] = f"{JAVA_HOME}/bin:{env.get('PATH', '')}" + env["VITE_API_BASE_URL"] = API_BASE_URL + + run(["npm", "run", "android:sync"], cwd=REPO_ROOT, env=env) + run(["./gradlew", "clean", "assembleDebug"], cwd=REPO_ROOT / "android", env=env) + + source = REPO_ROOT / "android" / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk" + apk = REPO_ROOT / f"IWantToHeal-Thor-v{version}.apk" + shutil.copy2(source, apk) + print(f"APK: {apk}") + return apk + + +def commit_and_push(version: str) -> None: + run(["git", "add", "."], cwd=REPO_ROOT) + run(["git", "commit", "-m", f"Android build v{version}"], cwd=REPO_ROOT) + run(["git", "push", "origin", BRANCH], cwd=REPO_ROOT) + + +def request_json(method: str, path: str, token: str, data: dict | None = None) -> dict: + body = json.dumps(data).encode("utf-8") if data is not None else None + headers = {"Authorization": f"token {token}", "Content-Type": "application/json"} + req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", "replace") + raise SystemExit(f"Gitea {method} failed ({exc.code}): {detail}") from exc + + +def upload_asset(path: str, token: str, apk: Path) -> dict: + boundary = "----iwanttoheal-release-boundary" + body = ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="attachment"; filename="{apk.name}"\r\n' + "Content-Type: application/vnd.android.package-archive\r\n\r\n" + ).encode("utf-8") + body += apk.read_bytes() + body += f"\r\n--{boundary}--\r\n".encode("utf-8") + headers = { + "Authorization": f"token {token}", + "Content-Type": f"multipart/form-data; boundary={boundary}", + } + req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method="POST") + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", "replace") + raise SystemExit(f"Gitea asset upload failed ({exc.code}): {detail}") from exc + + +def create_gitea_release(version: str, apk: Path) -> None: + token = GITEA_TOKEN.strip() + if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE": + raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py") + + repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}" + release = request_json( + "POST", + repo_path + "/releases", + token, + { + "tag_name": f"v{version}", + "target_commitish": BRANCH, + "name": f"v{version}", + "body": f"I Want to Heal Android build v{version}", + "draft": False, + "prerelease": False, + }, + ) + release_id = release.get("id") + if not release_id: + raise SystemExit(f"Gitea release missing id: {release}") + + asset_name = urllib.parse.quote(apk.name) + upload_asset(f"{repo_path}/releases/{release_id}/assets?name={asset_name}", token, apk) + print(f"Gitea release uploaded: v{version}") + + +def update_truenas() -> None: + if TRUENAS_PATH.exists(): + run(["git", "pull"], cwd=TRUENAS_PATH) + print("Restart app in TrueNAS UI.") + return + + print("TrueNAS path not mounted here. Run on TrueNAS:") + print(f"cd {TRUENAS_PATH}") + print("git pull") + print("Then restart app in TrueNAS UI.") + + +def main() -> int: + os.chdir(REPO_ROOT) + version = prompt_version() + apk = build_apk(version) + commit_and_push(version) + create_gitea_release(version, apk) + update_truenas() + print("Done.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())