import os import re import json import time import yaml import zipfile import hashlib import shutil import threading import asyncio from pathlib import Path from typing import Optional from fastapi import FastAPI, HTTPException, Query, UploadFile, File, Form from fastapi.responses import FileResponse, Response, JSONResponse, HTMLResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from app import AppMetadata, AppListResponse, UpdateCheckRequest, UpdateCheckResponse from scanner import scan_repository, parse_manifest, parse_manifest_bytes # Load config config_path = Path(__file__).parent / "config.yaml" with open(config_path) as f: config = yaml.safe_load(f) REPO_PATH = Path(__file__).parent / config["repository"]["path"] HOST = config["server"]["host"] PORT = config["server"]["port"] # Ensure repos directory exists REPO_PATH.mkdir(parents=True, exist_ok=True) app = FastAPI(title="Local App Store", version="1.0.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) # Serve web frontend web_dir = Path(__file__).parent / "web" if web_dir.exists(): app.mount("/static", StaticFiles(directory=str(web_dir)), name="static") @app.get("/", response_class=HTMLResponse) def serve_web(): """Serve the web frontend.""" index_path = web_dir / "index.html" if index_path.exists(): return HTMLResponse(content=index_path.read_text()) return HTMLResponse(content="
Web frontend not found.
", status_code=503) # Serve pre-built APKs for easy download downloads_dir = Path(__file__).parent / "downloads" downloads_dir.mkdir(exist_ok=True) app.mount("/dl", StaticFiles(directory=str(downloads_dir)), name="downloads") # Cache of scanned apps (thread-safe with TTL) _apps_cache: list = [] _cache_timestamp: float = 0.0 _CACHE_TTL = 300 # 5 minutes _cache_lock = threading.Lock() def get_apps() -> list: """Get list of apps, scanning if cache is stale or invalid.""" global _apps_cache, _cache_timestamp with _cache_lock: if not _apps_cache or (time.time() - _cache_timestamp) > _CACHE_TTL: _apps_cache = scan_repository(str(REPO_PATH)) _cache_timestamp = time.time() print(f"Repository scanned: {len(_apps_cache)} apps found") return _apps_cache def invalidate_cache(): """Invalidate the apps cache.""" global _cache_timestamp with _cache_lock: _cache_timestamp = 0 def find_app(app_id: str) -> Optional[dict]: """Find app by ID.""" for app in get_apps(): if app["id"] == app_id: return app return None def find_app_by_package(package_name: str) -> Optional[dict]: """Find app by package name.""" for app in get_apps(): if app["package_name"] == package_name: return app return None @app.get("/api/apps") def list_apps(search: Optional[str] = Query(None), offset: int = 0, limit: int = 50): """List all apps in the repository.""" apps = get_apps() if search: search_lower = search.lower() apps = [ a for a in apps if search_lower in a["name"].lower() or search_lower in a["description"].lower() ] total = len(apps) paginated = apps[offset:offset + limit] return AppListResponse( apps=[AppMetadata(**a) for a in paginated], total=total, ) @app.get("/api/apps/{app_id}") def get_app(app_id: str): """Get details for a specific app.""" app_data = find_app(app_id) if not app_data: raise HTTPException(status_code=404, detail=f"App '{app_id}' not found") return AppMetadata(**app_data) @app.get("/api/apps/{app_id}/download") def download_app(app_id: str): """Download the APK file for an app.""" app_data = find_app(app_id) if not app_data: raise HTTPException(status_code=404, detail=f"App '{app_id}' not found") apk_path = Path(app_data["file_path"]) if not apk_path.exists(): raise HTTPException(status_code=404, detail="APK file not found") # Generate ETag from file content hash file_stat = apk_path.stat() etag = hashlib.md5( f"{apk_path.name}:{file_stat.st_size}:{file_stat.st_mtime}".encode() ).hexdigest() return FileResponse( path=str(apk_path), media_type="application/vnd.android.package-archive", filename=f"{app_data['package_name']}_{app_data['version_name']}.apk", headers={ "Content-Disposition": f"attachment; filename={app_data['package_name']}_{app_data['version_name']}.apk", "ETag": etag, "X-App-Package": app_data["package_name"], "X-App-Version": app_data["version_name"], "X-App-VersionCode": app_data["version_code"], }, ) @app.get("/api/apps/{app_id}/icon") def get_app_icon(app_id: str): """Get the icon for an app.""" app_data = find_app(app_id) if not app_data: raise HTTPException(status_code=404, detail=f"App '{app_id}' not found") if not app_data["icon"]: raise HTTPException(status_code=404, detail="No icon available for this app") apk_path = Path(app_data["file_path"]) if not apk_path.exists(): raise HTTPException(status_code=404, detail="APK file not found") try: import zipfile from io import BytesIO from PIL import Image with zipfile.ZipFile(apk_path) as z: if app_data["icon"] not in z.namelist(): raise HTTPException(status_code=404, detail="Icon resource not found in APK") icon_data = z.read(app_data["icon"]) # Try to process as image (for proper format/size) img = Image.open(BytesIO(icon_data)) img = img.resize((128, 128), Image.Resampling.LANCZOS) output = BytesIO() img.save(output, format="PNG") return Response( content=output.getvalue(), media_type="image/png", headers={"Cache-Control": "public, max-age=86400"}, ) except Exception as e: # If icon processing fails, return raw data try: with zipfile.ZipFile(apk_path) as z: icon_data = z.read(app_data["icon"]) return Response( content=icon_data, media_type="image/png", headers={"Cache-Control": "public, max-age=86400"}, ) except Exception: raise HTTPException(status_code=500, detail=f"Error reading icon: {e}") @app.get("/api/apps/{app_id}/screenshots") def get_app_screenshots(app_id: str): """Get screenshots for an app.""" app_data = find_app(app_id) if not app_data: raise HTTPException(status_code=404, detail=f"App '{app_id}' not found") screenshots_dir = REPO_PATH / f"{app_id}_screenshots" if not screenshots_dir.exists(): return {"screenshots": []} screenshots = [] for img_file in sorted(screenshots_dir.glob("*.png")): screenshots.append({ "name": img_file.stem, "url": f"/api/apps/{app_id}/screenshots/{img_file.name}", }) return {"screenshots": screenshots} @app.get("/api/apps/{app_id}/screenshots/{filename}") def get_screenshot(app_id: str, filename: str): """Get a specific screenshot.""" safe_filename = os.path.basename(filename) screenshots_dir = REPO_PATH / f"{app_id}_screenshots" screenshot_path = (screenshots_dir / safe_filename).resolve() if not str(screenshot_path).startswith(str(screenshots_dir.resolve())): raise HTTPException(status_code=400, detail="Invalid path") if not screenshot_path.exists(): raise HTTPException(status_code=404, detail="Screenshot not found") return FileResponse( path=str(screenshot_path), media_type="image/png", headers={"Cache-Control": "public, max-age=86400"}, ) @app.post("/api/apps/update-check") def check_for_updates(request: UpdateCheckRequest): """Check if an installed app has an available update.""" app_data = find_app_by_package(request.package_name) if not app_data: return UpdateCheckResponse( has_update=False, current_version_code=request.current_version_code, ) current = int(request.current_version_code) latest = int(app_data["version_code"]) return UpdateCheckResponse( has_update=latest > current, app=AppMetadata(**app_data) if latest > current else None, current_version_code=request.current_version_code, latest_version_code=app_data["version_code"], ) def _sanitize_app_id(name: str) -> str: """Derive a safe app_id from filename or provided name.""" base = Path(name).stem sanitized = re.sub(r'[^a-z0-9\-]', '-', base.lower()) sanitized = re.sub(r'-+', '-', sanitized).strip('-') return sanitized or "app" @app.post("/api/upload") async def upload_app( apk: UploadFile = File(...), metadata: Optional[UploadFile] = File(None), app_id: Optional[str] = Form(None), ): """Upload an APK to the repository. - **apk**: The APK file (required) - **app_id**: Short lowercase ID for the app (optional, derived from filename) - **metadata**: JSON metadata file (optional, required for name/description) """ if not apk.filename or not apk.filename.endswith(".apk"): raise HTTPException(400, "File must be an APK") contents = await apk.read() if len(contents) < 4 or contents[:4] != b"PK\x03\x04": raise HTTPException(400, "File is not a valid APK (invalid ZIP magic bytes)") target_id = app_id or _sanitize_app_id(apk.filename) apk_target = REPO_PATH / f"{target_id}.apk" # Check for duplicate if apk_target.exists(): existing = parse_manifest(str(apk_target)) new = parse_manifest_bytes(contents) if existing.get("version_code") == new.get("version_code"): raise HTTPException(409, f"App '{target_id}' already exists with same version") # Save APK apk_target.write_bytes(contents) # Save metadata JSON if provided if metadata and metadata.filename and metadata.filename.endswith(".json"): json_bytes = await metadata.read() metadata_json = json.loads(json_bytes) required_fields = ("name", "description") missing = [f for f in required_fields if f not in metadata_json] if missing: apk_target.unlink(missing_ok=True) raise HTTPException(400, f"Metadata JSON requires: {', '.join(missing)}") # Coerce numeric fields to strings for consistency for key in ("version_name", "version_code", "min_sdk", "target_sdk"): if key in metadata_json: metadata_json[key] = str(metadata_json[key]) json_target = REPO_PATH / f"{target_id}.json" json_target.write_text(json.dumps(metadata_json, indent=2)) # Handle screenshots if included in metadata screenshots = metadata_json.get("screenshots", []) if screenshots: ss_dir = REPO_PATH / f"{target_id}_screenshots" ss_dir.mkdir(exist_ok=True) # Copy to downloads directory dl_target = downloads_dir / f"{target_id}.apk" shutil.copy2(str(apk_target), str(dl_target)) # Rescan invalidate_cache() apps = get_apps() uploaded = find_app(target_id) if not uploaded: apk_target.unlink(missing_ok=True) dl_target.unlink(missing_ok=True) json_target_exists = REPO_PATH / f"{target_id}.json" if json_target_exists.exists(): json_target_exists.unlink() raise HTTPException(500, "Upload succeeded but app not found after scan") return { "id": uploaded["id"], "name": uploaded["name"], "package_name": uploaded["package_name"], "version_name": uploaded["version_name"], "version_code": uploaded["version_code"], "message": f"App '{target_id}' uploaded successfully", } @app.post("/api/scan") def trigger_scan(): """Manually trigger repository scan.""" invalidate_cache() apps = get_apps() return {"scanned": True, "apps_count": len(apps)} @app.get("/api/status") def server_status(): """Get server status information.""" apps = get_apps() return { "server": "Local App Store", "version": "1.0.0", "apps_count": len(apps), "repo_path": str(REPO_PATH), } if __name__ == "__main__": import uvicorn # Initial scan on startup get_apps() uvicorn.run(app, host=HOST, port=PORT)