Android build v1.1.3
This commit is contained in:
Executable
+502
@@ -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())
|
||||
Reference in New Issue
Block a user