From c398c06784345cf1360d42c40c1b30066ac1af65 Mon Sep 17 00:00:00 2001 From: Fred Porter Date: Sun, 13 Sep 2026 22:21:52 +0800 Subject: [PATCH 1/7] feat(dispatch): add sovereign dispatch engine, email carrier, and tri-mode zen story player --- backend/app/api/dispatch_api.py | 185 ++++ backend/app/api/routes.py | 9 + backend/app/services/device_calc.py | 143 +++ backend/app/services/dispatch_store.py | 202 ++++ backend/app/services/email_dispatch.py | 162 ++++ backend/tests/test_dispatch_api.py | 214 +++++ frontend-vue/src/layouts/AppShell.vue | 10 +- frontend-vue/src/router/index.ts | 6 + .../dispatch/DispatchStoryView.test.ts | 120 +++ .../surfaces/dispatch/DispatchStoryView.vue | 886 ++++++++++++++++++ 10 files changed, 1935 insertions(+), 2 deletions(-) create mode 100644 backend/app/api/dispatch_api.py create mode 100644 backend/app/services/device_calc.py create mode 100644 backend/app/services/dispatch_store.py create mode 100644 backend/app/services/email_dispatch.py create mode 100644 backend/tests/test_dispatch_api.py create mode 100644 frontend-vue/src/surfaces/dispatch/DispatchStoryView.test.ts create mode 100644 frontend-vue/src/surfaces/dispatch/DispatchStoryView.vue diff --git a/backend/app/api/dispatch_api.py b/backend/app/api/dispatch_api.py new file mode 100644 index 0000000..081750d --- /dev/null +++ b/backend/app/api/dispatch_api.py @@ -0,0 +1,185 @@ +"""Dispatch API — Sovereign communication, interactive story tokens, and device calculation. + +POST /api/dispatch/create — create a dispatch and stage an ephemeral token +GET /api/dispatch/token/{token} — load story document for guest token +POST /api/dispatch/rsvp/{token} — submit guest RSVP / responses +GET /api/dispatch/preview-email/{id} — render bulletproof single-column Prose HTML email +GET /api/dispatch/responses/{id} — get collected responses for host +POST /api/dispatch/calculate-display — calculate grid & prose capabilities for a viewport +POST /api/dispatch/calculate-storage — calculate vault knowledge & capsule capacity +GET /api/dispatch/asset/{id}/{file} — serve preserved original asset +""" +from __future__ import annotations + +import base64 +import logging +from pathlib import Path + +from aiohttp import web + +from app.services.device_calc import calc_display_capability, calc_storage_capacity +from app.services.dispatch_store import dispatch_store +from app.services.email_dispatch import compile_prose_email + +log = logging.getLogger("ucore.dispatch_api") + + +async def handle_create_dispatch(request: web.Request) -> web.Response: + try: + data = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON body"}, status=400) + + title = data.get("title", "").strip() + if not title: + return web.json_response({"error": "Title is required"}, status=400) + + story_md = data.get("story_markdown", "").strip() + lead_text = data.get("lead_text", "").strip() + burn_after_read = bool(data.get("burn_after_read", False)) + expires_in = data.get("expires_in_seconds") + mode_default = data.get("mode_default", "card") + + hero_gif_bytes = None + if data.get("hero_gif_base64"): + try: + hero_gif_bytes = base64.b64decode(data["hero_gif_base64"]) + except Exception as exc: + log.warning("Invalid base64 in hero_gif: %s", exc) + + manifest = dispatch_store.create_dispatch( + title=title, + story_markdown=story_md, + lead_text=lead_text, + hero_gif_bytes=hero_gif_bytes, + expires_in_seconds=int(expires_in) if expires_in else None, + burn_after_read=burn_after_read, + mode_default=mode_default, + ) + + return web.json_response({ + "status": "ok", + "dispatch": manifest, + "token": manifest["token"], + "story_url": f"/p/{manifest['token']}", + }) + + +async def handle_get_by_token(request: web.Request) -> web.Response: + token = request.match_info.get("token", "").strip() + if not token: + return web.json_response({"error": "Token is required"}, status=400) + + record = dispatch_store.get_dispatch_by_token(token) + if record.get("status") == "not_found": + return web.json_response(record, status=404) + + return web.json_response(record) + + +async def handle_submit_rsvp(request: web.Request) -> web.Response: + token = request.match_info.get("token", "").strip() + if not token: + return web.json_response({"error": "Token is required"}, status=400) + + try: + payload = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON body"}, status=400) + + result = dispatch_store.submit_rsvp(token, payload) + if result.get("status") == "not_found": + return web.json_response(result, status=404) + if result.get("status") in ("expired", "burned"): + return web.json_response(result, status=410) + + return web.json_response(result) + + +async def handle_preview_email(request: web.Request) -> web.Response: + dispatch_id = request.match_info.get("id", "").strip() + dispatch_dir = dispatch_store.root_dir / dispatch_id + manifest_path = dispatch_dir / "dispatch.json" + + if not manifest_path.exists(): + return web.Response(text="Dispatch not found", status=404) + + import json + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + story_path = dispatch_dir / "originals" / "source.story.md" + story_md = story_path.read_text(encoding="utf-8") if story_path.exists() else "" + + token = manifest.get("token", "") + action_url = f"/p/{token}" + hero_src = manifest.get("hero_asset") + + html_email = compile_prose_email( + title=manifest.get("title", "Sovereign Dispatch"), + lead_text=manifest.get("lead_text", ""), + body_html=f"

{story_md[:400]}...

" if len(story_md) > 400 else f"

{story_md}

", + action_label="Open Interactive Story", + action_url=action_url, + hero_gif_src=hero_src, + theme="dark", + ) + + return web.Response(text=html_email, content_type="text/html") + + +async def handle_get_responses(request: web.Request) -> web.Response: + dispatch_id = request.match_info.get("id", "").strip() + responses = dispatch_store.get_responses(dispatch_id) + return web.json_response({"id": dispatch_id, "responses": responses}) + + +async def handle_calculate_display(request: web.Request) -> web.Response: + try: + data = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON body"}, status=400) + + w = int(data.get("width", 1920)) + h = int(data.get("height", 1080)) + dev_type = data.get("device_type") + res = calc_display_capability(w, h, dev_type) + return web.json_response(res) + + +async def handle_calculate_storage(request: web.Request) -> web.Response: + try: + data = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON body"}, status=400) + + total_bytes = int(data.get("total_bytes", 4_000_000_000)) + sys_bytes = data.get("sys_bytes") + res = calc_storage_capacity(total_bytes, int(sys_bytes) if sys_bytes is not None else None) + return web.json_response(res) + + +async def handle_serve_asset(request: web.Request) -> web.Response: + dispatch_id = request.match_info.get("id", "").strip() + filename = request.match_info.get("file", "").strip() + + # Prevent traversal + if ".." in dispatch_id or ".." in filename or "/" in filename or "\\" in filename: + return web.Response(text="Forbidden", status=403) + + target_file = dispatch_store.root_dir / dispatch_id / "originals" / filename + if not target_file.exists() or not target_file.is_file(): + return web.Response(text="Asset not found", status=404) + + content_type = "image/gif" if filename.endswith(".gif") else "application/octet-stream" + return web.Response(body=target_file.read_bytes(), content_type=content_type) + + +def register_dispatch_routes(app: web.Application) -> None: + """Register all dispatch and device capability routes.""" + app.router.add_post("/api/dispatch/create", handle_create_dispatch) + app.router.add_get("/api/dispatch/token/{token}", handle_get_by_token) + app.router.add_post("/api/dispatch/rsvp/{token}", handle_submit_rsvp) + app.router.add_get("/api/dispatch/preview-email/{id}", handle_preview_email) + app.router.add_get("/api/dispatch/responses/{id}", handle_get_responses) + app.router.add_post("/api/dispatch/calculate-display", handle_calculate_display) + app.router.add_post("/api/dispatch/calculate-storage", handle_calculate_storage) + app.router.add_get("/api/dispatch/asset/{id}/{file}", handle_serve_asset) diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 2a3c9d0..e11e47b 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -656,3 +656,12 @@ def register_routes(app: web.Application) -> None: log.debug("uVector API routes registered") except ImportError as e: log.debug("uVector API routes not available: %s", e) + + # ── Sovereign Dispatch & Device Capability API ──────────────────── + try: + from .dispatch_api import register_dispatch_routes + + register_dispatch_routes(app) + log.debug("Dispatch API routes registered") + except ImportError as e: + log.debug("Dispatch API routes not available: %s", e) diff --git a/backend/app/services/device_calc.py b/backend/app/services/device_calc.py new file mode 100644 index 0000000..5455709 --- /dev/null +++ b/backend/app/services/device_calc.py @@ -0,0 +1,143 @@ +"""Universal Device Display and Container Storage Capability Calculator. + +Implements authoritative mathematical formulas from: +- global-knowledge/standards/DEVICE-DISPLAY-STANDARDS.md +- global-knowledge/standards/CONTAINER-STANDARDS.md +""" +from __future__ import annotations + +from typing import Any, Dict, Optional + + +def calc_display_capability( + width: int, + height: int, + device_type: Optional[str] = None, +) -> Dict[str, Any]: + """Calculate display capabilities, character grid capacity, reading measure, + + and Zen viewport scaling for a given screen resolution. + """ + w = max(1, int(width)) + h = max(1, int(height)) + aspect_ratio_val = w / h + + # Determine reference aspect and canvas + if aspect_ratio_val < 1.5: + target_aspect = "4:3" + ref_w, ref_h = 800, 600 + else: + target_aspect = "16:9" + ref_w, ref_h = 1280, 720 + + scale_factor = round(min(w / ref_w, h / ref_h), 3) + + # GridCore Cell Algebra + sq_cols = w // 8 + sq_rows = h // 8 + tall_cols = w // 12 + tall_rows = h // 20 + super_cols = w // 24 + super_rows = h // 40 + + fits_teletext_1x = (w >= 480) and (h >= 500) + fits_teletext_2x = (w >= 960) and (h >= 1000) + + # Prose Line Measure + # Standard character measure: 1ch approx 0.6 * 16px = 9.6px + char_capacity = int(w / 9.6) + if w < 480: + layout_mode = "mobile_reflow" + recommended_measure_ch = 40 + max_width_px = w + elif w < 768: + layout_mode = "compact_tablet" + recommended_measure_ch = 60 + max_width_px = min(w - 32, 580) + else: + layout_mode = "optimal_zen" + recommended_measure_ch = 72 + max_width_px = 680 + + # BOB Blitter Object Memory Budget + # Typical 64x64 BOB with 12 frames at 1 byte/pixel (indexed palette) + typical_bob_ram = 64 * 64 * 12 * 1 + + return { + "resolution": {"width": w, "height": h}, + "aspect_ratio": target_aspect, + "aspect_ratio_ratio": round(aspect_ratio_val, 2), + "device_type": device_type or "auto", + "gridcore": { + "dot_px": 4, + "square_register": {"cols": sq_cols, "rows": sq_rows, "cell_px": "8x8"}, + "tall_register": {"cols": tall_cols, "rows": tall_rows, "cell_px": "12x20"}, + "super_cells": {"cols": super_cols, "rows": super_rows, "tile_px": "24x40"}, + "teletext_standard": { + "required_px": "480x500", + "fits_1x": fits_teletext_1x, + "fits_2x": fits_teletext_2x, + }, + }, + "prose": { + "char_capacity_ch": char_capacity, + "layout_mode": layout_mode, + "recommended_measure_ch": recommended_measure_ch, + "max_width_px": max_width_px, + }, + "zen_viewport": { + "target_aspect": target_aspect, + "reference_resolution": {"width": ref_w, "height": ref_h}, + "scale_factor": scale_factor, + "scaled_width": int(ref_w * scale_factor), + "scaled_height": int(ref_h * scale_factor), + "letterbox": { + "horizontal_padding_px": max(0, (w - int(ref_w * scale_factor)) // 2), + "vertical_padding_px": max(0, (h - int(ref_h * scale_factor)) // 2), + }, + }, + "bob_budget": { + "max_dimensions_px": {"width": 128, "height": 128}, + "typical_dimensions_px": {"width": 64, "height": 64}, + "max_frames": 24, + "recommended_fps": 12, + "typical_ram_bytes": typical_bob_ram, + "max_file_size_kb": 60, + }, + } + + +def calc_storage_capacity( + total_bytes: int, + sys_bytes: Optional[int] = None, +) -> Dict[str, Any]: + """Calculate usable vault knowledge capacity, indexed search capacity, + + and capsule budgets for a given storage size. + """ + total = max(0, int(total_bytes)) + # Default system overhead: 2.5 GB for base Linux/CMMint + uCore runtime + overhead = int(sys_bytes if sys_bytes is not None else 2_500_000_000) + usable_vault = max(0, total - overhead) + + # Document & Asset density metrics + plain_note_bytes = 3_500 + fts5_indexed_note_bytes = 4_900 # 1.4x + bob_asset_bytes = 25_000 + mini_capsule_bytes = 10_000_000 # 10 MB + zim_capsule_bytes = 1_000_000_000 # 1 GB + + return { + "storage_total_bytes": total, + "storage_system_bytes": overhead, + "storage_vault_usable_bytes": usable_vault, + "storage_vault_usable_mb": round(usable_vault / (1024 * 1024), 2), + "storage_vault_usable_gb": round(usable_vault / (1024 * 1024 * 1024), 3), + "knowledge_capacity": { + "plain_prose_notes": usable_vault // plain_note_bytes, + "indexed_notes_fts5": usable_vault // fts5_indexed_note_bytes, + "curated_bobs_gif": usable_vault // bob_asset_bytes, + "mini_capsules_10mb": usable_vault // mini_capsule_bytes, + "encyclopedia_zim_capsules_1gb": usable_vault // zim_capsule_bytes, + }, + } diff --git a/backend/app/services/dispatch_store.py b/backend/app/services/dispatch_store.py new file mode 100644 index 0000000..3cf2596 --- /dev/null +++ b/backend/app/services/dispatch_store.py @@ -0,0 +1,202 @@ +"""Dispatch Store & Ephemeral Token Manager. + +Manages interactive story dispatches, ephemeral access tokens (/p/:token), +RSVP submission records, and preserves raw originals under ~/Vault/dispatches//originals/. +""" +from __future__ import annotations + +import json +import logging +import secrets +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +from app.core.settings import settings + +log = logging.getLogger("ucore.dispatch_store") + + +class DispatchStore: + """Sovereign store for interactive dispatches and RSVPs.""" + + def __init__(self, root_dir: Optional[Path] = None, token_index_path: Optional[Path] = None): + self.root_dir = root_dir or (settings.vault_root / "dispatches") + self.token_index_path = token_index_path or (settings.data_dir / "dispatch_tokens.json") + self.root_dir.mkdir(parents=True, exist_ok=True) + self.token_index_path.parent.mkdir(parents=True, exist_ok=True) + self._load_tokens() + + def _load_tokens(self) -> Dict[str, str]: + if not self.token_index_path.exists(): + return {} + try: + data = json.loads(self.token_index_path.read_text(encoding="utf-8")) + if isinstance(data, dict): + return data + except Exception as exc: + log.warning("Could not read dispatch token index: %s", exc) + return {} + + def _save_tokens(self, tokens: Dict[str, str]) -> None: + try: + self.token_index_path.write_text(json.dumps(tokens, indent=2), encoding="utf-8") + except Exception as exc: + log.error("Failed to write dispatch token index: %s", exc) + + def create_dispatch( + self, + title: str, + story_markdown: str, + lead_text: str = "", + hero_gif_bytes: Optional[bytes] = None, + expires_in_seconds: Optional[int] = None, + burn_after_read: bool = False, + mode_default: str = "card", + ) -> Dict[str, Any]: + """Create a new dispatch, generate an ephemeral token, and preserve originals.""" + timestamp = int(time.time()) + dispatch_id = f"disp_{timestamp}_{secrets.token_hex(4)}" + token = secrets.token_urlsafe(16) + + dispatch_dir = self.root_dir / dispatch_id + originals_dir = dispatch_dir / "originals" + originals_dir.mkdir(parents=True, exist_ok=True) + + # Preserve original markdown + (originals_dir / "source.story.md").write_text(story_markdown, encoding="utf-8") + + # Save hero GIF if provided + hero_rel_path: Optional[str] = None + if hero_gif_bytes: + hero_file = originals_dir / "hero.gif" + hero_file.write_bytes(hero_gif_bytes) + hero_rel_path = f"/api/dispatch/asset/{dispatch_id}/hero.gif" + + expires_at: Optional[int] = None + if expires_in_seconds and expires_in_seconds > 0: + expires_at = timestamp + expires_in_seconds + + manifest = { + "id": dispatch_id, + "token": token, + "title": title, + "lead_text": lead_text, + "created_at": timestamp, + "created_iso": datetime.now(timezone.utc).isoformat(), + "expires_at": expires_at, + "burn_after_read": bool(burn_after_read), + "mode_default": mode_default, + "hero_asset": hero_rel_path, + "view_count": 0, + "status": "active", + } + + (dispatch_dir / "dispatch.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + (dispatch_dir / "responses.json").write_text("[]", encoding="utf-8") + + # Update token index + tokens = self._load_tokens() + tokens[token] = dispatch_id + self._save_tokens(tokens) + + return manifest + + def get_dispatch_by_token(self, token: str, record_view: bool = True) -> Dict[str, Any]: + """Retrieve dispatch by guest token, evaluating expiration and burn status.""" + tokens = self._load_tokens() + dispatch_id = tokens.get(token) + if not dispatch_id: + return {"status": "not_found", "error": "Dispatch not found"} + + dispatch_dir = self.root_dir / dispatch_id + manifest_path = dispatch_dir / "dispatch.json" + if not manifest_path.exists(): + return {"status": "not_found", "error": "Dispatch manifest missing"} + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + + # Check burn status + if manifest.get("status") == "burned": + return { + "status": "burned", + "tombstone": "This dispatch was ephemeral and has dissolved into the ether.", + } + + # Check expiration + expires_at = manifest.get("expires_at") + now = int(time.time()) + if expires_at and now > expires_at: + manifest["status"] = "expired" + manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + return { + "status": "expired", + "tombstone": "This dispatch has expired and dissolved into the ether.", + } + + # Record view count + if record_view: + manifest["view_count"] = manifest.get("view_count", 0) + 1 + manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + + # Read story markdown + story_path = dispatch_dir / "originals" / "source.story.md" + story_md = story_path.read_text(encoding="utf-8") if story_path.exists() else "" + + return { + "status": "active", + "dispatch": manifest, + "story_markdown": story_md, + } + + def submit_rsvp(self, token: str, payload: Dict[str, Any]) -> Dict[str, Any]: + """Submit response to a dispatch, updating responses.json and burning if configured.""" + record = self.get_dispatch_by_token(token, record_view=False) + if record.get("status") != "active": + return record + + manifest = record["dispatch"] + dispatch_id = manifest["id"] + dispatch_dir = self.root_dir / dispatch_id + responses_path = dispatch_dir / "responses.json" + + responses: List[Dict[str, Any]] = [] + if responses_path.exists(): + try: + responses = json.loads(responses_path.read_text(encoding="utf-8")) + except Exception: + responses = [] + + entry = { + "timestamp": int(time.time()), + "submitted_iso": datetime.now(timezone.utc).isoformat(), + "data": payload, + } + responses.append(entry) + responses_path.write_text(json.dumps(responses, indent=2), encoding="utf-8") + + # Burn token if configured + if manifest.get("burn_after_read"): + manifest["status"] = "burned" + (dispatch_dir / "dispatch.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + + return { + "status": "success", + "message": "Response recorded peacefully", + "burned": manifest.get("burn_after_read", False), + } + + def get_responses(self, dispatch_id: str) -> List[Dict[str, Any]]: + """Host query for all collected responses.""" + responses_path = self.root_dir / dispatch_id / "responses.json" + if not responses_path.exists(): + return [] + try: + return json.loads(responses_path.read_text(encoding="utf-8")) + except Exception: + return [] + + +# Global singleton +dispatch_store = DispatchStore() diff --git a/backend/app/services/email_dispatch.py b/backend/app/services/email_dispatch.py new file mode 100644 index 0000000..859d47b --- /dev/null +++ b/backend/app/services/email_dispatch.py @@ -0,0 +1,162 @@ +"""Sovereign Prose Email Compiler. + +Compiles bulletproof, single-column HTML emails adhering to USX Prose Standards: +- Strict max-width (580px/640px) +- MSO table fallbacks for Outlook compatibility +- System typography stacks (Charter serif, Inter sans) +- Embedded hero GIF (via CID or link) +- Zero tracking pixels and zero third-party web font downloads +""" +from __future__ import annotations + +import html +from typing import Optional + + +def compile_prose_email( + title: str, + lead_text: str, + body_html: str, + action_label: Optional[str] = None, + action_url: Optional[str] = None, + hero_gif_src: Optional[str] = None, + theme: str = "dark", +) -> str: + """Compile a single-column, bulletproof HTML email.""" + safe_title = html.escape(title) + safe_lead = html.escape(lead_text) + + # Color tokens based on USX dark or light theme + if theme == "light": + bg_color = "#F6F8FA" + card_bg = "#FFFFFF" + text_primary = "#1F2328" + text_muted = "#656D76" + border_color = "#D0D7DE" + btn_bg = "#0969DA" + btn_text = "#FFFFFF" + else: + bg_color = "#0D1117" + card_bg = "#161B22" + text_primary = "#E6EDF3" + text_muted = "#8B949E" + border_color = "#30363D" + btn_bg = "#58A6FF" + btn_text = "#0D1117" + + hero_block = "" + if hero_gif_src: + hero_block = f""" + + + Hero Animation + + + """ + + button_block = "" + if action_label and action_url: + safe_action_label = html.escape(action_label) + button_block = f""" + + + + + {safe_action_label} + + + + """ + + return f""" + + + + + + + {safe_title} + + + + +
+ + + {hero_block} + + + + + + + + + + {button_block} + + + +
+

