299 lines
8.4 KiB
Python
299 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Interactive CLI to quickly add new apps to applications.json."""
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from utils import detect_source_from_url, load_dotenv
|
|
|
|
CATEGORIES = [
|
|
"Emulator",
|
|
"Frontend",
|
|
"Utilities",
|
|
"PC Emulation",
|
|
"Streaming",
|
|
"Track Only",
|
|
]
|
|
|
|
VARIANT_OPTIONS = [
|
|
"Both",
|
|
"Standard only",
|
|
"Dual-screen only",
|
|
"README only",
|
|
]
|
|
|
|
|
|
def extract_github_info(url: str) -> tuple[str, str] | None:
|
|
match = re.match(r"https?://github\.com/([^/]+)/([^/]+)", url)
|
|
if match:
|
|
return match.group(1), match.group(2)
|
|
return None
|
|
|
|
|
|
def prompt(message: str, default: str = "") -> str:
|
|
if default:
|
|
result = input(f"{message} [{default}]: ").strip()
|
|
return result if result else default
|
|
return input(f"{message}: ").strip()
|
|
|
|
|
|
def prompt_yes_no(message: str, default: bool = True) -> bool:
|
|
default_str = "Y/n" if default else "y/N"
|
|
result = input(f"{message} [{default_str}]: ").strip().lower()
|
|
if not result:
|
|
return default
|
|
return result in ("y", "yes")
|
|
|
|
|
|
def select_menu(title: str, choices: list[str], default: int = 0) -> str:
|
|
if not sys.stdin.isatty():
|
|
return _select_menu_fallback(title, choices, default)
|
|
|
|
try:
|
|
import curses
|
|
|
|
return _select_menu_curses(title, choices, default)
|
|
except Exception:
|
|
return _select_menu_fallback(title, choices, default)
|
|
|
|
|
|
def _select_menu_curses(title: str, choices: list[str], default: int = 0) -> str:
|
|
|
|
def menu(stdscr):
|
|
curses.curs_set(0)
|
|
current = default
|
|
|
|
while True:
|
|
stdscr.clear()
|
|
stdscr.addstr(0, 0, title)
|
|
stdscr.addstr(1, 0, "(Use arrow keys, Enter to select)")
|
|
|
|
for i, choice in enumerate(choices):
|
|
y = i + 3
|
|
if i == current:
|
|
stdscr.attron(curses.A_REVERSE)
|
|
stdscr.addstr(y, 2, f" {choice} ")
|
|
stdscr.attroff(curses.A_REVERSE)
|
|
else:
|
|
stdscr.addstr(y, 2, f" {choice} ")
|
|
|
|
stdscr.refresh()
|
|
key = stdscr.getch()
|
|
|
|
if key == curses.KEY_UP and current > 0:
|
|
current -= 1
|
|
elif key == curses.KEY_DOWN and current < len(choices) - 1:
|
|
current += 1
|
|
elif key in (curses.KEY_ENTER, 10, 13):
|
|
return choices[current]
|
|
elif key == 27:
|
|
return choices[default]
|
|
|
|
import curses
|
|
|
|
return curses.wrapper(menu)
|
|
|
|
|
|
def _select_menu_fallback(title: str, choices: list[str], default: int = 0) -> str:
|
|
print(f"\n{title}")
|
|
for i, choice in enumerate(choices):
|
|
marker = ">" if i == default else " "
|
|
print(f" {marker} {i + 1}. {choice}")
|
|
|
|
while True:
|
|
result = input(f"Enter number [{default + 1}]: ").strip()
|
|
if not result:
|
|
return choices[default]
|
|
try:
|
|
idx = int(result) - 1
|
|
if 0 <= idx < len(choices):
|
|
return choices[idx]
|
|
except ValueError:
|
|
pass
|
|
print("Invalid choice, try again.")
|
|
|
|
|
|
def generate_app_entry(
|
|
app_id: str,
|
|
url: str,
|
|
author: str,
|
|
name: str,
|
|
categories: list[str],
|
|
source: str,
|
|
variant: str,
|
|
include_prereleases: bool = False,
|
|
verify_latest_tag: bool = False,
|
|
allow_id_change: bool = False,
|
|
app_name_override: str | None = None,
|
|
url_override: str | None = None,
|
|
) -> dict:
|
|
settings: dict[str, object] = {}
|
|
if "Track Only" in categories:
|
|
settings["trackOnly"] = True
|
|
if include_prereleases:
|
|
settings["includePrereleases"] = True
|
|
if verify_latest_tag:
|
|
settings["verifyLatestTag"] = True
|
|
if app_name_override:
|
|
settings["appName"] = app_name_override
|
|
|
|
app = {
|
|
"id": app_id,
|
|
"url": url,
|
|
"author": author,
|
|
"name": name,
|
|
"preferredApkIndex": 0,
|
|
"additionalSettings": settings,
|
|
"categories": categories,
|
|
"allowIdChange": allow_id_change,
|
|
"overrideSource": source,
|
|
}
|
|
|
|
meta = {}
|
|
if variant == "Standard only":
|
|
meta["includeInDualScreen"] = False
|
|
elif variant == "Dual-screen only":
|
|
meta["includeInStandard"] = False
|
|
elif variant == "README only":
|
|
meta["excludeFromExport"] = True
|
|
|
|
if app_name_override:
|
|
meta["nameOverride"] = app_name_override
|
|
if url_override:
|
|
meta["urlOverride"] = url_override
|
|
|
|
if meta:
|
|
app["meta"] = meta
|
|
|
|
return app
|
|
|
|
|
|
def main() -> int:
|
|
print("=" * 50)
|
|
print(" Add New App to Obtainium Emulation Pack")
|
|
print("=" * 50)
|
|
|
|
url = prompt("\nApp URL (GitHub/GitLab/etc.)")
|
|
if not url:
|
|
print("URL is required.")
|
|
return 1
|
|
|
|
source = detect_source_from_url(url)
|
|
if source:
|
|
print(f" Detected source: {source}")
|
|
else:
|
|
source = prompt("Source type", "GitHub")
|
|
|
|
author = ""
|
|
name = ""
|
|
github_info = extract_github_info(url)
|
|
if github_info:
|
|
author, repo_name = github_info
|
|
name = repo_name.replace("-", " ").replace("_", " ").title()
|
|
print(f" Detected author: {author}")
|
|
print(f" Detected name: {name}")
|
|
|
|
author = prompt("Author", author)
|
|
name = prompt("App name", name)
|
|
|
|
app_id = prompt("Android package ID (e.g., com.example.app)")
|
|
if not app_id:
|
|
print("Package ID is required.")
|
|
return 1
|
|
|
|
category = select_menu("Select category:", CATEGORIES)
|
|
print(f" Selected: {category}")
|
|
|
|
variant = select_menu("Include in which release(s):", VARIANT_OPTIONS)
|
|
print(f" Selected: {variant}")
|
|
|
|
include_prereleases = prompt_yes_no("Include pre-releases?", False)
|
|
print(f" Include pre-releases: {'Yes' if include_prereleases else 'No'}")
|
|
|
|
verify_latest_tag = prompt_yes_no("Verify latest tag?", False)
|
|
print(f" Verify latest tag: {'Yes' if verify_latest_tag else 'No'}")
|
|
|
|
allow_id_change = prompt_yes_no("Allow ID change?", False)
|
|
print(f" Allow ID change: {'Yes' if allow_id_change else 'No'}")
|
|
|
|
print("")
|
|
app_name_override = input(
|
|
"App name override - leave blank to skip (sets display name in both Obtainium & README): "
|
|
).strip()
|
|
if app_name_override:
|
|
print(f" Will set additionalSettings.appName and meta.nameOverride")
|
|
|
|
url_override = input("Homepage URL override - leave blank to skip: ").strip()
|
|
|
|
app_entry = generate_app_entry(
|
|
app_id=app_id,
|
|
url=url,
|
|
author=author,
|
|
name=name,
|
|
categories=[category],
|
|
source=source,
|
|
variant=variant,
|
|
include_prereleases=include_prereleases,
|
|
verify_latest_tag=verify_latest_tag,
|
|
allow_id_change=allow_id_change,
|
|
app_name_override=app_name_override or None,
|
|
url_override=url_override or None,
|
|
)
|
|
|
|
print("\n" + "=" * 50)
|
|
print(" Generated Entry Preview")
|
|
print("=" * 50)
|
|
print(json.dumps(app_entry, indent=2))
|
|
|
|
if not prompt_yes_no("\nAdd this app to applications.json?", True):
|
|
print("Cancelled.")
|
|
return 0
|
|
|
|
apps_file = Path("src/applications.json")
|
|
if not apps_file.exists():
|
|
print(f"Error: {apps_file} not found. Run from repo root.")
|
|
return 1
|
|
|
|
with open(apps_file, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
|
|
existing_ids = {app["id"] for app in data.get("apps", [])}
|
|
if app_id in existing_ids:
|
|
print(f"\nWarning: App with ID '{app_id}' already exists!")
|
|
if not prompt_yes_no("Add anyway?", False):
|
|
print("Cancelled.")
|
|
return 0
|
|
|
|
data["apps"].append(app_entry)
|
|
|
|
with open(apps_file, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
f.write("\n")
|
|
|
|
print(f"\nApp added to {apps_file}")
|
|
|
|
if prompt_yes_no("\nRun live test on this app?", True):
|
|
load_dotenv()
|
|
from importlib import import_module
|
|
|
|
test_mod = import_module("test-apps")
|
|
print()
|
|
result = test_mod.test_app(app_entry)
|
|
test_mod.print_result(result, verbose=True)
|
|
if not result.passed:
|
|
print("\nThe app config failed the live test.")
|
|
print("The entry has been saved - you may want to fix the config and re-test.")
|
|
print()
|
|
|
|
print("Next steps:")
|
|
print(" 1. Run 'just build' to regenerate all files")
|
|
print(" 2. Review the diff before committing")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|