web: port Sync page login + sync to garminconnect backend

garminconnect_backend gains web-friendly auth: resume() (token restore with
API validation), begin_login()/complete_mfa() (two-step MFA handshake via
return_on_mfa), user_label(), and patch_garth() split out of activate().

5_Sync.py drops the garth login flow (Cloudflare-blocked) for the new
backend; the authenticated client is cached in st.session_state. Verified
headlessly with streamlit AppTest: token restore, sync-button click, full
incremental sync, no exceptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 07:38:50 -04:00
parent dd2b8ef1bd
commit 87f73ad36b
3 changed files with 115 additions and 36 deletions

View File

@@ -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`

View File

@@ -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)

View File

@@ -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,12 +81,10 @@ 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:
gauth.patch_garth(_gc)
summary = run_sync(
get_conn(),
full=full,
@@ -93,7 +95,7 @@ if _user:
progress=status.write,
)
status.update(label="Sync complete", state="complete")
except Exception as exc: # noqa: BLE001 — surface any garth/HTTP error
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:
@@ -105,6 +107,7 @@ if _user:
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,6 +126,7 @@ else:
st.warning("Enter both email and password.")
else:
try:
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}")
@@ -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()