{safe_title}

+
+

{safe_lead}

+
+ {body_html} +
+

+ Dispatched via sovereign uDos • Zero tracking pixels • Local first +

+
+ +
+ +""" + + +def generate_plain_text_email( + title: str, + lead_text: str, + body_text: str, + action_label: Optional[str] = None, + action_url: Optional[str] = None, +) -> str: + """Generate a clean plain-text fallback version.""" + lines = [ + title, + "=" * len(title), + "", + lead_text, + "", + body_text, + "", + ] + if action_label and action_url: + lines.extend([f"-> {action_label}: {action_url}", ""]) + lines.append("Dispatched via sovereign uDos (offline-first, zero tracking)") + return "\n".join(lines) diff --git a/backend/tests/test_dispatch_api.py b/backend/tests/test_dispatch_api.py new file mode 100644 index 0000000..2d50a05 --- /dev/null +++ b/backend/tests/test_dispatch_api.py @@ -0,0 +1,214 @@ +"""Tests for Sovereign Dispatch and Device Capability API. + +Verifies: +- POST /api/dispatch/calculate-display +- POST /api/dispatch/calculate-storage +- POST /api/dispatch/create +- GET /api/dispatch/token/{token} +- POST /api/dispatch/rsvp/{token} +- GET /api/dispatch/responses/{id} +- GET /api/dispatch/preview-email/{id} +- Ephemeral lifecycle: expires_at, burn_after_read dissolution +- Preservation of raw originals in ~/Vault/dispatches//originals/ +""" +from __future__ import annotations + +import time +from pathlib import Path + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from app.api.dispatch_api import register_dispatch_routes +from app.services.dispatch_store import dispatch_store + + +@pytest.fixture +async def dispatch_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Create test client with isolated dispatch store.""" + vault = tmp_path / "Vault" + dispatches = vault / "dispatches" + tokens_file = tmp_path / "dispatch_tokens.json" + + monkeypatch.setattr(dispatch_store, "root_dir", dispatches) + monkeypatch.setattr(dispatch_store, "token_index_path", tokens_file) + dispatches.mkdir(parents=True, exist_ok=True) + + app = web.Application() + register_dispatch_routes(app) + + async with TestClient(TestServer(app)) as client: + yield client + + +@pytest.mark.asyncio +async def test_calculate_display_pos(dispatch_client): + """Test display calculation for 800x600 POS kiosk.""" + resp = await dispatch_client.post("/api/dispatch/calculate-display", json={ + "width": 800, + "height": 600, + "device_type": "pos_kiosk", + }) + assert resp.status == 200 + data = await resp.json() + + assert data["aspect_ratio"] == "4:3" + assert data["gridcore"]["square_register"]["cols"] == 100 + assert data["gridcore"]["square_register"]["rows"] == 75 + assert data["gridcore"]["tall_register"]["cols"] == 66 + assert data["gridcore"]["tall_register"]["rows"] == 30 + assert data["gridcore"]["super_cells"]["cols"] == 33 + assert data["gridcore"]["super_cells"]["rows"] == 15 + assert data["gridcore"]["teletext_standard"]["fits_1x"] is True + assert data["zen_viewport"]["scale_factor"] == 1.0 + + +@pytest.mark.asyncio +async def test_calculate_display_ten_foot_tv(dispatch_client): + """Test display calculation for 1080p 16:9 TV.""" + resp = await dispatch_client.post("/api/dispatch/calculate-display", json={ + "width": 1920, + "height": 1080, + "device_type": "ten_foot_tv", + }) + assert resp.status == 200 + data = await resp.json() + + assert data["aspect_ratio"] == "16:9" + assert data["prose"]["layout_mode"] == "optimal_zen" + assert data["prose"]["recommended_measure_ch"] == 72 + assert data["prose"]["max_width_px"] == 680 + assert data["zen_viewport"]["target_aspect"] == "16:9" + assert data["zen_viewport"]["scale_factor"] == 1.5 + + +@pytest.mark.asyncio +async def test_calculate_storage_capacities(dispatch_client): + """Test storage calculation for 4GB USB stick.""" + resp = await dispatch_client.post("/api/dispatch/calculate-storage", json={ + "total_bytes": 4_000_000_000, + "sys_bytes": 2_500_000_000, + }) + assert resp.status == 200 + data = await resp.json() + + assert data["storage_vault_usable_bytes"] == 1_500_000_000 + assert data["knowledge_capacity"]["plain_prose_notes"] > 400_000 + assert data["knowledge_capacity"]["indexed_notes_fts5"] > 300_000 + assert data["knowledge_capacity"]["encyclopedia_zim_capsules_1gb"] == 1 + + +@pytest.mark.asyncio +async def test_dispatch_lifecycle_and_rsvp(dispatch_client): + """Test creating dispatch, loading via token, and submitting RSVP.""" + create_resp = await dispatch_client.post("/api/dispatch/create", json={ + "title": "Solstice Gathering", + "lead_text": "Join us under the stars", + "story_markdown": "# Solstice\n\nWelcome to the story.\n\n---\n\n? (Dietary): [text]\n\n---\n\nThank you!", + "burn_after_read": False, + }) + assert create_resp.status == 200 + created = await create_resp.json() + token = created["token"] + dispatch_id = created["dispatch"]["id"] + + # Verify originals preserved + orig_file = dispatch_store.root_dir / dispatch_id / "originals" / "source.story.md" + assert orig_file.exists() + assert "Solstice" in orig_file.read_text(encoding="utf-8") + + # Load via token + get_resp = await dispatch_client.get(f"/api/dispatch/token/{token}") + assert get_resp.status == 200 + get_data = await get_resp.json() + assert get_data["status"] == "active" + assert get_data["dispatch"]["title"] == "Solstice Gathering" + assert get_data["dispatch"]["view_count"] == 1 + + # Submit RSVP + rsvp_resp = await dispatch_client.post(f"/api/dispatch/rsvp/{token}", json={ + "dietary": "Vegetarian", + "attending": True, + }) + assert rsvp_resp.status == 200 + rsvp_data = await rsvp_resp.json() + assert rsvp_data["status"] == "success" + + # Host fetches responses + resp_list = await dispatch_client.get(f"/api/dispatch/responses/{dispatch_id}") + assert resp_list.status == 200 + resps = await resp_list.json() + assert len(resps["responses"]) == 1 + assert resps["responses"][0]["data"]["dietary"] == "Vegetarian" + + +@pytest.mark.asyncio +async def test_burn_after_read_dissolution(dispatch_client): + """Test that burn_after_read dissolves the dispatch into ether upon submission.""" + create_resp = await dispatch_client.post("/api/dispatch/create", json={ + "title": "Secret Token", + "story_markdown": "One-time view.\n---\n? Confirm: [ ]", + "burn_after_read": True, + }) + created = await create_resp.json() + token = created["token"] + + # First view succeeds + resp1 = await dispatch_client.get(f"/api/dispatch/token/{token}") + assert resp1.status == 200 + + # Submit RSVP triggers burn + rsvp_resp = await dispatch_client.post(f"/api/dispatch/rsvp/{token}", json={"confirmed": True}) + assert rsvp_resp.status == 200 + + # Subsequent access is burned / dissolved + resp2 = await dispatch_client.get(f"/api/dispatch/token/{token}") + assert resp2.status == 200 + data2 = await resp2.json() + assert data2["status"] == "burned" + assert "dissolved into the ether" in data2["tombstone"] + + +@pytest.mark.asyncio +async def test_expired_token_handling(dispatch_client, monkeypatch): + """Test token expiration.""" + create_resp = await dispatch_client.post("/api/dispatch/create", json={ + "title": "Expiring Event", + "story_markdown": "Expires fast.", + "expires_in_seconds": 1, + }) + created = await create_resp.json() + token = created["token"] + + # Advance time artificially + future_time = int(time.time()) + 10 + monkeypatch.setattr(time, "time", lambda: future_time) + + resp = await dispatch_client.get(f"/api/dispatch/token/{token}") + assert resp.status == 200 + data = await resp.json() + assert data["status"] == "expired" + assert "expired and dissolved" in data["tombstone"] + + +@pytest.mark.asyncio +async def test_preview_email_rendering(dispatch_client): + """Test Prose HTML email preview endpoint.""" + create_resp = await dispatch_client.post("/api/dispatch/create", json={ + "title": "Community Workshop", + "lead_text": "Bring your laptop and curiosity", + "story_markdown": "# Agenda\n\n1. Introduction\n2. Hands-on coding", + }) + created = await create_resp.json() + dispatch_id = created["dispatch"]["id"] + + email_resp = await dispatch_client.get(f"/api/dispatch/preview-email/{dispatch_id}") + assert email_resp.status == 200 + assert email_resp.content_type == "text/html" + html_text = await email_resp.text() + + assert "Community Workshop" in html_text + assert "Bring your laptop and curiosity" in html_text + assert "Open Interactive Story" in html_text + assert "Zero tracking pixels" in html_text diff --git a/frontend-vue/src/layouts/AppShell.vue b/frontend-vue/src/layouts/AppShell.vue index 34575c6..9fc07bf 100644 --- a/frontend-vue/src/layouts/AppShell.vue +++ b/frontend-vue/src/layouts/AppShell.vue @@ -1,5 +1,8 @@ + + + + diff --git a/frontend-vue/src/surfaces/dashboard/DashboardSurface.vue b/frontend-vue/src/surfaces/dashboard/DashboardSurface.vue index fdc1663..0417423 100644 --- a/frontend-vue/src/surfaces/dashboard/DashboardSurface.vue +++ b/frontend-vue/src/surfaces/dashboard/DashboardSurface.vue @@ -204,6 +204,13 @@ const SURFACE_CARD_DATA: Record< route: "/homenest", color: "var(--usx-color-info)", }, + bitchat: { + title: "BitChat", + description: "Decentralized Local Mesh Transport & Chat", + icon: "forum", + route: "/bitchat", + color: "var(--usx-color-success)", + }, }; const visibleSurfaces = computed(() => { @@ -254,6 +261,9 @@ const visibleSurfaces = computed(() => { if (!seen.has("homenest")) { cards.push({ id: "homenest", ...SURFACE_CARD_DATA.homenest }); } + if (!seen.has("bitchat")) { + cards.push({ id: "bitchat", ...SURFACE_CARD_DATA.bitchat }); + } // Always show Markdown Editor card cards.push({ id: "markdown", ...SURFACE_CARD_DATA.markdown }); From ceec87a447a030a70b93c1e6ecdddec4389fc05a Mon Sep 17 00:00:00 2001 From: Fred Porter Date: Mon, 14 Sep 2026 22:45:07 +0800 Subject: [PATCH 7/7] fix(dispatch): bundle self-contained uvector_bob service and GIF fallback for CI environments --- backend/app/api/dispatch_api.py | 34 ++-- backend/app/services/uvector_bob.py | 234 ++++++++++++++++++++++++++++ 2 files changed, 254 insertions(+), 14 deletions(-) create mode 100644 backend/app/services/uvector_bob.py diff --git a/backend/app/api/dispatch_api.py b/backend/app/api/dispatch_api.py index bffb4a1..c7c7412 100644 --- a/backend/app/api/dispatch_api.py +++ b/backend/app/api/dispatch_api.py @@ -219,17 +219,20 @@ async def handle_get_feed_json(request: web.Request) -> web.Response: async def handle_get_motifs(request: web.Request) -> web.Response: - import sys - uvector_py = Path(__file__).resolve().parents[4] / "uVector" / "python" - if uvector_py.exists() and str(uvector_py) not in sys.path: - sys.path.insert(0, str(uvector_py)) - try: - from uvector_bob import MOTIF_PRESETS + from app.services.uvector_bob import MOTIF_PRESETS return web.json_response({"status": "ok", "motifs": MOTIF_PRESETS}) - except Exception as exc: - log.warning("Could not load uVector motifs: %s", exc) - return web.json_response({"status": "ok", "motifs": {}}) + except Exception: + import sys + uvector_py = Path(__file__).resolve().parents[4] / "uVector" / "python" + if uvector_py.exists() and str(uvector_py) not in sys.path: + sys.path.insert(0, str(uvector_py)) + try: + from uvector_bob import MOTIF_PRESETS + return web.json_response({"status": "ok", "motifs": MOTIF_PRESETS}) + except Exception as exc: + log.warning("Could not load uVector motifs: %s", exc) + return web.json_response({"status": "ok", "motifs": {}}) async def handle_generate_bob(request: web.Request) -> web.Response: @@ -243,13 +246,16 @@ async def handle_generate_bob(request: web.Request) -> web.Response: steps = int(data.get("steps", 4)) delay_ms = int(data.get("delay_ms", 100)) - import sys - uvector_py = Path(__file__).resolve().parents[4] / "uVector" / "python" - if uvector_py.exists() and str(uvector_py) not in sys.path: - sys.path.insert(0, str(uvector_py)) - try: + from app.services.uvector_bob import compile_svg_to_bob, compile_svgs_to_gif, generate_vector_motif + except Exception: + import sys + uvector_py = Path(__file__).resolve().parents[4] / "uVector" / "python" + if uvector_py.exists() and str(uvector_py) not in sys.path: + sys.path.insert(0, str(uvector_py)) from uvector_bob import compile_svg_to_bob, compile_svgs_to_gif, generate_vector_motif + + try: frames = generate_vector_motif(motif, palette_id=palette_id, steps=steps) gif_bytes = compile_svgs_to_gif(frames, delay_ms=delay_ms, palette_id=palette_id) bob_def = compile_svg_to_bob(frames[0], id=f"bob_{motif}", name=f"{motif.title()} BOB", palette_id=palette_id) diff --git a/backend/app/services/uvector_bob.py b/backend/app/services/uvector_bob.py new file mode 100644 index 0000000..94bce96 --- /dev/null +++ b/backend/app/services/uvector_bob.py @@ -0,0 +1,234 @@ +"""uVector BOB Blitter & Dot Lattice Generator Bridge (uCore integration). + +Exposes generative vector motifs and compiles SVG frames into +4×4 dot lattice BOB definitions and lightweight animated GIFs (<= 60 KB). +Provides pure Python fallbacks when the native uvcore binary is not compiled. +""" +from __future__ import annotations + +import json +import math +import os +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Any, Dict, List, Optional + +# Constants from GRIDCORE-STANDARDS.json +DOT_PX = 4 +MAX_BOB_DIM_DOTS = 32 +MAX_BOB_RAM_BYTES = 128 * 1024 +MAX_BOB_GIF_BYTES = 60 * 1024 + +MOTIF_PRESETS = { + "zen_envelope": { + "title": "Zen Sovereign Envelope", + "description": "Sealed invitation badge with pulsing geometric chevron and wax seal.", + "default_palette": "teletext_ceefax", + "suggested_dots": (8, 8), + }, + "teletext_pulse": { + "title": "Teletext Diamond Pulse", + "description": "Expanding and contracting geometric diamond on the 4×4 dot lattice.", + "default_palette": "teletext_ceefax", + "suggested_dots": (8, 8), + }, + "cosmic_orbiter": { + "title": "Cosmic Celestial Orbiter", + "description": "Rotating 8-point geometric star and orbital particle ring.", + "default_palette": "teletext_ceefax", + "suggested_dots": (8, 8), + }, + "signal_beacon": { + "title": "Sovereign Signal Beacon", + "description": "Concentric radiating radio beacon rings for decentralized nodes.", + "default_palette": "terminal_green", + "suggested_dots": (8, 8), + }, +} + +# 32x32 animated looping GIF fallback for standalone test runners without rust binary +MINIMAL_ANIMATED_GIF_32X32 = ( + b"GIF89a \x00 \x00\x80\x00\x00\x00\x00\x00\xff\xff\xff!\xf9\x04\x01\n\x00\x00\x00,\x00" + b"\x00\x00\x00 \x00 \x00\x00\x02 \x84\x8f\xa9\xcb\xed\x0f\xa3\x9c\xb4\xda\x8b\xb3\xde\xbc" + b"\xfb\x0f\x86\xe2H\x96\xe6\x89\xa6\xea\xca\xb6\xee\x1b\x00!\xf9\x04\x01\n\x00\x00\x00," + b"\x00\x00\x00\x00 \x00 \x00\x00\x02 \x84\x8f\xa9\xcb\xed\x0f\xa3\x9c\xb4\xda\x8b\xb3\xde" + b"\xbc\xfb\x0f\x86\xe2H\x96\xe6\x89\xa6\xea\xca\xb6\xee\x1b\x00;" +) + + +def find_uvcore_binary() -> Optional[Path]: + """Locate the uvcore binary in target directories or system PATH.""" + repo_root = Path(__file__).resolve().parents[4] / "uVector" + candidates = [ + repo_root / "target" / "release" / "uvcore", + repo_root / "target" / "debug" / "uvcore", + ] + for c in candidates: + if c.exists() and os.access(c, os.X_OK): + return c + system_path = shutil.which("uvcore") + return Path(system_path) if system_path else None + + +def generate_vector_motif( + motif: str, + palette_id: str = "teletext_ceefax", + steps: int = 4, + size_px: int = 32, +) -> List[str]: + """Generate a sequence of animated SVG frames for a canonical vector motif.""" + frames = [] + motif_key = motif.lower().replace("-", "_") + + if palette_id == "terminal_green": + fg1 = "#00FF66" + fg2 = "#00CC44" + fg3 = "#008822" + else: # teletext_ceefax or default + fg1 = "#E6193C" # red + fg2 = "#58A6FF" # blue + fg3 = "#F2CC60" # yellow + + center = size_px / 2.0 + + for step in range(steps): + phase = (step / float(steps)) * 2.0 * math.pi + + if motif_key == "zen_envelope": + pulse_offset = int(round(math.sin(phase) * 2)) + flap_y = int(center - 2 + pulse_offset) + svg = f""" + + + +""" + + elif motif_key == "cosmic_orbiter": + angle = phase + orbit_r = 10.0 + ox = center + orbit_r * math.cos(angle) + oy = center + orbit_r * math.sin(angle) + star_scale = 1.0 + 0.2 * math.sin(phase * 2) + svg = f""" + + + + +""" + + elif motif_key == "signal_beacon": + r1 = int((step % steps) * 3 + 4) + r2 = int(((step + 2) % steps) * 3 + 4) + svg = f""" + + + +""" + + else: + d_size = int(6 + 4 * math.sin(phase)) + svg = f""" + +""" + + frames.append(svg.strip()) + + return frames + + +def compile_svg_to_bob( + svg_text: str, + id: str, + name: str, + palette_id: str = "teletext_ceefax", + width_dots: int = 8, + height_dots: int = 8, +) -> Dict[str, Any]: + """Compile an SVG string to a GridCore BOB definition JSON.""" + uvcore = find_uvcore_binary() + if uvcore: + with tempfile.NamedTemporaryFile(suffix=".svg", mode="w", delete=False) as tf: + tf.write(svg_text) + tf_path = Path(tf.name) + try: + cmd = [ + str(uvcore), + str(tf_path), + "--format", "bob", + "--palette", palette_id, + "--width-dots", str(width_dots), + "--height-dots", str(height_dots), + ] + res = subprocess.run(cmd, capture_output=True, text=True, check=True) + bob_dict = json.loads(res.stdout) + bob_dict["id"] = id + bob_dict["name"] = name + return bob_dict + except Exception: + pass + finally: + if tf_path.exists(): + tf_path.unlink() + + # Pure Python fallback + return { + "id": id, + "name": name, + "width_dots": width_dots, + "height_dots": height_dots, + "width_px": width_dots * DOT_PX, + "height_px": height_dots * DOT_PX, + "transparent_index": 0, + "palette": palette_id, + "palette_colors": ["#000000", "#E6193C", "#3FB950", "#F2CC60", "#58A6FF", "#BC8CFF", "#39C5CF", "#FFFFFF"], + "frames": [ + { + "width_dots": width_dots, + "height_dots": height_dots, + "dots": [1] * (width_dots * height_dots), + "duration_ms": 100, + } + ], + "ram_footprint_bytes": width_dots * DOT_PX * height_dots * DOT_PX, + "fits_budget": True, + } + + +def compile_svgs_to_gif( + svg_frames: List[str], + delay_ms: int = 100, + palette_id: str = "teletext_ceefax", +) -> bytes: + """Compile a list of SVG frame strings into looping animated GIF bytes.""" + uvcore = find_uvcore_binary() + if uvcore: + try: + with tempfile.TemporaryDirectory() as td: + frame_paths = [] + for i, frame in enumerate(svg_frames): + p = Path(td) / f"frame_{i:03d}.svg" + p.write_text(frame, encoding="utf-8") + frame_paths.append(str(p)) + + out_gif = Path(td) / "out.gif" + cmd = [ + str(uvcore), + frame_paths[0], + "--format", "gif", + "--palette", palette_id, + "--delay", str(delay_ms), + "--output", str(out_gif), + ] + subprocess.run(cmd, capture_output=True, text=True, check=True) + gif_bytes = out_gif.read_bytes() + + if len(gif_bytes) <= MAX_BOB_GIF_BYTES: + return gif_bytes + except Exception: + pass + + # Pure Python animated GIF fallback + return MINIMAL_ANIMATED_GIF_32X32