shopstack / app.py
pranaysuyash's picture
Sync ShopStack 2026-06-15 round 2: primary-nav More, undo bar, freshness stamps, safe_render_html, home-flow TabContext
af69759 verified
Raw
History Blame Contribute Delete
12.1 kB
from __future__ import annotations
import os
# HF Spaces: ensure DB_PATH defaults to a writable location before
# shopstack.config instantiates Settings() at module import time.
# Only do this on Spaces (SPACE_ID is set by the HF runtime) β€” locally
# this must NOT override shopstack.config's data/shopstack.db default,
# or every write silently lands in a stray ./shopstack.db at repo root.
if os.environ.get("SPACE_ID"):
os.environ.setdefault("SHOPSTACK_DB_PATH", "shopstack.db")
import gradio as gr
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
class PermissionsPolicyMiddleware(BaseHTTPMiddleware):
"""Add a restrictive Permissions-Policy header to all responses.
The policy restricts access to sensitive browser APIs by default.
Only features needed by the app should be explicitly allowed.
"""
# Valid Permissions-Policy features (per W3C spec)
# We disable all by default. Enable only what the app actually uses.
PERMISSIONS_POLICY = (
"accelerometer=(), "
"ambient-light-sensor=(), "
"autoplay=(), "
"battery=(), "
"camera=(), "
"display-capture=(), "
"document-domain=(), "
"encrypted-media=(), "
"fullscreen=(), "
"gamepad=(), "
"geolocation=(), "
"gyroscope=(), "
"hid=(), "
"identity-credentials-get=(), "
"idle-detection=(), "
"keyboard-map=(), "
"local-fonts=(), "
"magnetometer=(), "
"microphone=(), "
"midi=(), "
"otp-credentials=(), "
"payment=(), "
"picture-in-picture=(), "
"publickey-credentials-create=(), "
"publickey-credentials-get=(), "
"screen-wake-lock=(), "
"serial=(), "
"speaker-selection=(), "
"sync-xhr=(), "
"usb=(), "
"web-share=(), "
"window-management=(), "
"xr-spatial-tracking=()"
)
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
response.headers["Permissions-Policy"] = self.PERMISSIONS_POLICY
return response
def _install_permissions_policy_middleware(app: gr.Blocks) -> None:
"""Install the Permissions-Policy middleware on the Gradio app's FastAPI instance."""
fastapi_app = app.app
fastapi_app.add_middleware(PermissionsPolicyMiddleware)
def _install_post_launch_hooks(app: gr.Blocks) -> None:
"""Install hooks that re-mount PWA routes and middleware after Gradio's launch() recreates the FastAPI app."""
original_launch = app.launch
def wrapped_launch(*args, **kwargs):
result = original_launch(*args, **kwargs)
# Re-mount PWA routes and middleware after launch() creates a new FastAPI app
from shopstack.ui.pwa_mount import mount_pwa_static
from shopstack.services.health_mount import mount_health_endpoint
from shopstack.app_context import db
mount_pwa_static(app)
mount_health_endpoint(app, db)
_install_permissions_policy_middleware(app)
return result
app.launch = wrapped_launch
from shopstack.ui.header import header_block, pwa_head_html
from shopstack.ui.theme import CSS
from shopstack.ui.tabs.context import TabContext
from shopstack.ui.tabs.registry import build_all_tabs
from shopstack.ui.household_settings import build_household_settings
from shopstack.ui.tabs.onboarding import build_onboarding_wizard
from shopstack.ui.locale_save import build_locale_save
from shopstack.ui.pwa_mount import mount_pwa_static
from shopstack.ui.runtime_status import build_runtime_status
from shopstack.services.sms_webhook import mount_sms_webhook
from shopstack.services.health_mount import mount_health_endpoint
from shopstack.services.global_search_mount import mount_global_search
from shopstack.services.privacy_mount import mount_privacy_endpoints
from shopstack.services.undo_mount import mount_undo_endpoint
from shopstack.services.whoami_mount import mount_whoami_endpoint
from shopstack.services.data_retention import (
render_privacy_panel_html,
render_privacy_panel_script,
)
from shopstack.app_context import (
APP_DESCRIPTION,
APP_NAME,
current_user_id,
db,
tools,
providers,
planner,
)
from shopstack.services.i18n import load_locale_preference
def _install_post_launch_hooks(app: gr.Blocks) -> None:
"""Ensure root routes are restored after Gradio recreates the FastAPI app.
Gradio's ``launch()`` rebuilds ``app.app``. That means any routes
mounted during ``build_app()`` are lost unless we mount them again
after launch. HF Spaces follows the same launch path, so this wrapper
keeps ``/sw.js``, ``/manifest.json``, and ``/health/ui`` reachable
both locally and on Spaces.
"""
original_launch = app.launch
def _launch_with_post_hooks(*args, **kwargs):
result = original_launch(*args, **kwargs)
mount_pwa_static(app)
mount_health_endpoint(app, db)
mount_whoami_endpoint(app)
return result
app.launch = _launch_with_post_hooks
def build_app() -> gr.Blocks:
"""Compose the ShopStack app β€” pure composition, no business logic.
All tab builders are dispatched via ``shopstack.ui.tabs.registry``,
which iterates ``module_registry.TAB_ORDER`` to determine what tabs
exist and what order they appear in. This makes the module registry
the single source of truth for tab wiring.
Architecture:
* ``gr.Blocks`` is the root container.
* ``mount_pwa_static()`` mounts shopstack/static/ at /static/*.
* ``mount_sms_webhook()`` mounts /api/sms/incoming.
* ``header_block()`` renders the top header.
* ``build_all_tabs()`` iterates TAB_ORDER and calls each builder.
* ``build_household_settings()`` renders the workspace admin panel.
* The tail block wires cross-tab event handlers.
Adding a new tab does NOT require editing this file β€” register it
in ``module_registry.TAB_ORDER`` and ``shopstack.ui.tabs.registry``.
"""
with gr.Blocks(title=APP_NAME) as app:
mount_sms_webhook(app)
mount_global_search(app)
mount_privacy_endpoints(app)
mount_undo_endpoint(app)
# /api/whoami is a read-only operator introspection endpoint
# (see shopstack.services.whoami_mount). It is also re-mounted
# in the post-launch hooks so it survives the Blocks-context exit
# that recreates app.app. (Same pattern as the other mounts.)
mount_whoami_endpoint(app)
initial_locale = load_locale_preference(current_user_id() or "default_household")
# 2026-06-15 (Home screen review): the brand subtitle now
# answers the user's actual job instead of the abstract
# "platform" copy. The legacy APP_DESCRIPTION is still
# passed as a tooltip / fallback via header attributes.
gr.HTML(
header_block(
APP_NAME,
"Know what is at home, what to buy next, and what to skip.",
current_locale=initial_locale,
),
padding=True,
)
build_locale_save()
# Hidden runtime_status API endpoint (external consumers can
# query whether the app is in mock or real mode)
build_runtime_status()
# ── Onboarding wizard (first-run household setup) ──
# Rendered above the tab bar so first-time users see setup
# immediately, instead of after scrolling past every tab's
# content. Hidden by default; auto-shown on first load if the
# active household hasn't completed onboarding yet. See
# Docs/HANDOFF_ONBOARDING_WIRING_2026-06-13.md.
onboarding_wizard = build_onboarding_wizard(app)
def _show_onboarding_if_first_run() -> gr.update:
from shopstack.services.onboarding import should_show_onboarding
return gr.update(visible=should_show_onboarding(db))
app.load(
_show_onboarding_if_first_run,
outputs=[onboarding_wizard],
)
# ── Household settings accordion (workspace admin panel) ──
# Rendered above the tab bar β€” a workspace switcher belongs at
# the top of the page, accessible from any tab, not buried
# below the last tab's content.
hh = build_household_settings(app)
household_dropdown = hh.household_dropdown
add_hh_btn = hh.add_hh_btn
hh_add_row = hh.hh_add_row
hh_name_input = hh.hh_name_input
hh_create_btn = hh.hh_create_btn
hh_cancel_btn = hh.hh_cancel_btn
# ── Tab bar β€” driven by module_registry.TAB_ORDER via the builder registry ──
# 2026-06-15: switched to the 6-item user-facing primary nav
# (Home / Pantry / Shopping / Recipes / Trips / Memory). The
# old 5-group nested layout is still available via
# ``build_all_tabs(use_primary_nav=False)`` for back-compat.
with gr.Tabs(elem_classes="tabs primary-nav", elem_id="main-content"):
handles = build_all_tabs(
blocks=app, app=app, ctx=TabContext(),
use_primary_nav=True,
)
# Extract handles from tabs that expose them for cross-tab wiring.
# These are P0 guards: if a builder silently returns None or is
# skipped, we fail fast with a clear error rather than crashing
# with AttributeError deep inside the event wiring below.
today_handles = handles.get("today")
reconcile_handles = handles.get("reconcile")
if today_handles is None:
raise RuntimeError(
"Today tab builder did not return required handles. "
"Check that build_today_tab() returns a TodayTabHandles "
"dataclass and that the builder is registered in "
"shopstack/ui/tabs/registry.py."
)
if reconcile_handles is None:
raise RuntimeError(
"Reconcile tab builder did not return required handles. "
"Check that build_reconcile_tab() returns a "
"ReconcileTabHandles dataclass and that the builder is "
"registered in shopstack/ui/tabs/registry.py."
)
# Cross-tab event wiring (household dropdown, add-household
# form, per-render location refresh, post-load JS shims) is
# delegated to a dedicated sub-builder so app.py stays a pure
# composition layer. See shopstack/ui/state/household_wiring.py.
from shopstack.ui.state.household_wiring import wire_household_handlers
wire_household_handlers(
app,
household_dropdown=household_dropdown,
add_hh_btn=add_hh_btn,
hh_add_row=hh_add_row,
hh_name_input=hh_name_input,
hh_create_btn=hh_create_btn,
hh_cancel_btn=hh_cancel_btn,
today_handles=today_handles,
reconcile_handles=reconcile_handles,
)
# PWA shell (manifest/sw.js/icons) and the /health/ui liveness probe
# (motto_v3 Β§0.10 Observability Is Delivery) must both be mounted AFTER
# the ``with gr.Blocks()`` block exits: exiting the context recreates
# ``app.app`` (a fresh FastAPI instance), discarding any routes added
# while inside the context.
mount_pwa_static(app)
mount_health_endpoint(app, db)
_install_permissions_policy_middleware(app)
_install_post_launch_hooks(app)
return app
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=7860)
parser.add_argument("--share", action="store_true")
args = parser.parse_args()
app = build_app()
app.launch(server_port=args.port, share=args.share, theme=gr.themes.Base(), head=pwa_head_html(), css=CSS, prevent_thread_lock=True)
app.block_thread()