DIAGNOZA NA EMULATORZE (emulator-5554, goon-v0.1.9.apk):
Dwa błędne założenia z poprzednich sesji obalone empirycznie:
1. RUNTIME: APK ma EXPO_RUNTIME_VERSION="1.0" (NIE 0.1.9 — pomyliłem versionName
z runtime). App akceptuje TYLKO manifest runtime 1.0. Mój wcześniejszy
"fix" na 0.1.9 (c19da51) był wstecz — app go ignorował. Cofnięte: app.json
+ publish_update RUNTIME_DEFAULT z powrotem na "1.0".
2. CRASH: prawdziwa przyczyna "nic się nie pojawia" — OTA bundle z expo-font
crashował: "Cannot find native module 'ExpoFontLoader'" → expo-updates
ErrorRecovery rollback. APK (build 22-maja) nie ma natywnego ExpoFontLoader
(expo-font dodany 30-maja, PO buildzie APK). OTA NIE MOŻE dostarczyć native
modułu. Potwierdzone: embedded bundle + served bundle grep = 0 ExpoFontLoader;
stary font-bundle crashował, font-stripped NIE.
FIX: usunięto useFonts z App.tsx + expo-font import; theme.fonts → undefined
(system font); SceneTile/MoviePosterCard/navigation/GoonWordmark fontFamily →
fontWeight. Wszystko inne (2-col grid, oxblood, logo SVG-RNSVG-jest-w-APK)
zostaje. Custom fonty wrócą przy rebuildzie APK z expo-font (option B).
ZWERYFIKOWANE: bundle d5b87e5c (runtime 1.0, 0 ttf) — emulator launch:
`ReactNativeJS: Running "main"`, zero JS errors, brak ExpoFontLoader crash.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
173 lines
6.6 KiB
Python
173 lines
6.6 KiB
Python
"""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/<runtime>/<update_id>/`
|
|
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 = "1.0" # == EXPO_RUNTIME_VERSION w APK (zweryfikowane emulatorem 2026-05-31: app akceptuje TYLKO runtime 1.0).
|
|
# 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())
|