"""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 # Windows console domyślnie cp1252 → polskie znaki w finalnych printach rzucały # UnicodeEncodeError PO udanym publishu (exit 1 mylił "czy się udało"). Wymuś UTF-8. if hasattr(sys.stdout, "reconfigure"): try: sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") except Exception: pass from datetime import UTC, datetime from pathlib import Path ROOT = Path(__file__).resolve().parent.parent MOBILE = ROOT / "mobile" DIST = MOBILE / "dist" RUNTIME_DEFAULT = "1.1" # == EXPO_RUNTIME_VERSION w APK (bump 1.0→1.1 2026-06; aktywny kanał + app.json runtimeVersion=1.1). # 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" ) # git-bash (MSYS) na Windows konwertuje POSIX-owe ścieżki w env (`/root/...`) na # `C:/Program Files/Git/root/...` ZANIM python je zobaczy → upload trafiał w złą ścieżkę # (bug 2026-06-02). Odkręcamy częsty przypadek. Alternatywnie: uruchom z MSYS_NO_PATHCONV=1. if "/Git/root/" in VPS_BASE: VPS_BASE = VPS_BASE[VPS_BASE.index("/Git") + len("/Git"):] 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") # MSYS_NO_PATHCONV=1 — blokuje konwersję `/root/...` argów ssh/scp przez git-bash sh. env = {**os.environ, "MSYS_NO_PATHCONV": "1"} if shell: subprocess.run(" ".join(cmd), cwd=cwd, check=True, shell=True, env=env) else: subprocess.run(cmd, cwd=cwd, check=True, env=env) 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}"]) # Źródło jako "." z cwd=DIST — Windows scp traktuje `C:\...` jako `host:path` # (litera dysku = host), więc nie podajemy ścieżki z literą dysku (bug 2026-06-02). run(["scp", "-r", ".", f"{VPS}:{remote_dir}/"], cwd=DIST) # 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", "current.json", f"{VPS}:{VPS_BASE}/{args.runtime}/current.json", ], cwd=DIST) 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())