import os import re import json import yaml import zipfile import hashlib import shutil 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 # 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="

Local App Store

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 _apps_cache: list = [] _cache_valid = False def get_apps() -> list: """Get list of apps, scanning if cache is invalid.""" global _apps_cache, _cache_valid if not _cache_valid: _apps_cache = scan_repository(str(REPO_PATH)) _cache_valid = True print(f"Repository scanned: {_apps_cache.__len__()} apps found") return _apps_cache def invalidate_cache(): """Invalidate the apps cache.""" global _cache_valid _cache_valid = False 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.""" screenshots_dir = REPO_PATH / f"{app_id}_screenshots" screenshot_path = screenshots_dir / filename 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") 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(str(apk.filename)) 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(await apk.read()) # 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)