Browser-back / ota.py
Nrighton233j
Add OTA update support (adapted from Messenger backend)
be03b51
Raw
History Blame Contribute Delete
6.03 kB
"""
ota.py — OTA update support for B24 Browser.
Adapted directly from the working, protocol-correct implementation in the
Messenger backend (messenger_backend_space2/app.py): same Expo Updates
protocol contract (plain-JSON manifest, no code signing), same asset
hashing scheme, same admin-upload flow. Scoped to Browser-back's own
ota_db.py instead of sharing Messenger's database, and uploaded under a
distinct "browser-ota/" path prefix so the two apps' OTA assets never
collide even if they share the same HF dataset repo. Android-only for
now, matching Messenger's actual current scope.
"""
import hashlib
import base64
import uuid
import io
import json
import zipfile
import os
from datetime import datetime, timezone
import ota_db as db
APP_UPDATE_DATASET_REPO = os.environ.get("APP_UPDATE_DATASET_REPO", "Brighton233j/APP_UPDATES")
OTA_PATH_PREFIX = "browser-ota" # distinct from Messenger's "ota/" prefix in the same dataset repo
def _asset_key(file_bytes):
"""Stable per-asset id (md5 of content) — mirrors the 'key' field
Expo's own export tooling uses for on-device asset caching."""
return hashlib.md5(file_bytes).hexdigest()
def _sha256_base64url(file_bytes):
digest = hashlib.sha256(file_bytes).digest()
return base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=")
def _uuid_from_update_id(update_id):
"""Deterministic UUID derived from our own update_id, in the same spirit
as Expo's reference server's convertSHA256HashToUUID()."""
hex_digest = hashlib.sha256(update_id.encode("utf-8")).hexdigest()
return str(uuid.UUID(hex_digest[:32]))
def _content_type_for(rel_path):
if rel_path.endswith(".js"):
return "application/javascript"
if rel_path.endswith(".png"):
return "image/png"
if rel_path.endswith(".jpg") or rel_path.endswith(".jpeg"):
return "image/jpeg"
if rel_path.endswith(".ttf") or rel_path.endswith(".otf"):
return "font/ttf"
if rel_path.endswith(".json"):
return "application/json"
return "application/octet-stream"
def process_upload(file_storage, runtime_version, notes=None):
"""Takes the uploaded zip (dist/ folder from `npx expo export
--platform android --output-dir dist`), uploads every asset to the HF
dataset repo, and records the new manifest as the latest OTA update.
Returns (result_dict, error_tuple) — exactly one of the two is None.
error_tuple is (body_dict, status_code), ready to jsonify+return.
"""
from huggingface_hub import HfApi
update_id = str(uuid.uuid4())
try:
zf = zipfile.ZipFile(io.BytesIO(file_storage.read()))
names = zf.namelist()
meta_name = next((n for n in names if n.endswith("metadata.json")), None)
if not meta_name:
return None, ({"error": "metadata.json not found — zip the dist/ folder produced by expo export"}, 400)
metadata = json.loads(zf.read(meta_name))
base_dir = meta_name[: -len("metadata.json")]
api = HfApi(token=os.environ.get("HF_TOKEN") or os.environ.get("hf_token"))
def upload_asset(rel_path):
data = zf.read(base_dir + rel_path)
path_in_repo = f"{OTA_PATH_PREFIX}/{runtime_version}/{update_id}/{rel_path}"
api.upload_file(
path_or_fileobj=data,
path_in_repo=path_in_repo,
repo_id=APP_UPDATE_DATASET_REPO,
repo_type="dataset",
)
url = f"https://huggingface.co/datasets/{APP_UPDATE_DATASET_REPO}/resolve/main/{path_in_repo}"
return url, data
android_meta = metadata["fileMetadata"]["android"]
bundle_url, bundle_bytes = upload_asset(android_meta["bundle"])
launch_asset = {
"hash": _sha256_base64url(bundle_bytes),
"key": _asset_key(bundle_bytes),
"contentType": "application/javascript",
"url": bundle_url,
}
assets = []
for a in android_meta.get("assets", []):
url, data = upload_asset(a["path"])
assets.append({
"hash": _sha256_base64url(data),
"key": _asset_key(data),
"contentType": _content_type_for(a["path"]),
"url": url,
})
db.set_latest_ota_update(update_id, runtime_version, json.dumps(launch_asset), json.dumps(assets), notes)
except KeyError as e:
return None, ({"error": f"Unexpected metadata.json shape, missing {e}"}, 400)
except Exception as e:
return None, ({"error": f"OTA upload failed: {e}"}, 500)
return {
"status": "updated",
"update_id": update_id,
"runtime_version": runtime_version,
"asset_count": len(assets) + 1,
}, None
def build_manifest(protocol_version, platform, runtime_version, current_update_id):
"""Returns (manifest_dict, error_tuple) — exactly one is None.
error_tuple is (body_dict, status_code), ready to jsonify+return."""
if platform != "android":
return None, ({"error": "Unsupported platform. Expected android."}, 400)
if not runtime_version:
return None, ({"error": "No runtimeVersion provided."}, 400)
update = db.get_latest_ota_update()
if not update or update.get("runtime_version") != runtime_version:
return None, ({"error": "No update available for this runtime version."}, 404)
manifest_id = _uuid_from_update_id(update["update_id"])
if protocol_version == "1" and current_update_id == manifest_id:
return None, ({"error": "No update available."}, 406)
created_at = datetime.fromtimestamp(update["created_at"], tz=timezone.utc).isoformat()
manifest = {
"id": manifest_id,
"createdAt": created_at,
"runtimeVersion": runtime_version,
"launchAsset": json.loads(update["launch_asset_json"]),
"assets": json.loads(update["assets_json"]),
"metadata": {},
"extra": {},
}
return manifest, None