goon/scripts/publish_update.py
https://github.com/goon-foss/goon 7979d5fa61 session work: bug-report fixes + WIP cleanup
User-facing bugs resolved (per bug_reports table 2026-05-25):
- 40cd28aa (short-scene filter): mobile api.ts default min_duration_sec=60
  hides 6519 sub-60s scenes across all list endpoints (Performer/Site/Tag/
  Browse). Caller may override with explicit 0.
- 5e89ef7e (porndoe needs cookies/play click): INJECTED_JS in PlayerScreen
  now auto-clicks player-poster overlay (player-poster-play, big-play-button,
  vjs-big-play-button, jw-icon-display, btn-big-play, mejs__overlay-button,
  play-button, btn-play, videoPlayButton). Triggered same interval as
  consent-dismiss + ad-iframe removal.
- b1b5e1a2 (Mixdrop czarny ekran): re-enable mixdrop direct stream via VPS
  curl_cffi proxy (was: skip → WebView fallback → blank screen). Backend
  pipeline (mixdrop.py extract + stream_proxy._curl_cffi_stream with JA3 +
  auto-refetch on token expire) was already complete; just removed the skip
  in app/api/playback.py.

Plus ongoing WIP (paradisehill multi-part extraction, stream_proxy refetch
logic, gesture race fix for long-press 2x speed, anti-adblock INJECTED_JS
defenses, scripts for freshporno backfill, new sources API).
2026-05-25 22:02:52 +02:00

173 lines
6.5 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"
# 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())