Spaces:
Running
Running
| """ | |
| 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 | |