"""Publish a new Expo Updates JS bundle do VPS-a (silent OTA update). Workflow: 1. `npx expo export --platform android` -> produkuje `mobile/dist/` 2. Czyta `dist/metadata.json` (file->hash mapping wygenerowany przez Expo) 3. Buduje manifest JSON wg Expo Updates v1 protocol 4. scp dist/ + manifest.json -> `/root/goon/app/static/expo-updates///` 5. Updatuje `current.json` żeby wskazać nowy update_id 6. Mobile przy następnym launchu zauważy zmianę manifestu i pobierze bundle UWAGA: runtimeVersion MUSI pasować do `EXPO_RUNTIME_VERSION` z AndroidManifest embedowanego APK. Aktualnie `1.0`. Bumpować TYLKO gdy native code change wymusza incompat z poprzednim bundle (wtedy nowy APK trzeba przez PackageInstaller). """ from __future__ import annotations import argparse import base64 import hashlib import json import mimetypes import os import subprocess import sys import uuid from datetime import UTC, datetime from pathlib import Path ROOT = Path(__file__).resolve().parent.parent MOBILE = ROOT / "mobile" DIST = MOBILE / "dist" RUNTIME_DEFAULT = "0.1.9" # MUSI == EXPO_RUNTIME_VERSION w APK (AndroidManifest). Zweryfikowane 2026-05-31. # Operator config — set in your shell / .env.local before running this script. # Defaults are placeholders intended to fail loudly if you forgot to configure. VPS = os.environ.get("GOON_VPS_SSH", "root@your-vps.example.com") VPS_BASE = os.environ.get("GOON_VPS_UPDATES_DIR", "/root/goon/app/static/expo-updates") PUBLIC_BASE = os.environ.get( "GOON_PUBLIC_UPDATES_URL", "https://your-vps.example.com:8443/expo-updates/asset" ) def sha256_b64url(data: bytes) -> str: """SHA-256 -> base64url bez padding (Expo Updates wymaga tego formatu).""" return base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode() def run(cmd: list[str], cwd: Path | None = None) -> None: print(f"$ {' '.join(cmd)}") # Windows: `npx` to `.cmd` więc subprocess wymaga `shell=True` (CreateProcess # nie wie jak rozwiązać .cmd extension bez shella). Linux/Mac: shell=False # jest bezpieczniejsze (brak shell interpolation), ale my podajemy list[str] # więc i tak nie ma shell injection risk. shell = sys.platform == "win32" and cmd[0] in ("npx", "npm", "scp", "ssh") if shell: subprocess.run(" ".join(cmd), cwd=cwd, check=True, shell=True) else: subprocess.run(cmd, cwd=cwd, check=True) def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--runtime", default=RUNTIME_DEFAULT) ap.add_argument("--skip-export", action="store_true", help="reuse existing dist/") args = ap.parse_args() if not args.skip_export: if DIST.exists(): import shutil shutil.rmtree(DIST) run(["npx", "expo", "export", "--platform", "android"], cwd=MOBILE) metadata_path = DIST / "metadata.json" if not metadata_path.exists(): print(f"ERROR: {metadata_path} not found — czy expo export się powiodło?") return 1 metadata = json.loads(metadata_path.read_text(encoding="utf-8")) android_meta = metadata["fileMetadata"]["android"] bundle_rel = android_meta["bundle"] bundle_path = DIST / bundle_rel bundle_data = bundle_path.read_bytes() bundle_hash = sha256_b64url(bundle_data) update_id = str(uuid.uuid4()) created_at = datetime.now(UTC).isoformat().replace("+00:00", "Z") def asset_url(rel_path: str) -> str: # rel_path = "_expo/static/js/android/abc.hbc" lub "assets/abc". # Windows: Expo metadata.json używa os.sep (`\`) w assets[].path. Normalizujemy # do `/` żeby URL był poprawny path-side (Linux backend nie traktuje `\` jako # separatora — bez tego mobile dostaje 404 na każdy asset i odrzuca update). rel_path = rel_path.replace("\\", "/") return f"{PUBLIC_BASE}?asset={update_id}/{rel_path}&runtimeVersion={args.runtime}&platform=android" launch_asset = { "hash": bundle_hash, "key": Path(bundle_rel).stem, "contentType": "application/javascript", "url": asset_url(bundle_rel), } assets: list[dict] = [] for a in android_meta.get("assets", []): ap_path = DIST / a["path"] ap_data = ap_path.read_bytes() ext = a.get("ext") or "" if not ext.startswith("."): ext = "." + ext if ext else "" ctype = mimetypes.types_map.get(ext, "application/octet-stream") assets.append({ "hash": sha256_b64url(ap_data), "key": Path(a["path"]).name, "contentType": ctype, "fileExtension": ext, "url": asset_url(a["path"]), }) # `extra.expoClient` musi zawierać zawartość app.json (sekcja `expo`) — bez tego # `Constants.expoConfig` w mobile zwraca null po reload OTA, więc np. version # czyta się jako '0.0.0' (zgłoszone w bug-report 97adff93 — fake "Update available" # dialog 0.1.8 vs 0.0.0). Patrz `mobile/src/lib/appVersion.ts`. app_json_path = MOBILE / "app.json" expo_client_config: dict = {} if app_json_path.exists(): app_json = json.loads(app_json_path.read_text(encoding="utf-8")) expo_client_config = app_json.get("expo", {}) manifest = { "id": update_id, "createdAt": created_at, "runtimeVersion": args.runtime, "launchAsset": launch_asset, "assets": assets, "metadata": {}, "extra": { "expoClient": expo_client_config, }, } manifest_local = DIST / "manifest.json" manifest_local.write_text(json.dumps(manifest, indent=2), encoding="utf-8") print(f"Manifest: {manifest_local}") print(f" update_id: {update_id}") print(f" assets: {len(assets)}") print(f" bundle: {bundle_rel} ({len(bundle_data) // 1024} KB)") # Upload do VPS remote_dir = f"{VPS_BASE}/{args.runtime}/{update_id}" print(f"\nUpload -> {remote_dir}") run(["ssh", VPS, f"mkdir -p {remote_dir}"]) run(["scp", "-r", f"{DIST}/.", f"{VPS}:{remote_dir}/"]) # current.json wskazuje na nowy update_id current = { "update_id": update_id, "runtime_version": args.runtime, "created_at": created_at, } current_local = DIST / "current.json" current_local.write_text(json.dumps(current, indent=2), encoding="utf-8") run([ "scp", str(current_local), f"{VPS}:{VPS_BASE}/{args.runtime}/current.json", ]) print(f"\nOK Update {update_id} live na runtime {args.runtime}") print(f" Klienci dostaną bundle przy następnym launchu (lub Updates.checkForUpdate()).") return 0 if __name__ == "__main__": sys.exit(main())