diff --git a/NEXT_SESSION.md b/NEXT_SESSION.md index 3d8feda7..a84a89e3 100644 --- a/NEXT_SESSION.md +++ b/NEXT_SESSION.md @@ -33,8 +33,16 @@ architecture and conventions. Delete this file when the list is done. fallback in gc.login (main() parks it as .bak first). - DB after sync: 385 activities (→ 2026-06-02), all ISO timestamps on new rows, 356 FITs linked, 364 TIZ rows, wellness through 2026-06-12. - The 18 legacy epoch-ms rows remain (P2). Web Sync page still uses the - garth login flow — port it to the gc backend (or hide login) later. + The 18 legacy epoch-ms rows remain (P2). +- **Web Sync page ported to the gc backend** (same session): login + MFA + round-trip + sync button all run on garminconnect; verified headlessly via + streamlit AppTest (login restore, button click, full sync, no exceptions). + `openrun.ingest.auth` (garth) remains only for `--backend=garth`. +- **New goal (user, 2026-06-12): shareable web UI** — let others run the + openrun web app against their own Garmin account. Known gaps: per-user + token store + DB (everything is cwd-relative single-user today), per-user + openrun.toml (zones/races — openrun-init helps), and deploy story + (`streamlit run` is single-process; secrets must stay server-side). - `race_plan` table is EMPTY and `manual_activities` empty, but openrun.toml has races: 30K 2026-06-13, 50K 2026-07-25, 50 MILE 2026-09-12. - Known bugs: 18 activities have epoch-ms floats in `start_time_local` diff --git a/src/openrun/ingest/garminconnect_backend.py b/src/openrun/ingest/garminconnect_backend.py index eddab802..f9f467c9 100644 --- a/src/openrun/ingest/garminconnect_backend.py +++ b/src/openrun/ingest/garminconnect_backend.py @@ -19,19 +19,86 @@ from pathlib import Path from typing import Any +def token_dir() -> Path: + """Token store: `.secrets/` relative to the current working directory.""" + return Path.cwd() / ".secrets" + + def has_tokens(token_dir: Path) -> bool: return (token_dir / "garmin_tokens.json").exists() +def resume(td: Path | None = None) -> Any | None: + """Restore an authenticated Garmin client from saved tokens, else None. + + Validates against the API (refreshing the DI token if needed), so a dead + tokenstore returns None rather than failing later mid-sync. + """ + from garminconnect import Garmin + + td = td or token_dir() + if not has_tokens(td): + return None + try: + gc = Garmin() + gc.login(tokenstore=str(td)) + return gc + except Exception: # noqa: BLE001 — expired/rejected tokens → logged out + return None + + +def begin_login(email: str, password: str, td: Path | None = None) -> tuple[str, Any]: + """Start a fresh credential login (web flow, no input()). Returns: + + * ``("ok", gc)`` — logged in; tokens saved. + * ``("needs_mfa", gc)`` — hold ``gc`` (e.g. in st.session_state) and pass + it with the emailed/app code to ``complete_mfa``. + + Raises garminconnect's typed errors on bad credentials / rate limits. + """ + from garminconnect import Garmin + + gc = Garmin(email=email, password=password, return_on_mfa=True) + status, _ = gc.login() # cascading strategies; early-returns pre-dump + if status == "needs_mfa": + return "needs_mfa", gc + return "ok", _finish_login(gc, td) + + +def complete_mfa(gc: Any, code: str, td: Path | None = None) -> Any: + """Finish an MFA login started by ``begin_login``. Returns the client.""" + gc.client.resume_login(None, code.strip()) + return _finish_login(gc, td) + + +def _finish_login(gc: Any, td: Path | None = None) -> Any: + """Persist tokens and load the profile (skipped in return_on_mfa mode).""" + td = td or token_dir() + td.mkdir(parents=True, exist_ok=True) + gc.client.dump(str(td)) + gc._load_profile_and_settings() # noqa: SLF001 — wrapper has no public hook + return gc + + +def user_label(gc: Any) -> str: + """Human-readable account label for UI display.""" + return gc.full_name or gc.username or gc.display_name or "Garmin user" + + def activate(token_dir: Path) -> str: """Authenticate from `token_dir` and patch garth. Returns the username.""" - import garth - from garth.http import Client as GarthClient - from garth.http import client as garth_singleton from garminconnect import Garmin gc = Garmin() gc.login(tokenstore=str(token_dir)) # loads DI tokens; auto-refreshes + re-saves + return patch_garth(gc) + + +def patch_garth(gc: Any) -> str: + """Route garth's API surface through an authenticated garminconnect client.""" + import garth + from garth.http import Client as GarthClient + from garth.http import client as garth_singleton def connectapi(path: str, **kwargs: Any) -> Any: return gc.connectapi(path, **kwargs) diff --git a/src/openrun/web/pages/5_Sync.py b/src/openrun/web/pages/5_Sync.py index 80f29f8b..0d69efe3 100644 --- a/src/openrun/web/pages/5_Sync.py +++ b/src/openrun/web/pages/5_Sync.py @@ -19,7 +19,7 @@ import streamlit as st from openrun.config import default_config from openrun.db import get_state -from openrun.ingest import auth as gauth +from openrun.ingest import garminconnect_backend as gauth from openrun.ingest.garmin_api import run_sync from openrun.ingest.garmin_export import ingest_path from openrun.web._helpers import get_conn, show_sidebar @@ -45,10 +45,14 @@ st.write( st.divider() st.subheader("Live sync (Garmin Connect)") -_user = gauth.current_user() +# Resume validates tokens against the API (one round-trip), so cache the +# result for the browser session instead of re-checking on every rerun. +if "garmin_client" not in st.session_state: + st.session_state["garmin_client"] = gauth.resume() +_gc = st.session_state["garmin_client"] -if _user: - st.success(f"Logged in as **{_user}**.") +if _gc is not None: + st.success(f"Logged in as **{gauth.user_label(_gc)}**.") c1, c2 = st.columns(2) full = c1.checkbox( @@ -77,34 +81,33 @@ if _user: fit_limit = int(_lim) or None if st.button("🔄 Sync now from Garmin", type="primary"): - if not gauth.resume(): - st.error("Session expired — log in again below.") - else: - summary = None - with st.status("Syncing from Garmin…", expanded=True) as status: - try: - summary = run_sync( - get_conn(), - full=full, - fetch_fit=want_fit, - fit_backfill=fit_backfill, - fit_type=fit_type, - fit_limit=fit_limit, - progress=status.write, - ) - status.update(label="Sync complete", state="complete") - except Exception as exc: # noqa: BLE001 — surface any garth/HTTP error - status.update(label="Sync failed", state="error") - st.error(str(exc)) - if summary is not None: - st.cache_data.clear() - st.success("Synced from Garmin Connect.") - st.json(summary) + summary = None + with st.status("Syncing from Garmin…", expanded=True) as status: + try: + gauth.patch_garth(_gc) + summary = run_sync( + get_conn(), + full=full, + fetch_fit=want_fit, + fit_backfill=fit_backfill, + fit_type=fit_type, + fit_limit=fit_limit, + progress=status.write, + ) + status.update(label="Sync complete", state="complete") + except Exception as exc: # noqa: BLE001 — surface any API/HTTP error + status.update(label="Sync failed", state="error") + st.error(str(exc)) + if summary is not None: + st.cache_data.clear() + st.success("Synced from Garmin Connect.") + st.json(summary) with st.expander("Log out"): st.caption("Removes the saved OAuth tokens in `.secrets/`. You'll need to log in again to sync.") if st.button("Forget saved tokens"): shutil.rmtree(gauth.token_dir(), ignore_errors=True) + st.session_state.pop("garmin_client", None) st.rerun() else: @@ -123,7 +126,8 @@ else: st.warning("Enter both email and password.") else: try: - kind, payload = gauth.begin_login(email, password) + with st.spinner("Logging in to Garmin…"): + kind, payload = gauth.begin_login(email, password) except Exception as exc: # noqa: BLE001 st.error(f"Login failed: {exc}") else: @@ -131,7 +135,7 @@ else: st.session_state["garmin_mfa_state"] = payload st.rerun() else: - st.success(f"Logged in as {payload}.") + st.session_state["garmin_client"] = payload st.rerun() if "garmin_mfa_state" in st.session_state: @@ -141,12 +145,12 @@ else: mfa_submitted = st.form_submit_button("Verify", type="primary") if mfa_submitted: try: - user = gauth.complete_mfa(st.session_state["garmin_mfa_state"], code) + gc = gauth.complete_mfa(st.session_state["garmin_mfa_state"], code) except Exception as exc: # noqa: BLE001 st.error(f"Verification failed: {exc}") else: del st.session_state["garmin_mfa_state"] - st.success(f"Logged in as {user}.") + st.session_state["garmin_client"] = gc st.rerun() st.divider()