Nowa zakladka w dolnym pasku: react-native-pager-view (pion, natywny snap), dwie instancje createVideoPlayer (aktywna + nastepna w buforze), komorka z posterem, podpisem tworcy i tagami, kolumna przyciskow, pasek postepu. Gesty bez reanimated: tap = dzwiek, double-tap = ulubione z wybuchem serca i haptyka, przytrzymanie = 2x, przesuniecie w lewo = arkusz "show less of this" (tworca / tagi / nisze / seen it, plus blokada tworcy). Onboarding 3 tapow z top tagow, AutoSwipe (off/5/10 s), licznik obejrzanych, nowe rozdanie. Telemetria: SwipeTelemetry zbiera zdarzenia i wysyla paczki co 5 s / 10 zdarzen / w tle; przy opuszczeniu strony impression z dwell, skip (<2 s) albo complete (petla lub >=80%). Serwer z tego uczy wagi i uklada 80% nastepnych stron. Pager to modul natywny, wiec import jest miekki (require w try/catch) i zakladka pokazuje sie tylko od runtime 1.4; ten sam bundle leci OTA na 1.2/1.3 z wpisem "pobierz 0.4.0" (onlyBelowRuntime), a wpis o Swipe ma nowe pole minRuntime. Bump: app.json 0.4.0 / runtime 1.4, versionCode 14, AndroidManifest 1.4, publish_update RUNTIME_DEFAULT 1.4, strona goon-foss.org pod 0.4.0. Sprawdzone na emulatorze (APK 0.4.0): zakladka widoczna, onboarding zapisany na serwerze, feed spersonalizowany 8/2, ExoPlayer dekoduje wideo i audio, swipe daje zdarzenia complete/impression, double-tap daje like + ulubione + wagi.
260 lines
11 KiB
Python
260 lines
11 KiB
Python
"""Publish a new Expo Updates JS bundle do VPS-a (silent OTA update).
|
|
|
|
Workflow:
|
|
1. `npx expo export --platform android --source-maps` -> produkuje `mobile/dist/`
|
|
1b. Mapy lecą do Sentry (`upload_sourcemaps`), a potem znikają z `dist`
|
|
(`strip_sourcemaps`), żeby nie pojechały na serwer razem z paczką
|
|
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
|
|
|
|
# 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.4" # == EXPO_RUNTIME_VERSION w AndroidManifest + app.json runtimeVersion.
|
|
# Historia kanalow: 1.0 -> 1.1 (2026-06) -> 1.2 (2026-08-11, APK 0.2.2 z expo-keep-awake).
|
|
# 1.1 wciaz ZYJE: siedza tam instalacje sprzed 0.2.2, ktore nie moga sie zaktualizowac in-place
|
|
# (stary klucz podpisujacy przepadl) i dostaly baner migracji. Poprawki dla nich publikuj
|
|
# jawnie przez --runtime 1.1, dopoki ktos tam jeszcze jest.
|
|
# 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, extra_env: dict | 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", **(extra_env or {})}
|
|
if shell:
|
|
subprocess.run(" ".join(cmd), cwd=cwd, check=True, shell=True, env=env)
|
|
else:
|
|
subprocess.run(cmd, cwd=cwd, check=True, env=env)
|
|
|
|
|
|
SENTRY_UPLOADER = (
|
|
MOBILE / "node_modules" / "@sentry" / "react-native" / "scripts" / "expo-upload-sourcemaps.js"
|
|
)
|
|
|
|
|
|
def upload_sourcemaps() -> None:
|
|
"""Wysyła mapy tej paczki do Sentry, żeby ślady JS dało się odczytać.
|
|
|
|
Po co: mapy z buildu APK opisują WYŁĄCZNIE paczkę wbudowaną w plik instalacyjny, a
|
|
praktycznie każdy użytkownik dociąga OTA przy pierwszym uruchomieniu i od tego
|
|
momentu w Sentry widać `app:///index.android.bundle` zamiast nazw plików i numerów
|
|
linii. Dopasowanie idzie po `debug id` zaszytym w paczce, więc nazwa release'u ani
|
|
dist nie są do niczego potrzebne.
|
|
|
|
Brak `SENTRY_AUTH_TOKEN` NIE jest błędem. Publikacja ma działać także u kogoś, kto
|
|
stawia własną instancję i nie ma naszego Sentry; wtedy tylko o tym mówimy. U nas
|
|
token wstrzykuje wrapper `iclaude` (workspace `goon-app`), więc publikuj przez niego.
|
|
"""
|
|
if not os.environ.get("SENTRY_AUTH_TOKEN"):
|
|
print("Sentry: brak SENTRY_AUTH_TOKEN, pomijam wysyłkę map (slady JS beda nieczytelne)")
|
|
return
|
|
if not SENTRY_UPLOADER.exists():
|
|
print(f"Sentry: nie znalazlem {SENTRY_UPLOADER}, pomijam wysyłkę map")
|
|
return
|
|
# Skrypt Sentry sięga po konfigurację Expo, gdy brakuje KTÓREGOKOLWIEK z org, project
|
|
# i url, i wtedy kończy błędem. Dla sentry.io ten adres jest stały, więc podstawiamy
|
|
# domyślny i zostajemy przy zmiennych środowiskowych.
|
|
# Musi też działać z katalogu `mobile`, bo zakłada, że stoi w korzeniu projektu.
|
|
try:
|
|
run(
|
|
["node", str(SENTRY_UPLOADER), str(DIST)],
|
|
cwd=MOBILE,
|
|
extra_env={"SENTRY_URL": os.environ.get("SENTRY_URL") or "https://sentry.io"},
|
|
)
|
|
except subprocess.CalledProcessError as e:
|
|
# Nieudana wysyłka map NIE MOŻE zablokować publikacji: gorsza jest apka bez
|
|
# poprawki niż nieczytelny ślad w Sentry.
|
|
print(f"Sentry: wysyłka map nie powiodła się ({e}), publikuję mimo to")
|
|
|
|
|
|
def strip_sourcemaps() -> None:
|
|
"""Kasuje mapy z `dist`, zanim cokolwiek pojedzie na serwer.
|
|
|
|
Mapa waży około 10,7 MB przy 3 MB paczki i zawiera cały kod źródłowy. `metadata.json`
|
|
jej nie wymienia, więc manifest bez niej działa, ale `scp -r .` wysłałby ją razem z
|
|
resztą i leżałaby publicznie obok paczki.
|
|
"""
|
|
removed = 0
|
|
freed = 0
|
|
for path in DIST.rglob("*.map"):
|
|
freed += path.stat().st_size
|
|
path.unlink()
|
|
removed += 1
|
|
if removed:
|
|
print(f"Usunięto {removed} map(y) z dist ({freed / 1e6:.1f} MB), nie ida na serwer")
|
|
|
|
|
|
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)
|
|
# `--source-maps` dokłada `.hbc.map`, potrzebną do odczytania śladów JS
|
|
# w Sentry. Mapa NIE jedzie na serwer, kasuje ją `strip_sourcemaps()`.
|
|
run(["npx", "expo", "export", "--platform", "android", "--source-maps"], cwd=MOBILE)
|
|
|
|
upload_sourcemaps()
|
|
strip_sourcemaps()
|
|
|
|
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())
|