From 698ed085bcef7ac36bbf91ca8a7f2b8aa8e6db36 Mon Sep 17 00:00:00 2001 From: andre Date: Tue, 20 Oct 2026 18:01:41 -0400 Subject: [PATCH] feat: add F1 TV authentication for historical ingestion Pin FastF1 3.9.0a0 and upgrade managed environments when requirements change. Add F1 TV account settings and authenticated historical ingestion with actionable sign-in errors. Preserve cached replays and keep F1 TV and OpenF1 logins independent. --- .env.example | 7 + README.md | 15 +- backend/src/api/ingest.rs | 8 +- backend/src/api/mod.rs | 7 + backend/src/api/settings.rs | 114 +++- backend/src/api/tests.rs | 267 +++++++- backend/src/connectors/fastf1_historical.rs | 510 ++++++++++++-- backend/src/settings.rs | 232 ++++++- desktop-core/src/api_client.rs | 50 +- desktop-core/src/settings_panel.rs | 204 +++++- desktop-gpui/src/embed.rs | 35 + desktop-gpui/src/main.rs | 213 +++--- desktop-gpui/src/views/mod.rs | 2 +- desktop-gpui/src/views/settings_menu.rs | 705 ++++++++++++-------- docs/project-overview.md | 1 + scripts/fastf1-requirements.txt | 2 +- scripts/fastf1_export_replay.py | 237 ++++++- scripts/test_fastf1_export_replay.py | 328 ++++++++- 18 files changed, 2453 insertions(+), 484 deletions(-) diff --git a/.env.example b/.env.example index 8551aa8..3ad1edd 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,13 @@ INTERVAL_OPENF1_LIVE_ENABLED=true # instead; a token saved in the settings panel takes precedence over this value. # INTERVAL_OPENF1_LIVE_TOKEN=YOUR_OPENF1_SPONSOR_TOKEN +# Historical downloads need a Formula 1 account with an F1 TV subscription (an Apple TV +# subscription must be linked to a Formula 1 account first). The desktop app's settings +# panel stores this login too; set these only for headless or `cargo run` backends. +# A login saved in the settings panel takes precedence over these values. +# F1_USERNAME=you@example.com +# F1_PASSWORD=your-formula1-account-password + # Optional: # INTERVAL_OPENF1_LIVE_BASE_URL=https://api.openf1.org/v1/ # INTERVAL_OPENF1_LIVE_AUTH_HEADER=Authorization diff --git a/README.md b/README.md index e75223f..0edff5e 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,21 @@ Select a session to download and replay it. Python and FastF1 are set up automat ## Runtime configuration +### Historical data (F1 TV) + +Downloading a historical session needs a Formula 1 account with an active F1 TV subscription: the F1 archive now requires it, and the bundled FastF1 is pinned to the `3.9.0a0` prerelease because that is the first release that can sign in. Open the gear menu and fill in the **F1 TV account** section with the email and password of your Formula 1 account. If you subscribe through Apple TV, sign in with the Formula 1 account the subscription is linked to, not your Apple ID; link it first at [account.formula1.com](https://account.formula1.com/#/en/partner-account-sync?country=us&lead_source=web_syndication_apple_us&partnerId=syn_apple_us) if you have not already. + +SIGN IN saves the login and runs a real sign-in; the section shows when the login was last verified, and only says "Signed in" after that check has passed. FastF1 performs the sign-in at the start of every download and keeps the resulting token in memory for that run only, so nothing is written to disk, a changed password or account is always used on the next download, and your own FastF1 installs' token file is never touched. Sessions downloaded earlier keep working without a login. + +Headless or `cargo run` backends can set `F1_USERNAME` and `F1_PASSWORD` instead; a login saved in the app takes precedence. The exporter never prompts for credentials, so a missing or rejected login fails the download with a clear error instead of hanging. + ### Live data -Open the gear menu and sign in with an [OpenF1 account with live access](https://openf1.org/auth.html). Historical replays do not require a login. Access tokens refresh automatically. +Open the gear menu and sign in with an [OpenF1 account with live access](https://openf1.org/auth.html) in the **OpenF1 account** section. Access tokens refresh automatically. + +### Credential storage -Credentials are stored in plain text in `interval/settings.json` under your platform's configuration directory: `%APPDATA%` on Windows, `~/Library/Application Support` on macOS, or `${XDG_CONFIG_HOME:-~/.config}` on Linux. +Both logins are stored in plain text in `interval/settings.json` under your platform's configuration directory: `%APPDATA%` on Windows, `~/Library/Application Support` on macOS, or `${XDG_CONFIG_HOME:-~/.config}` on Linux. Each login is saved and cleared independently. ### Data and settings @@ -48,6 +58,7 @@ Set `INTERVAL_DESKTOP_DATA_DIR` in your environment to use another directory. Fo | --- | --- | | `INTERVAL_OPENF1_LIVE_ENABLED` | Set to `false` to disable live discovery and polling. Default: `true`. | | `INTERVAL_OPENF1_LIVE_TOKEN` | Optional bearer token when no login is saved. Saved credentials take precedence. | +| `F1_USERNAME` / `F1_PASSWORD` | Formula 1 account for historical downloads when no login is saved in the app. Saved credentials take precedence. | | `INTERVAL_OPENF1_LIVE_BASE_URL` | Custom live API endpoint. Default: `https://api.openf1.org/v1/`. | | `INTERVAL_OPENF1_LIVE_AUTH_HEADER` | Custom token header. Default: `Authorization`. | | `RUST_LOG` | Diagnostic logging filter, such as `interval_backend=debug`. | diff --git a/backend/src/api/ingest.rs b/backend/src/api/ingest.rs index b921cf0..18c0660 100644 --- a/backend/src/api/ingest.rs +++ b/backend/src/api/ingest.rs @@ -46,9 +46,15 @@ pub async fn ingest_session( ) .await?; + let (f1_login, _) = crate::settings::resolve_f1_auth( + &state.settings.load(), + crate::settings::f1_login_from_env(), + ); let bundle = match tokio::time::timeout( Duration::from_secs(1_000), - state.fastf1.fetch_race_bundle(&session, meeting.as_ref()), + state + .fastf1 + .fetch_race_bundle(&session, meeting.as_ref(), f1_login), ) .await { diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs index 7811d53..aaf075c 100644 --- a/backend/src/api/mod.rs +++ b/backend/src/api/mod.rs @@ -99,6 +99,13 @@ pub fn settings_router(state: AppState) -> Router { "/api/settings/openf1-login/test", post(settings::test_login), ) + .route( + "/api/settings/f1-login", + get(settings::get_f1_login) + .put(settings::put_f1_login) + .delete(settings::delete_f1_login), + ) + .route("/api/settings/f1-login/test", post(settings::test_f1_login)) .with_state(state) } diff --git a/backend/src/api/settings.rs b/backend/src/api/settings.rs index 0331119..422aa01 100644 --- a/backend/src/api/settings.rs +++ b/backend/src/api/settings.rs @@ -10,11 +10,99 @@ //! user typed in themselves and is echoed back so the panel can show who is signed in. use super::{ApiError, AppState}; -use crate::connectors::openf1_live::{OpenF1Auth, OpenF1Login, OpenF1LiveError}; -use crate::settings::resolve_openf1_auth; +use crate::connectors::fastf1_historical::{F1AuthProbe, F1Login}; +use crate::connectors::openf1_live::{OpenF1Auth, OpenF1LiveError, OpenF1Login}; +use crate::settings::{f1_login_from_env, resolve_f1_auth, resolve_openf1_auth, AuthSource}; use axum::{extract::State, Json}; use serde::{Deserialize, Serialize}; +#[derive(Debug, Serialize)] +pub struct F1LoginSettings { + configured: bool, + username: Option, + source: &'static str, + env_login_present: bool, + verified_at: Option, + path: Option, +} + +pub async fn get_f1_login(State(state): State) -> Json { + Json(current_f1_settings(&state)) +} + +pub async fn put_f1_login( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + let login = F1Login { + username: validated_username(&body.username)?, + password: validated_password(body.password)?, + }; + state.settings.store_f1_login(Some(&login))?; + Ok(Json(current_f1_settings(&state))) +} + +pub async fn delete_f1_login( + State(state): State, +) -> Result, ApiError> { + state.settings.store_f1_login(None)?; + Ok(Json(current_f1_settings(&state))) +} + +pub async fn test_f1_login(State(state): State) -> Json { + let (login, source) = resolve_f1_auth(&state.settings.load(), f1_login_from_env()); + let probe = match state.fastf1.probe_login(login.clone()).await { + Ok(probe) => probe, + Err(error) => F1AuthProbe { + result: "invalid".to_string(), + message: error.to_string(), + }, + }; + if let (AuthSource::Settings, Some(tested)) = (source, login) { + let verified_at = probe.is_ok().then(|| chrono::Utc::now().to_rfc3339()); + if let Err(error) = state.settings.store_f1_verified_at(verified_at, &tested) { + tracing::warn!(error = %error, "could not record the F1 login verification"); + } + } + Json(probe) +} + +fn current_f1_settings(state: &AppState) -> F1LoginSettings { + let stored = state.settings.load(); + let env = f1_login_from_env(); + let env_login_present = env.is_some(); + let (login, source) = resolve_f1_auth(&stored, env); + F1LoginSettings { + configured: login.is_some(), + username: login.map(|login| login.username), + source: source.as_str(), + env_login_present, + verified_at: (source == AuthSource::Settings) + .then_some(stored.f1_verified_at) + .flatten(), + path: state.settings.path_display(), + } +} + +fn validated_username(username: &str) -> Result { + let username = username.trim(); + if username.is_empty() { + return Err(ApiError::BadRequest( + "username must not be blank".to_string(), + )); + } + Ok(username.to_string()) +} + +fn validated_password(password: String) -> Result { + if password.trim().is_empty() { + return Err(ApiError::BadRequest( + "password must not be blank".to_string(), + )); + } + Ok(password) +} + #[derive(Debug, Serialize)] pub struct OpenF1LoginSettings { configured: bool, @@ -51,18 +139,11 @@ pub async fn put_login( State(state): State, Json(body): Json, ) -> Result, ApiError> { - let username = body.username.trim(); - if username.is_empty() { - return Err(ApiError::BadRequest("username must not be blank".to_string())); - } // Not trimmed: OpenF1 generates the password, and a generated password may well // start or end with whitespace. Only an all-blank one is rejected. - if body.password.trim().is_empty() { - return Err(ApiError::BadRequest("password must not be blank".to_string())); - } let login = OpenF1Login { - username: username.to_string(), - password: body.password, + username: validated_username(&body.username)?, + password: validated_password(body.password)?, }; // Persist first: a write failure must not leave a login applied in memory that @@ -91,10 +172,9 @@ pub async fn test_login(State(state): State) -> Json "unauthorized", format!("OpenF1 rejected the login (HTTP {status})."), ), - Err(OpenF1LiveError::Request(error)) if error.is_timeout() => ( - "unreachable", - "OpenF1 did not respond in time.".to_string(), - ), + Err(OpenF1LiveError::Request(error)) if error.is_timeout() => { + ("unreachable", "OpenF1 did not respond in time.".to_string()) + } Err(OpenF1LiveError::Request(error)) => { ("unreachable", format!("Could not reach OpenF1: {error}")) } @@ -113,9 +193,7 @@ pub async fn test_login(State(state): State) -> Json fn current_settings(state: &AppState) -> OpenF1LoginSettings { let stored = state.settings.load(); let env = env_token(); - let env_token_present = env - .as_ref() - .is_some_and(|value| !value.trim().is_empty()); + let env_token_present = env.as_ref().is_some_and(|value| !value.trim().is_empty()); let (auth, source) = resolve_openf1_auth(&stored, env); let username = match &auth { OpenF1Auth::Login(login) => Some(login.username.clone()), diff --git a/backend/src/api/tests.rs b/backend/src/api/tests.rs index 874962d..7353a11 100644 --- a/backend/src/api/tests.rs +++ b/backend/src/api/tests.rs @@ -2024,9 +2024,11 @@ async fn live_simulation_stream_without_speed_query_keeps_the_current_speed() { ) .await; - let text = get_sse_prefix(app.clone(), "/api/sessions/9839/live-simulation/stream", |body| { - body.contains("event: snapshot") - }) + let text = get_sse_prefix( + app.clone(), + "/api/sessions/9839/live-simulation/stream", + |body| body.contains("event: snapshot"), + ) .await; assert!(text.contains("event: metadata")); @@ -2044,7 +2046,10 @@ async fn live_simulation_stream_without_speed_query_keeps_the_current_speed() { ) .await; let advanced = after["current_t"].as_f64().unwrap() - before["current_t"].as_f64().unwrap(); - assert!(advanced >= 0.8, "simulation advanced only {advanced}s in 300ms; speed was reset"); + assert!( + advanced >= 0.8, + "simulation advanced only {advanced}s in 300ms; speed was reset" + ); } #[tokio::test] @@ -2514,12 +2519,7 @@ async fn get_json(app: Router, uri: &str, expected_status: StatusCode) -> Value serde_json::from_slice(&body).unwrap() } -async fn post_json_body( - app: Router, - uri: &str, - body: Value, - expected_status: StatusCode, -) -> Value { +async fn post_json_body(app: Router, uri: &str, body: Value, expected_status: StatusCode) -> Value { let response = app .oneshot( Request::builder() @@ -2760,6 +2760,20 @@ fn settings_test_store() -> (crate::settings::SettingsStore, std::path::PathBuf) } async fn settings_app(path: &std::path::Path) -> Router { + settings_app_with_fastf1( + path, + crate::connectors::fastf1_historical::FastF1HistoricalClient::for_test( + std::env::current_dir().unwrap(), + Some(std::path::PathBuf::from("missing-fastf1-python")), + ), + ) + .await +} + +async fn settings_app_with_fastf1( + path: &std::path::Path, + fastf1: crate::connectors::fastf1_historical::FastF1HistoricalClient, +) -> Router { let pool = storage::connect("sqlite::memory:").await.unwrap(); storage::migrate(&pool).await.unwrap(); let live_client = crate::connectors::openf1_live::OpenF1LiveClient::with_config( @@ -2771,16 +2785,235 @@ async fn settings_app(path: &std::path::Path) -> Router { auth_header: "authorization".to_string(), }, ); - let state = AppState::new_with_live( + let mut state = AppState::new_with_live( pool, crate::connectors::openf1_historical::HistoricalClient::default(), live_client, ) .with_settings(crate::settings::SettingsStore::at(path.to_path_buf())); + state.fastf1 = fastf1; router(state.clone()).merge(settings_router(state)) } -async fn send(app: Router, method: &str, uri: &str, body: Option<&str>) -> (HttpStatusCode, String) { +fn fastf1_scratch_root(tag: &str) -> std::path::PathBuf { + let root = + std::env::temp_dir().join(format!("interval-api-fastf1-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + root +} + +#[tokio::test] +async fn f1_settings_routes_are_absent_unless_they_are_registered() { + let pool = storage::connect("sqlite::memory:").await.unwrap(); + storage::migrate(&pool).await.unwrap(); + let app = router(AppState::new( + pool, + crate::connectors::openf1_historical::HistoricalClient::default(), + )); + + for (method, uri) in [ + ("GET", "/api/settings/f1-login"), + ("DELETE", "/api/settings/f1-login"), + ("POST", "/api/settings/f1-login/test"), + ] { + let (status, _) = send(app.clone(), method, uri, None).await; + assert_eq!(status, HttpStatusCode::NOT_FOUND, "{method} {uri}"); + } +} + +#[tokio::test] +async fn f1_login_is_saved_without_ever_returning_the_password() { + let (_store, path) = settings_test_store(); + let secret = "f1tv-secret-password"; + + let (status, saved) = send( + settings_app(&path).await, + "PUT", + "/api/settings/f1-login", + Some(&format!( + r#"{{"username":" tv@example.com ","password":"{secret}"}}"# + )), + ) + .await; + assert_eq!(status, HttpStatusCode::OK); + let (_, fetched) = send( + settings_app(&path).await, + "GET", + "/api/settings/f1-login", + None, + ) + .await; + + for body in [&saved, &fetched] { + assert!( + !body.contains(secret), + "password leaked in response: {body}" + ); + } + let payload = serde_json::from_str::(&fetched).unwrap(); + assert_eq!(payload["configured"], true); + assert_eq!(payload["source"], "settings"); + assert_eq!(payload["username"], "tv@example.com"); + assert_eq!(payload["verified_at"], Value::Null); + assert!(std::fs::read_to_string(&path).unwrap().contains(secret)); + + let (_, openf1) = send( + settings_app(&path).await, + "GET", + "/api/settings/openf1-login", + None, + ) + .await; + let openf1 = serde_json::from_str::(&openf1).unwrap(); + assert_eq!(openf1["configured"], false); + + let _ = std::fs::remove_file(&path); +} + +#[tokio::test] +async fn f1_login_rejects_blank_halves() { + let (_store, path) = settings_test_store(); + for body in [ + r#"{"username":" ","password":"pw"}"#, + r#"{"username":"tv@example.com","password":" "}"#, + ] { + let (status, _) = send( + settings_app(&path).await, + "PUT", + "/api/settings/f1-login", + Some(body), + ) + .await; + assert_eq!(status, HttpStatusCode::BAD_REQUEST); + } + let _ = std::fs::remove_file(&path); +} + +#[tokio::test] +async fn testing_the_f1_login_records_verification_and_clearing_forgets_it() { + let (_store, path) = settings_test_store(); + let root = fastf1_scratch_root("f1-probe"); + let fake = crate::connectors::fastf1_historical::fake_python( + &root, + r#"{"result":"ok","message":"Signed in as {username}"}"#, + "", + 0, + ); + let fastf1 = crate::connectors::fastf1_historical::FastF1HistoricalClient::for_test( + root.clone(), + Some(fake), + ); + send( + settings_app_with_fastf1(&path, fastf1.clone()).await, + "PUT", + "/api/settings/f1-login", + Some(r#"{"username":"tv@example.com","password":"pw"}"#), + ) + .await; + let (status, body) = send( + settings_app_with_fastf1(&path, fastf1.clone()).await, + "POST", + "/api/settings/f1-login/test", + None, + ) + .await; + assert_eq!(status, HttpStatusCode::OK); + let probe = serde_json::from_str::(&body).unwrap(); + assert_eq!(probe["result"], "ok"); + assert_eq!(probe["message"], "Signed in as tv@example.com"); + + let (_, fetched) = send( + settings_app_with_fastf1(&path, fastf1.clone()).await, + "GET", + "/api/settings/f1-login", + None, + ) + .await; + let fetched = serde_json::from_str::(&fetched).unwrap(); + assert!(fetched["verified_at"] + .as_str() + .is_some_and(|value| value.contains('T'))); + + let (status, body) = send( + settings_app_with_fastf1(&path, fastf1).await, + "DELETE", + "/api/settings/f1-login", + None, + ) + .await; + assert_eq!(status, HttpStatusCode::OK); + let payload = serde_json::from_str::(&body).unwrap(); + assert_eq!(payload["configured"], false); + assert_eq!(payload["source"], "none"); + assert_eq!(payload["verified_at"], Value::Null); + + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_dir_all(&root); +} + +#[tokio::test] +async fn testing_the_f1_login_without_credentials_reports_missing() { + let (_store, path) = settings_test_store(); + let (status, body) = send( + settings_app(&path).await, + "POST", + "/api/settings/f1-login/test", + None, + ) + .await; + assert_eq!(status, HttpStatusCode::OK); + let payload = serde_json::from_str::(&body).unwrap(); + assert_eq!(payload["result"], "missing"); + let _ = std::fs::remove_file(&path); +} + +#[tokio::test] +async fn ingest_reports_a_failed_f1_sign_in_as_the_ingest_error() { + let pool = storage::connect("sqlite::memory:").await.unwrap(); + storage::migrate(&pool).await.unwrap(); + storage::seed_mvp_fixture(&pool).await.unwrap(); + let root = fastf1_scratch_root("ingest-auth"); + let fake = crate::connectors::fastf1_historical::fake_python( + &root, + "", + "error: Formula 1 account sign-in failed: Sign-in was rejected. Check username and password.", + 1, + ); + let historical = crate::connectors::openf1_historical::HistoricalClient::with_base_url( + "http://127.0.0.1:1/v1/".parse().unwrap(), + ); + let fastf1 = crate::connectors::fastf1_historical::FastF1HistoricalClient::for_test( + root.clone(), + Some(fake), + ); + let app = router(AppState::new_with_fastf1(pool.clone(), historical, fastf1)); + + let payload = post_json(app, "/api/sessions/9472/ingest", StatusCode::BAD_GATEWAY).await; + + assert_eq!(payload["status"], "failed"); + let error = payload["error"].as_str().unwrap(); + assert!(error.contains("Sign-in was rejected"), "{error}"); + assert!(!error.contains("Traceback"), "{error}"); + let readiness = storage::list_session_readiness(&pool, 1229) + .await + .unwrap() + .pop() + .unwrap(); + assert_eq!(readiness.ingest_status, crate::domain::IngestStatus::Failed); + assert!(readiness + .last_error + .as_deref() + .is_some_and(|message| message.contains("Sign-in was rejected"))); + let _ = std::fs::remove_dir_all(&root); +} + +async fn send( + app: Router, + method: &str, + uri: &str, + body: Option<&str>, +) -> (HttpStatusCode, String) { let request = Request::builder().method(method).uri(uri); let request = match body { Some(json) => request @@ -2858,7 +3091,10 @@ async fn saving_a_login_never_returns_the_password() { // Assert on the raw bytes, not the parsed value: a future field must not be able to // reintroduce a leak without failing this test. for body in [&saved, &fetched] { - assert!(!body.contains(secret), "password leaked in response: {body}"); + assert!( + !body.contains(secret), + "password leaked in response: {body}" + ); } let payload = serde_json::from_str::(&fetched).unwrap(); @@ -2906,7 +3142,10 @@ async fn blank_logins_are_rejected() { for (body, field) in [ (r#"{"username":" ","password":"pw"}"#, "username"), - (r#"{"username":"me@example.com","password":" "}"#, "password"), + ( + r#"{"username":"me@example.com","password":" "}"#, + "password", + ), ] { let (status, response) = send( settings_app(&path).await, diff --git a/backend/src/connectors/fastf1_historical.rs b/backend/src/connectors/fastf1_historical.rs index ad890b8..a54f967 100644 --- a/backend/src/connectors/fastf1_historical.rs +++ b/backend/src/connectors/fastf1_historical.rs @@ -1,13 +1,27 @@ +//! Historical replay extraction through FastF1, run as a child Python process. +//! +//! FastF1 3.9 requires a Formula 1 account with an F1 TV subscription to download archived +//! timing data. The account is handed to the exporter only through the child's +//! environment (`F1_USERNAME`/`F1_PASSWORD`, the variables FastF1's native password +//! sign-in reads), never on the command line, and the child's stdin is closed so FastF1 +//! can never block on an interactive credential prompt. FastF1 performs the sign-in and +//! token refresh itself; the exporter swaps its token store for an in-memory one, so a +//! token lives only for the run that signed in with the credentials it was given and +//! Interval never reads or writes the user's own FastF1 token file. +//! +//! The managed venv's readiness sentinel stores the requirements it was installed from, +//! so a changed pin reinstalls into the existing venv without touching cached race data. + use crate::{ connectors::openf1_historical::RawEndpoint, domain::{Meeting, Session, SessionType}, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::{ - fs, + fmt, fs, path::{Path, PathBuf}, - process::Command, + process::{Command, Stdio}, time::Duration, }; use thiserror::Error; @@ -18,6 +32,44 @@ const DEFAULT_CACHE_DIR: &str = "cache/fastf1"; const DEFAULT_VENV_DIR: &str = "cache/fastf1-venv"; const READY_SENTINEL: &str = ".interval-fastf1-ready"; const STDERR_LOG: &str = "cache/fastf1-stderr.log"; +const F1_USERNAME_ENV: &str = "F1_USERNAME"; +const F1_PASSWORD_ENV: &str = "F1_PASSWORD"; + +#[derive(Clone, PartialEq, Eq)] +pub struct F1Login { + pub username: String, + pub password: String, +} + +impl fmt::Debug for F1Login { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("F1Login") + .field("username", &self.username) + .field("password", &"") + .finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct F1AuthProbe { + pub result: String, + pub message: String, +} + +impl F1AuthProbe { + pub fn missing() -> Self { + Self { + result: "missing".to_string(), + message: "No Formula 1 account is configured. Enter the email and password of the \ + Formula 1 account linked to your F1 TV subscription." + .to_string(), + } + } + + pub fn is_ok(&self) -> bool { + self.result == "ok" + } +} /// Pinned rather than "latest available": fastf1/numpy/pandas wheels are known-good on /// this minor, and uv downloads a managed CPython of exactly this version when the host /// has none — so every install runs the same interpreter we tested. @@ -81,20 +133,13 @@ impl FastF1HistoricalClient { &self, session: &Session, meeting: Option<&Meeting>, + login: Option, ) -> Result, FastF1HistoricalError> { let config = FastF1SessionConfig::for_session(session, meeting)?; - let root = self.root.clone(); - let python_override = self.python_override.clone(); - let uv_override = self.uv_override.clone(); - let bootstrap_python = self.bootstrap_python.clone(); let timeout = self.timeout; let session_key = session.session_key; - tokio::task::spawn_blocking(move || { - let python = match python_override { - Some(path) => path, - None => ensure_managed_python(&root, uv_override.as_deref(), &bootstrap_python)?, - }; + self.run_blocking(move |python, root| { let output_path = root .join("cache") .join("fastf1-bundles") @@ -102,61 +147,150 @@ impl FastF1HistoricalClient { if let Some(parent) = output_path.parent() { fs::create_dir_all(parent)?; } - - let mut command = Command::new(&python); - let warnings = config.warnings.clone(); - command - .current_dir(&root) - .arg(SCRIPT_PATH) - .arg("--year") - .arg(config.year.to_string()) - .arg("--session") - .arg(config.session_code) - .arg("--session-key") - .arg(session_key.to_string()) - .arg("--cache-dir") - .arg(DEFAULT_CACHE_DIR) - .arg("--output") - .arg(&output_path) - .arg("--resolver-method") - .arg(config.resolver_method); - - match config.round { - Some(round) => { - command.arg("--round").arg(round.to_string()); - } - None => { - if let Some(event_name) = config.event_name { - command.arg("--event-name").arg(event_name); - } - if let Some(country) = config.country { - command.arg("--country").arg(country); - } - if let Some(location) = config.location { - command.arg("--location").arg(location); - } - if let Some(session_start) = config.session_start { - command.arg("--session-start").arg(session_start); - } - } - } - + let _ = fs::remove_file(&output_path); + + let command = export_command( + python, + root, + &config, + session_key, + &output_path, + login.as_ref(), + ); let output = run_command(command, timeout, &root.join(STDERR_LOG))?; if !output.status.success() { return Err(FastF1HistoricalError::CommandFailed { program: python.display().to_string(), - stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + stderr: failure_message(&output.stderr), }); } let payload = fs::read_to_string(output_path)?; - bundle_from_export(session_key, &payload, &warnings) + bundle_from_export(session_key, &payload, &config.warnings) + }) + .await + } + + pub async fn probe_login( + &self, + login: Option, + ) -> Result { + let Some(login) = login else { + return Ok(F1AuthProbe::missing()); + }; + let timeout = self.timeout; + self.run_blocking(move |python, root| { + let command = auth_check_command(python, root, &login); + let output = run_command(command, timeout, &root.join(STDERR_LOG))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let verdict = stdout + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .and_then(|line| serde_json::from_str::(line.trim()).ok()); + verdict.ok_or_else(|| FastF1HistoricalError::CommandFailed { + program: python.display().to_string(), + stderr: failure_message(&output.stderr), + }) + }) + .await + } + + async fn run_blocking(&self, job: F) -> Result + where + T: Send + 'static, + F: FnOnce(&Path, &Path) -> Result + Send + 'static, + { + let root = self.root.clone(); + let python_override = self.python_override.clone(); + let uv_override = self.uv_override.clone(); + let bootstrap_python = self.bootstrap_python.clone(); + tokio::task::spawn_blocking(move || { + let python = match python_override { + Some(path) => path, + None => ensure_managed_python(&root, uv_override.as_deref(), &bootstrap_python)?, + }; + job(&python, &root) }) .await .map_err(|error| FastF1HistoricalError::Join(error.to_string()))? } } +fn script_command(python: &Path, root: &Path, login: Option<&F1Login>) -> Command { + let mut command = Command::new(python); + command + .current_dir(root) + .stdin(Stdio::null()) + .arg(SCRIPT_PATH); + if let Some(login) = login { + command + .env(F1_USERNAME_ENV, &login.username) + .env(F1_PASSWORD_ENV, &login.password); + } + command +} + +fn export_command( + python: &Path, + root: &Path, + config: &FastF1SessionConfig, + session_key: i64, + output_path: &Path, + login: Option<&F1Login>, +) -> Command { + let mut command = script_command(python, root, login); + command + .arg("--year") + .arg(config.year.to_string()) + .arg("--session") + .arg(config.session_code) + .arg("--session-key") + .arg(session_key.to_string()) + .arg("--cache-dir") + .arg(DEFAULT_CACHE_DIR) + .arg("--output") + .arg(output_path) + .arg("--resolver-method") + .arg(config.resolver_method); + + match config.round { + Some(round) => { + command.arg("--round").arg(round.to_string()); + } + None => { + if let Some(event_name) = &config.event_name { + command.arg("--event-name").arg(event_name); + } + if let Some(country) = &config.country { + command.arg("--country").arg(country); + } + if let Some(location) = &config.location { + command.arg("--location").arg(location); + } + if let Some(session_start) = &config.session_start { + command.arg("--session-start").arg(session_start); + } + } + } + command +} + +fn auth_check_command(python: &Path, root: &Path, login: &F1Login) -> Command { + let mut command = script_command(python, root, Some(login)); + command.arg("--auth-check").stdout(Stdio::piped()); + command +} + +fn failure_message(stderr: &[u8]) -> String { + let text = String::from_utf8_lossy(stderr); + text.lines() + .rev() + .find_map(|line| line.trim().strip_prefix("error: ")) + .map(str::to_string) + .unwrap_or_else(|| text.trim().to_string()) +} + fn ensure_managed_python( root: &Path, uv: Option<&Path>, @@ -165,7 +299,10 @@ fn ensure_managed_python( let venv_dir = root.join(DEFAULT_VENV_DIR); let python = venv_python(&venv_dir); let sentinel = venv_dir.join(READY_SENTINEL); - if sentinel.exists() && python.exists() { + let requirements = fs::read_to_string(root.join(REQUIREMENTS_PATH))?; + if python.exists() + && fs::read_to_string(&sentinel).is_ok_and(|installed| installed == requirements) + { return Ok(python); } let stderr_log = root.join(STDERR_LOG); @@ -209,7 +346,7 @@ fn ensure_managed_python( }); } - fs::write(sentinel, "ready\n")?; + fs::write(sentinel, requirements)?; // Best-effort: uv's wheel cache holds hundreds of MB the venv no longer needs — // installed files are hardlinks, so they survive the clean. A failure here only @@ -460,6 +597,56 @@ pub enum FastF1HistoricalError { Json(#[from] serde_json::Error), } +#[cfg(test)] +pub(crate) fn fake_python(dir: &Path, stdout: &str, stderr: &str, exit_code: i32) -> PathBuf { + let (path, body) = if cfg!(windows) { + let mut body = String::from("@echo off\r\n"); + if !stdout.is_empty() { + body.push_str(&format!("echo {}\r\n", fake_python_text(stdout))); + } + if !stderr.is_empty() { + body.push_str(&format!("echo {} 1>&2\r\n", fake_python_text(stderr))); + } + body.push_str(&format!("exit /b {exit_code}\r\n")); + (dir.join("fake-python.cmd"), body) + } else { + let mut body = String::from("#!/bin/sh\n"); + if !stdout.is_empty() { + body.push_str(&format!( + "echo \"{}\"\n", + fake_python_text(stdout).replace('"', "\\\"") + )); + } + if !stderr.is_empty() { + body.push_str(&format!( + "echo \"{}\" >&2\n", + fake_python_text(stderr).replace('"', "\\\"") + )); + } + body.push_str(&format!("exit {exit_code}\n")); + (dir.join("fake-python"), body) + }; + fs::write(&path, body).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + } + path +} + +#[cfg(test)] +fn fake_python_text(template: &str) -> String { + let (username, args) = if cfg!(windows) { + ("%F1_USERNAME%", "%*") + } else { + ("$F1_USERNAME", "$*") + }; + template + .replace("{username}", username) + .replace("{args}", args) +} + #[cfg(test)] mod tests { use super::*; @@ -475,6 +662,205 @@ mod tests { dir } + fn write_requirements(root: &Path, text: &str) { + let path = root.join(REQUIREMENTS_PATH); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, text).unwrap(); + } + + fn login() -> F1Login { + F1Login { + username: "me@example.com".to_string(), + password: "s3cret-pass".to_string(), + } + } + + fn args_of(command: &Command) -> Vec { + command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect() + } + + fn envs_of(command: &Command) -> std::collections::HashMap> { + command + .get_envs() + .map(|(key, value)| { + ( + key.to_string_lossy().into_owned(), + value.map(|value| value.to_string_lossy().into_owned()), + ) + }) + .collect() + } + + fn bahrain_config() -> FastF1SessionConfig { + FastF1SessionConfig { + year: 2024, + round: Some(1), + session_code: "R", + resolver_method: "curated_override", + event_name: None, + country: None, + location: None, + session_start: None, + warnings: vec![], + } + } + + #[test] + fn export_command_passes_the_login_only_through_the_environment() { + let root = Path::new("/data"); + let command = export_command( + Path::new("/venv/python"), + root, + &bahrain_config(), + 9472, + &root.join("out.json"), + Some(&login()), + ); + + let args = args_of(&command); + assert!( + args.iter().all(|arg| !arg.contains("s3cret-pass")), + "{args:?}" + ); + assert!( + args.iter().all(|arg| !arg.contains("me@example.com")), + "{args:?}" + ); + assert!(args.contains(&"--round".to_string())); + let envs = envs_of(&command); + assert_eq!(envs[F1_USERNAME_ENV], Some("me@example.com".to_string())); + assert_eq!(envs[F1_PASSWORD_ENV], Some("s3cret-pass".to_string())); + } + + #[test] + fn export_command_without_a_login_sets_no_credential_variables() { + let root = Path::new("/data"); + let command = export_command( + Path::new("/venv/python"), + root, + &bahrain_config(), + 9472, + &root.join("out.json"), + None, + ); + let envs = envs_of(&command); + assert!(!envs.contains_key(F1_USERNAME_ENV)); + assert!(!envs.contains_key(F1_PASSWORD_ENV)); + } + + #[test] + fn auth_check_command_requests_a_probe_with_the_login_in_the_environment() { + let command = auth_check_command(Path::new("/venv/python"), Path::new("/data"), &login()); + let args = args_of(&command); + assert!(args.contains(&"--auth-check".to_string())); + assert!(args.iter().all(|arg| !arg.contains("s3cret-pass"))); + assert_eq!( + envs_of(&command)[F1_PASSWORD_ENV], + Some("s3cret-pass".to_string()) + ); + } + + #[test] + fn failure_message_prefers_the_exporters_error_line() { + let stderr = b"core INFO Loading data\nTraceback...\nerror: Formula 1 account sign-in failed: nope\n"; + assert_eq!( + failure_message(stderr), + "Formula 1 account sign-in failed: nope" + ); + assert_eq!( + failure_message(b" plain traceback tail \n"), + "plain traceback tail" + ); + } + + #[test] + fn login_debug_output_redacts_the_password() { + let printed = format!("{:?}", login()); + assert!(printed.contains("me@example.com")); + assert!(!printed.contains("s3cret-pass"), "{printed}"); + } + + #[test] + fn managed_python_is_trusted_only_when_the_sentinel_matches_the_requirements() { + let root = scratch_dir("sentinel"); + write_requirements(&root, "fastf1==3.9.0a0\n"); + let venv_dir = root.join(DEFAULT_VENV_DIR); + let python = venv_python(&venv_dir); + fs::create_dir_all(python.parent().unwrap()).unwrap(); + fs::write(&python, "").unwrap(); + let sentinel = venv_dir.join(READY_SENTINEL); + + fs::write(&sentinel, "ready\n").unwrap(); + let legacy = ensure_managed_python(&root, None, "interval-missing-bootstrap-python"); + assert!( + legacy.is_err(), + "a legacy sentinel must trigger a reinstall" + ); + + fs::write(&sentinel, "fastf1>=3.5,<4\n").unwrap(); + let stale = ensure_managed_python(&root, None, "interval-missing-bootstrap-python"); + assert!(stale.is_err(), "a changed pin must trigger a reinstall"); + + fs::write(&sentinel, "fastf1==3.9.0a0\n").unwrap(); + let current = ensure_managed_python(&root, None, "interval-missing-bootstrap-python"); + assert_eq!(current.unwrap(), python); + let _ = fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn probe_login_reads_the_exporters_verdict_from_stdout() { + let root = scratch_dir("probe-ok"); + let fake = fake_python( + &root, + r#"{"result":"ok","message":"user={username} args={args}"}"#, + "", + 0, + ); + let client = FastF1HistoricalClient::for_test(root.clone(), Some(fake)); + + let probe = client.probe_login(Some(login())).await.unwrap(); + + assert!(probe.is_ok()); + assert!( + probe.message.contains("user=me@example.com"), + "{}", + probe.message + ); + assert!(probe.message.contains("--auth-check"), "{}", probe.message); + assert!(!probe.message.contains("s3cret-pass"), "{}", probe.message); + let _ = fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn probe_login_without_a_login_never_starts_python() { + let root = scratch_dir("probe-missing"); + let client = FastF1HistoricalClient::for_test( + root.clone(), + Some(PathBuf::from("missing-fastf1-python")), + ); + + let probe = client.probe_login(None).await.unwrap(); + + assert_eq!(probe.result, "missing"); + let _ = fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn probe_login_reports_a_broken_exporter_as_a_command_failure() { + let root = scratch_dir("probe-broken"); + let fake = fake_python(&root, "not json", "error: exporter exploded", 1); + let client = FastF1HistoricalClient::for_test(root.clone(), Some(fake)); + + let error = client.probe_login(Some(login())).await.unwrap_err(); + + assert!(error.to_string().contains("exporter exploded"), "{error}"); + let _ = fs::remove_dir_all(&root); + } + + #[test] fn uv_commands_pin_python_and_stay_self_contained() { let root = Path::new("/data"); @@ -530,12 +916,13 @@ mod tests { use std::os::unix::fs::PermissionsExt; let root = scratch_dir("uv-bootstrap"); + write_requirements(&root, "fastf1==3.9.0a0\n"); // A fake uv: `venv ` materializes the interpreter, `pip install` succeeds. // The deliberately-missing bootstrap python proves the uv branch was taken. let uv = root.join("uv"); fs::write( &uv, - "#!/bin/sh\nif [ \"$1\" = venv ]; then\n for last; do :; done\n mkdir -p \"$last/bin\"\n touch \"$last/bin/python\"\nfi\nexit 0\n", + "#!/bin/sh\necho \"$1 $2\" >> \"$(dirname \"$0\")/uv-calls.log\"\nif [ \"$1\" = venv ]; then\n for last; do :; done\n mkdir -p \"$last/bin\"\n touch \"$last/bin/python\"\nfi\nexit 0\n", ) .unwrap(); fs::set_permissions(&uv, fs::Permissions::from_mode(0o755)).unwrap(); @@ -545,7 +932,16 @@ mod tests { assert_eq!(python, venv_python(&root.join(DEFAULT_VENV_DIR))); assert!(python.exists()); - assert!(root.join(DEFAULT_VENV_DIR).join(READY_SENTINEL).exists()); + let sentinel = root.join(DEFAULT_VENV_DIR).join(READY_SENTINEL); + assert_eq!(fs::read_to_string(&sentinel).unwrap(), "fastf1==3.9.0a0\n"); + + write_requirements(&root, "fastf1==3.9.1\n"); + ensure_managed_python(&root, Some(&uv), "interval-missing-bootstrap-python").unwrap(); + + let calls = fs::read_to_string(root.join("uv-calls.log")).unwrap(); + assert_eq!(calls.matches("venv --python").count(), 1, "{calls}"); + assert_eq!(calls.matches("pip install").count(), 2, "{calls}"); + assert_eq!(fs::read_to_string(&sentinel).unwrap(), "fastf1==3.9.1\n"); let _ = fs::remove_dir_all(&root); } diff --git a/backend/src/settings.rs b/backend/src/settings.rs index e83b1c4..e65acae 100644 --- a/backend/src/settings.rs +++ b/backend/src/settings.rs @@ -5,15 +5,26 @@ //! deliberate exception: they live in the per-user config directory so a `cargo run` //! backend and an installed desktop app on the same machine share one file, and so a //! credential is not stored next to the replay data. - +//! +//! Two independent logins live here: the OpenF1 account for live data and the Formula 1 +//! account (the one an F1 TV subscription, including one bought through Apple TV, is +//! linked to) that FastF1 signs in with for historical ingestion. Each is saved and +//! cleared on its own, a saved login wins over its environment variables, and +//! `f1_verified_at` records the last time FastF1 actually accepted the F1 login so the +//! UI can tell "configured" from "known to work". + +use crate::connectors::fastf1_historical::F1Login; use crate::connectors::openf1_live::{OpenF1Auth, OpenF1Login}; use serde::{Deserialize, Serialize}; use std::ffi::OsStr; +use std::fmt; use std::fs; use std::path::{Path, PathBuf}; const APP_DIR: &str = "interval"; const FILE_NAME: &str = "settings.json"; +const F1_USERNAME_ENV: &str = "F1_USERNAME"; +const F1_PASSWORD_ENV: &str = "F1_PASSWORD"; /// Settings as stored on disk. /// @@ -23,12 +34,31 @@ const FILE_NAME: &str = "settings.json"; /// The OpenF1 login is stored as the account's username and password rather than a /// token: OpenF1 issues one-hour tokens, so a saved token would be dead by the next /// session. The file is owner-only, but this is still a plaintext password on disk. -#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Default, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Settings { #[serde(default, skip_serializing_if = "Option::is_none")] pub openf1_username: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub openf1_password: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub f1_username: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub f1_password: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub f1_verified_at: Option, +} + +impl fmt::Debug for Settings { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let redacted = |value: &Option| value.as_ref().map(|_| ""); + f.debug_struct("Settings") + .field("openf1_username", &self.openf1_username) + .field("openf1_password", &redacted(&self.openf1_password)) + .field("f1_username", &self.f1_username) + .field("f1_password", &redacted(&self.f1_password)) + .field("f1_verified_at", &self.f1_verified_at) + .finish() + } } impl Settings { @@ -39,6 +69,30 @@ impl Settings { password: non_blank(self.openf1_password.clone())?, }) } + + pub fn f1_login(&self) -> Option { + Some(F1Login { + username: non_blank(self.f1_username.clone())?, + password: non_blank(self.f1_password.clone())?, + }) + } +} + +pub fn resolve_f1_auth(settings: &Settings, env: Option) -> (Option, AuthSource) { + match settings.f1_login() { + Some(login) => (Some(login), AuthSource::Settings), + None => match env { + Some(login) => (Some(login), AuthSource::Env), + None => (None, AuthSource::None), + }, + } +} + +pub fn f1_login_from_env() -> Option { + Some(F1Login { + username: non_blank(std::env::var(F1_USERNAME_ENV).ok())?, + password: non_blank(std::env::var(F1_PASSWORD_ENV).ok())?, + }) } /// Where the effective OpenF1 credentials came from. @@ -197,13 +251,39 @@ impl SettingsStore { /// Load-modify-write, so keys this build does not set are preserved. /// `None` removes the login entirely rather than storing empty strings. pub fn store_login(&self, login: Option<&OpenF1Login>) -> anyhow::Result<()> { + self.update(|settings| { + settings.openf1_username = login.map(|login| login.username.clone()); + settings.openf1_password = login.map(|login| login.password.clone()); + }) + } + + pub fn store_f1_login(&self, login: Option<&F1Login>) -> anyhow::Result<()> { + self.update(|settings| { + settings.f1_username = login.map(|login| login.username.clone()); + settings.f1_password = login.map(|login| login.password.clone()); + settings.f1_verified_at = None; + }) + } + + pub fn store_f1_verified_at( + &self, + verified_at: Option, + tested: &F1Login, + ) -> anyhow::Result<()> { + self.update(|settings| { + if settings.f1_login().as_ref() == Some(tested) { + settings.f1_verified_at = verified_at; + } + }) + } + + fn update(&self, apply: impl FnOnce(&mut Settings)) -> anyhow::Result<()> { let path = self .path .as_deref() .ok_or_else(|| anyhow::anyhow!("no writable settings directory for this platform"))?; let mut settings = load_from(path); - settings.openf1_username = login.map(|login| login.username.clone()); - settings.openf1_password = login.map(|login| login.password.clone()); + apply(&mut settings); save_to(path, &settings) } @@ -290,9 +370,153 @@ mod tests { Settings { openf1_username: Some(username.to_string()), openf1_password: Some(password.to_string()), + ..Settings::default() + } + } + + fn f1_saved(username: &str, password: &str) -> Settings { + Settings { + f1_username: Some(username.to_string()), + f1_password: Some(password.to_string()), + ..Settings::default() + } + } + + fn f1_login(username: &str, password: &str) -> F1Login { + F1Login { + username: username.to_string(), + password: password.to_string(), } } + #[test] + fn resolve_f1_prefers_a_saved_login_over_the_environment() { + let (login, source) = resolve_f1_auth( + &f1_saved("saved@example.com", "pw"), + Some(f1_login("env@example.com", "env-pw")), + ); + assert_eq!(login, Some(f1_login("saved@example.com", "pw"))); + assert_eq!(source, AuthSource::Settings); + } + + #[test] + fn resolve_f1_falls_back_to_the_environment_and_then_to_nothing() { + let (login, source) = resolve_f1_auth( + &Settings::default(), + Some(f1_login("env@example.com", "env-pw")), + ); + assert_eq!(login, Some(f1_login("env@example.com", "env-pw"))); + assert_eq!(source, AuthSource::Env); + + let (login, source) = resolve_f1_auth(&f1_saved("me@example.com", " "), None); + assert_eq!(login, None); + assert_eq!(source, AuthSource::None); + } + + #[test] + fn f1_login_round_trips_and_clearing_resets_verification() { + let dir = temp_dir(); + let path = dir.join("settings.json"); + let store = SettingsStore::at(path.clone()); + + let me = f1_login("me@example.com", "pw"); + store.store_f1_login(Some(&me)).unwrap(); + store + .store_f1_verified_at(Some("2026-10-20T10:00:00Z".to_string()), &me) + .unwrap(); + let loaded = store.load(); + assert_eq!(loaded.f1_login(), Some(me.clone())); + assert_eq!( + loaded.f1_verified_at.as_deref(), + Some("2026-10-20T10:00:00Z") + ); + + let other = f1_login("other@example.com", "pw2"); + store.store_f1_login(Some(&other)).unwrap(); + assert_eq!(store.load().f1_verified_at, None); + + store + .store_f1_verified_at(Some("2026-10-20T10:05:00Z".to_string()), &me) + .unwrap(); + assert_eq!( + store.load().f1_verified_at, + None, + "a probe of stale credentials must not verify the newly saved ones" + ); + store + .store_f1_verified_at(Some("2026-10-20T10:06:00Z".to_string()), &other) + .unwrap(); + assert_eq!( + store.load().f1_verified_at.as_deref(), + Some("2026-10-20T10:06:00Z") + ); + store.store_f1_verified_at(None, &other).unwrap(); + assert_eq!(store.load().f1_verified_at, None); + + store.store_f1_login(None).unwrap(); + assert_eq!(store.load().f1_login(), None); + let raw = fs::read_to_string(&path).unwrap(); + assert!(!raw.contains("f1_"), "unexpected file body: {raw}"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn openf1_and_f1_logins_are_stored_independently() { + let dir = temp_dir(); + let store = SettingsStore::at(dir.join("settings.json")); + let openf1 = OpenF1Login { + username: "live@example.com".to_string(), + password: "live-pw".to_string(), + }; + + let tv = f1_login("tv@example.com", "tv-pw"); + store.store_login(Some(&openf1)).unwrap(); + store.store_f1_login(Some(&tv)).unwrap(); + assert_eq!(store.load().openf1_login(), Some(openf1.clone())); + assert_eq!(store.load().f1_login(), Some(tv.clone())); + + store.store_login(None).unwrap(); + assert_eq!(store.load().openf1_login(), None); + assert_eq!(store.load().f1_login(), Some(tv)); + + store.store_f1_login(None).unwrap(); + store.store_login(Some(&openf1)).unwrap(); + assert_eq!(store.load().f1_login(), None); + assert_eq!(store.load().openf1_login(), Some(openf1)); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn settings_debug_output_redacts_both_passwords_but_keeps_them_serialized() { + let settings = Settings { + openf1_username: Some("live@example.com".to_string()), + openf1_password: Some("live-secret".to_string()), + f1_username: Some("tv@example.com".to_string()), + f1_password: Some("tv-secret".to_string()), + f1_verified_at: Some("2026-10-20T10:00:00Z".to_string()), + }; + let printed = format!("{settings:?}"); + assert!(printed.contains("live@example.com") && printed.contains("tv@example.com")); + assert!(!printed.contains("live-secret"), "{printed}"); + assert!(!printed.contains("tv-secret"), "{printed}"); + let serialized = serde_json::to_string(&settings).unwrap(); + assert!(serialized.contains("live-secret") && serialized.contains("tv-secret")); + assert_eq!( + serde_json::from_str::(&serialized).unwrap(), + settings + ); + } + + #[test] + fn f1_login_debug_output_redacts_the_password() { + let login = f1_saved("me@example.com", "hunter2").f1_login().unwrap(); + let printed = format!("{login:?}"); + assert!(printed.contains("me@example.com")); + assert!(!printed.contains("hunter2"), "{printed}"); + } + #[test] fn resolve_prefers_a_saved_login_over_an_env_token() { let (auth, source) = diff --git a/desktop-core/src/api_client.rs b/desktop-core/src/api_client.rs index 0caf1d8..74174f1 100644 --- a/desktop-core/src/api_client.rs +++ b/desktop-core/src/api_client.rs @@ -10,7 +10,7 @@ use interval_backend::domain::{ ReplayMetadata, ReplaySnapshot, Season, SessionReadiness, TrackGeometry, }; -use crate::settings_panel::{OpenF1LoginProbe, OpenF1LoginSettings}; +use crate::settings_panel::{F1LoginProbe, F1LoginSettings, OpenF1LoginProbe, OpenF1LoginSettings}; #[derive(Debug, thiserror::Error)] pub enum ApiError { @@ -227,24 +227,57 @@ impl ApiClient { username: &str, password: &str, ) -> Result { + self.put_login(OPENF1_LOGIN_PATH, username, password).await + } + + pub async fn clear_openf1_login(&self) -> Result { + self.delete_json(OPENF1_LOGIN_PATH).await + } + + pub async fn test_openf1_login(&self) -> Result { + self.post_json(&format!("{OPENF1_LOGIN_PATH}/test")).await + } + + pub async fn f1_login(&self) -> Result { + self.get_json(F1_LOGIN_PATH).await + } + + pub async fn save_f1_login( + &self, + username: &str, + password: &str, + ) -> Result { + self.put_login(F1_LOGIN_PATH, username, password).await + } + + pub async fn clear_f1_login(&self) -> Result { + self.delete_json(F1_LOGIN_PATH).await + } + + pub async fn test_f1_login(&self) -> Result { + self.post_json(&format!("{F1_LOGIN_PATH}/test")).await + } + + async fn put_login( + &self, + path: &str, + username: &str, + password: &str, + ) -> Result { let response = self .http - .put(self.url(OPENF1_LOGIN_PATH)) + .put(self.url(path)) .json(&serde_json::json!({ "username": username, "password": password })) .send() .await?; decode_json(response).await } - pub async fn clear_openf1_login(&self) -> Result { - let response = self.http.delete(self.url(OPENF1_LOGIN_PATH)).send().await?; + async fn delete_json(&self, path: &str) -> Result { + let response = self.http.delete(self.url(path)).send().await?; decode_json(response).await } - pub async fn test_openf1_login(&self) -> Result { - self.post_json(&format!("{OPENF1_LOGIN_PATH}/test")).await - } - /// Open one of the `*_url` endpoints as a byte stream for [`crate::sse::SseParser`]. /// The stream ending (Ok or Err) is the end-of-stream signal — the server's `end` /// event carries no data and is deliberately not dispatched, matching browsers. @@ -260,6 +293,7 @@ impl ApiClient { } const OPENF1_LOGIN_PATH: &str = "/api/settings/openf1-login"; +const F1_LOGIN_PATH: &str = "/api/settings/f1-login"; async fn decode_json( response: reqwest::Response, diff --git a/desktop-core/src/settings_panel.rs b/desktop-core/src/settings_panel.rs index 0317311..5eee334 100644 --- a/desktop-core/src/settings_panel.rs +++ b/desktop-core/src/settings_panel.rs @@ -95,6 +95,92 @@ pub fn save_error_message(error: Option<&str>) -> String { } } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct F1LoginSettings { + pub configured: bool, + pub username: Option, + pub source: OpenF1AuthSource, + pub env_login_present: bool, + pub verified_at: Option, + pub path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum F1LoginProbeResult { + Ok, + Missing, + Unauthorized, + NoSubscription, + Unreachable, + #[serde(other)] + Invalid, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct F1LoginProbe { + pub result: F1LoginProbeResult, + pub message: String, +} + +pub const F1_ACCOUNT_HINT: &str = "Historical timing downloads need an F1 TV subscription. Use the email and password of your Formula 1 account, not your Apple ID; an Apple TV subscription must be linked to a Formula 1 account at account.formula1.com first."; + +pub fn f1_login_source_line(settings: &F1LoginSettings) -> String { + let who = settings + .username + .as_deref() + .filter(|username| !username.trim().is_empty()) + .map(|username| format!(" {username}")) + .unwrap_or_default(); + match settings.source { + OpenF1AuthSource::Settings if settings.verified_at.is_some() => { + format!("Signed in as{who}") + } + OpenF1AuthSource::Settings => format!("Login saved for{who}"), + OpenF1AuthSource::Env => format!("Using F1_USERNAME from the environment{who}"), + OpenF1AuthSource::None => "Not signed in".to_string(), + } +} + +pub fn f1_verification_line(settings: &F1LoginSettings) -> Option { + match (&settings.source, settings.verified_at.as_deref()) { + (OpenF1AuthSource::Settings, Some(verified_at)) => Some(format!( + "Sign-in verified {}", + verified_at.get(..10).unwrap_or(verified_at) + )), + (OpenF1AuthSource::Settings, None) | (OpenF1AuthSource::Env, _) => { + Some("Sign-in not verified yet. Press TEST to check it.".to_string()) + } + (OpenF1AuthSource::None, _) => None, + } +} + +pub fn f1_env_override_notice(settings: &F1LoginSettings) -> Option<&'static str> { + if settings.source != OpenF1AuthSource::Settings || !settings.env_login_present { + return None; + } + Some( + "F1_USERNAME and F1_PASSWORD are also set. The login saved here takes precedence; sign out to use the environment login.", + ) +} + +pub fn f1_probe_badge(probe: &F1LoginProbe) -> ChannelBadge { + let (label, ready, tone) = match probe.result { + F1LoginProbeResult::Ok => ("SIGNED IN", true, BadgeTone::Ready), + F1LoginProbeResult::Missing => ("NO LOGIN", false, BadgeTone::Degraded), + F1LoginProbeResult::Unauthorized => ("REJECTED", false, BadgeTone::Missing), + F1LoginProbeResult::NoSubscription => ("NO F1 TV", false, BadgeTone::Missing), + F1LoginProbeResult::Unreachable => ("UNREACHABLE", false, BadgeTone::Missing), + F1LoginProbeResult::Invalid => ("INVALID", false, BadgeTone::Degraded), + }; + ChannelBadge { + label: label.to_string(), + ready, + tone, + title: None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -184,13 +270,15 @@ mod tests { #[test] fn warns_only_when_a_saved_login_is_shadowing_an_environment_token() { - assert!(env_override_notice(&settings(SettingsOverrides { - source: OpenF1AuthSource::Settings, - env_token_present: true, - ..Default::default() - })) - .unwrap() - .contains("takes precedence")); + assert!( + env_override_notice(&settings(SettingsOverrides { + source: OpenF1AuthSource::Settings, + env_token_present: true, + ..Default::default() + })) + .unwrap() + .contains("takes precedence") + ); } #[test] @@ -267,4 +355,106 @@ mod tests { assert_eq!(save_error_message(Some(" ")), "Could not save the login."); assert_eq!(save_error_message(None), "Could not save the login."); } + + fn f1_settings( + source: OpenF1AuthSource, + verified_at: Option<&str>, + env: bool, + ) -> F1LoginSettings { + F1LoginSettings { + configured: source != OpenF1AuthSource::None, + username: (source != OpenF1AuthSource::None).then(|| "tv@example.com".to_string()), + source, + env_login_present: env, + verified_at: verified_at.map(str::to_string), + path: None, + } + } + + #[test] + fn f1_source_line_claims_a_sign_in_only_after_a_successful_probe() { + assert_eq!( + f1_login_source_line(&f1_settings(OpenF1AuthSource::Settings, None, false)), + "Login saved for tv@example.com" + ); + assert_eq!( + f1_login_source_line(&f1_settings( + OpenF1AuthSource::Settings, + Some("2026-10-20T09:30:00Z"), + false + )), + "Signed in as tv@example.com" + ); + assert_eq!( + f1_login_source_line(&f1_settings(OpenF1AuthSource::Env, None, true)), + "Using F1_USERNAME from the environment tv@example.com" + ); + assert_eq!( + f1_login_source_line(&f1_settings(OpenF1AuthSource::None, None, false)), + "Not signed in" + ); + } + + #[test] + fn f1_verification_line_distinguishes_configured_from_verified() { + assert_eq!( + f1_verification_line(&f1_settings(OpenF1AuthSource::Settings, None, false)).unwrap(), + "Sign-in not verified yet. Press TEST to check it." + ); + assert_eq!( + f1_verification_line(&f1_settings( + OpenF1AuthSource::Settings, + Some("2026-10-20T09:30:00.123Z"), + false + )) + .unwrap(), + "Sign-in verified 2026-10-20" + ); + assert_eq!( + f1_verification_line(&f1_settings(OpenF1AuthSource::Env, None, true)).unwrap(), + "Sign-in not verified yet. Press TEST to check it." + ); + assert_eq!( + f1_verification_line(&f1_settings(OpenF1AuthSource::None, None, false)), + None + ); + } + + #[test] + fn f1_env_notice_appears_only_when_a_saved_login_shadows_the_environment() { + assert!( + f1_env_override_notice(&f1_settings(OpenF1AuthSource::Settings, None, true)).is_some() + ); + assert!( + f1_env_override_notice(&f1_settings(OpenF1AuthSource::Settings, None, false)).is_none() + ); + assert!(f1_env_override_notice(&f1_settings(OpenF1AuthSource::Env, None, true)).is_none()); + } + + #[test] + fn f1_probe_results_decode_and_map_to_distinct_badges() { + let decoded: F1LoginProbe = + serde_json::from_str(r#"{"result":"no_subscription","message":"m"}"#).unwrap(); + assert_eq!(decoded.result, F1LoginProbeResult::NoSubscription); + let unknown: F1LoginProbe = + serde_json::from_str(r#"{"result":"something_new","message":"m"}"#).unwrap(); + assert_eq!(unknown.result, F1LoginProbeResult::Invalid); + + let badge = |result| { + f1_probe_badge(&F1LoginProbe { + result, + message: String::new(), + }) + }; + assert_eq!(badge(F1LoginProbeResult::Ok).label, "SIGNED IN"); + assert!(badge(F1LoginProbeResult::Ok).ready); + assert_eq!(badge(F1LoginProbeResult::Unauthorized).label, "REJECTED"); + assert_eq!(badge(F1LoginProbeResult::NoSubscription).label, "NO F1 TV"); + assert_eq!( + badge(F1LoginProbeResult::Unreachable).tone, + BadgeTone::Missing + ); + assert_eq!(badge(F1LoginProbeResult::Missing).tone, BadgeTone::Degraded); + assert!(!badge(F1LoginProbeResult::Invalid).ready); + } } diff --git a/desktop-gpui/src/embed.rs b/desktop-gpui/src/embed.rs index f376f72..56e3798 100644 --- a/desktop-gpui/src/embed.rs +++ b/desktop-gpui/src/embed.rs @@ -277,6 +277,41 @@ mod tests { assert!(!dir.0.join(DISCOVERY_FILE).exists()); } + #[test] + fn changed_fastf1_requirements_drop_only_the_readiness_sentinel() { + let dir = TempDir::new("requirements"); + let sentinel = dir.0.join("cache/fastf1-venv/.interval-fastf1-ready"); + std::fs::create_dir_all(sentinel.parent().unwrap()).unwrap(); + std::fs::write(&sentinel, "fastf1>=3.5,<4\n").unwrap(); + std::fs::write(dir.0.join("cache/fastf1-venv/keep.txt"), "venv contents").unwrap(); + std::fs::create_dir_all(dir.0.join("scripts")).unwrap(); + std::fs::write( + dir.0.join("scripts/fastf1-requirements.txt"), + "fastf1>=3.5,<4\n", + ) + .unwrap(); + + prepare_data_dir(&dir.0).unwrap(); + + assert!( + !sentinel.exists(), + "a stale requirements file must invalidate the venv" + ); + assert!(dir.0.join("cache/fastf1-venv/keep.txt").exists()); + assert_eq!( + std::fs::read_to_string(dir.0.join("scripts/fastf1-requirements.txt")).unwrap(), + FASTF1_REQUIREMENTS + ); + assert!(FASTF1_REQUIREMENTS.contains("fastf1==3.9.0a0")); + + std::fs::write(&sentinel, FASTF1_REQUIREMENTS).unwrap(); + prepare_data_dir(&dir.0).unwrap(); + assert!( + sentinel.exists(), + "unchanged requirements must keep the venv ready" + ); + } + #[test] fn remove_discovery_leaves_another_instances_record_alone() { let dir = TempDir::new("foreign"); diff --git a/desktop-gpui/src/main.rs b/desktop-gpui/src/main.rs index b90a8d3..6e4da88 100644 --- a/desktop-gpui/src/main.rs +++ b/desktop-gpui/src/main.rs @@ -23,6 +23,23 @@ use gpui::{ prelude::*, px, size, }; use gpui_platform::application; +use interval_desktop_core::settings_panel::{ + F1LoginProbe, F1LoginSettings, OpenF1LoginProbe, OpenF1LoginSettings, +}; +use views::Account; + +enum SettingsOutcome { + OpenF1 { + settings: Option, + probe: Option, + }, + F1 { + settings: Option, + probe: Option, + }, +} + +type SettingsResult = Result; actions!( interval, @@ -95,7 +112,7 @@ pub struct IntervalApp { pub(crate) map_animation: Option, /// The last snapshot the map rendered, kept to seed the next tween. pub(crate) map_last_snapshot: Option, - /// The OpenF1 account popover's state. + /// The account settings popover's state. pub(crate) settings: views::SettingsUi, /// The settings popover's update control. pub(crate) update_state: updates::UpdateState, @@ -194,130 +211,151 @@ impl IntervalApp { let api = self.api.clone(); let (tx, rx) = tokio::sync::oneshot::channel(); self.tokio.spawn(async move { - let _ = tx.send(api.openf1_login().await.ok()); + let _ = tx.send((api.openf1_login().await.ok(), api.f1_login().await.ok())); }); cx.spawn(async move |this, cx| { - let result = rx.await.ok().flatten(); + let Ok((openf1, f1)) = rx.await else { return }; let _ = this.update(cx, |this, cx| { - this.settings.settings = Some(result); + this.settings.settings = Some(openf1); + this.settings.f1_settings = f1; cx.notify(); }); }) .detach(); } - pub(crate) fn settings_save(&mut self, cx: &mut Context) { + pub(crate) fn settings_save(&mut self, account: Account, cx: &mut Context) { if self.settings.busy { return; } - self.settings.busy = true; - self.settings.error = None; - self.settings.probe = None; - let username = self.settings.username_input.trim().to_string(); - let password = self.settings.password_input.clone(); + self.settings.begin(account); + let inputs = self.settings.account(account); + let username = inputs.username_input.trim().to_string(); + let password = inputs.password_input.clone(); let api = self.api.clone(); let store = std::sync::Arc::clone(&self.store); let (tx, rx) = tokio::sync::oneshot::channel(); self.tokio.spawn(async move { - let outcome = async { - api.save_openf1_login(&username, &password).await?; - let settings = api.openf1_login().await.ok(); - // A newly applied login can change live availability. - store.check_live(); - let probe = api.test_openf1_login().await.ok(); - Ok::<_, interval_desktop_core::api_client::ApiError>((settings, probe)) - } - .await; - let _ = tx.send(outcome); - }); - cx.spawn(async move |this, cx| { - let Ok(outcome) = rx.await else { return }; - let _ = this.update(cx, |this, cx| { - this.settings.busy = false; - match outcome { - Ok((settings, probe)) => { - this.settings.username_input.clear(); - this.settings.password_input.clear(); - if settings.is_some() { - this.settings.settings = Some(settings); - } - this.settings.probe = probe; + let outcome = match account { + Account::OpenF1 => { + async { + api.save_openf1_login(&username, &password).await?; + let settings = api.openf1_login().await.ok(); + // A newly applied login can change live availability. + store.check_live(); + let probe = api.test_openf1_login().await.ok(); + Ok(SettingsOutcome::OpenF1 { settings, probe }) } - Err(error) => { - this.settings.error = - Some(interval_desktop_core::settings_panel::save_error_message( - Some(&error.to_string()), - )) + .await + } + Account::F1 => { + async { + api.save_f1_login(&username, &password).await?; + let probe = api.test_f1_login().await.ok(); + let settings = api.f1_login().await.ok(); + Ok(SettingsOutcome::F1 { settings, probe }) } + .await } - cx.notify(); - }); - }) - .detach(); + }; + let _ = tx.send(outcome); + }); + self.finish_settings_action(rx, account, true, cx); } - pub(crate) fn settings_test(&mut self, cx: &mut Context) { + pub(crate) fn settings_test(&mut self, account: Account, cx: &mut Context) { if self.settings.busy { return; } - self.settings.busy = true; - self.settings.error = None; + self.settings.begin(account); let api = self.api.clone(); let (tx, rx) = tokio::sync::oneshot::channel(); self.tokio.spawn(async move { - let _ = tx.send(api.test_openf1_login().await); - }); - cx.spawn(async move |this, cx| { - let Ok(outcome) = rx.await else { return }; - let _ = this.update(cx, |this, cx| { - this.settings.busy = false; - match outcome { - Ok(probe) => this.settings.probe = Some(probe), - Err(error) => { - this.settings.error = - Some(interval_desktop_core::settings_panel::save_error_message( - Some(&error.to_string()), - )) + let outcome = match account { + Account::OpenF1 => { + api.test_openf1_login() + .await + .map(|probe| SettingsOutcome::OpenF1 { + settings: None, + probe: Some(probe), + }) + } + Account::F1 => { + async { + let probe = api.test_f1_login().await?; + let settings = api.f1_login().await.ok(); + Ok(SettingsOutcome::F1 { + settings, + probe: Some(probe), + }) } + .await } - cx.notify(); - }); - }) - .detach(); + }; + let _ = tx.send(outcome); + }); + self.finish_settings_action(rx, account, false, cx); } - pub(crate) fn settings_clear(&mut self, cx: &mut Context) { + pub(crate) fn settings_clear(&mut self, account: Account, cx: &mut Context) { if self.settings.busy { return; } - self.settings.busy = true; - self.settings.error = None; - self.settings.probe = None; + self.settings.begin(account); let api = self.api.clone(); let store = std::sync::Arc::clone(&self.store); let (tx, rx) = tokio::sync::oneshot::channel(); self.tokio.spawn(async move { - let outcome = async { - api.clear_openf1_login().await?; - let settings = api.openf1_login().await.ok(); - store.check_live(); - Ok::<_, interval_desktop_core::api_client::ApiError>(settings) - } - .await; + let outcome = match account { + Account::OpenF1 => { + async { + api.clear_openf1_login().await?; + let settings = api.openf1_login().await.ok(); + store.check_live(); + Ok(SettingsOutcome::OpenF1 { + settings, + probe: None, + }) + } + .await + } + Account::F1 => { + async { + api.clear_f1_login().await?; + let settings = api.f1_login().await.ok(); + Ok(SettingsOutcome::F1 { + settings, + probe: None, + }) + } + .await + } + }; let _ = tx.send(outcome); }); + self.finish_settings_action(rx, account, false, cx); + } + + fn finish_settings_action( + &self, + rx: tokio::sync::oneshot::Receiver, + account: Account, + clear_inputs: bool, + cx: &mut Context, + ) { cx.spawn(async move |this, cx| { let Ok(outcome) = rx.await else { return }; let _ = this.update(cx, |this, cx| { this.settings.busy = false; match outcome { - Ok(settings) => { - if settings.is_some() { - this.settings.settings = Some(settings); + Ok(outcome) => { + if clear_inputs { + this.settings.account_mut(account).clear_inputs(); } + this.apply_settings_outcome(outcome); } Err(error) => { - this.settings.error = + this.settings.account_mut(account).error = Some(interval_desktop_core::settings_panel::save_error_message( Some(&error.to_string()), )) @@ -329,6 +367,27 @@ impl IntervalApp { .detach(); } + fn apply_settings_outcome(&mut self, outcome: SettingsOutcome) { + match outcome { + SettingsOutcome::OpenF1 { settings, probe } => { + if settings.is_some() { + self.settings.settings = Some(settings); + } + if probe.is_some() { + self.settings.probe = probe; + } + } + SettingsOutcome::F1 { settings, probe } => { + if settings.is_some() { + self.settings.f1_settings = settings; + } + if probe.is_some() { + self.settings.f1_probe = probe; + } + } + } + } + /// Maps a window x-coordinate onto the seek bar and seeks there. pub(crate) fn scrub_to(&mut self, x: gpui::Pixels, cx: &mut Context) { let bounds = self.seek_bounds.get(); diff --git a/desktop-gpui/src/views/mod.rs b/desktop-gpui/src/views/mod.rs index 62c79be..c4b35a4 100644 --- a/desktop-gpui/src/views/mod.rs +++ b/desktop-gpui/src/views/mod.rs @@ -15,7 +15,7 @@ mod window_frame; pub use replay_controls::replay_controls; pub use session_selector::session_selector; -pub use settings_menu::{SettingsUi, settings_menu}; +pub use settings_menu::{Account, SettingsUi, settings_menu}; pub use side_panels::side_panels; pub use stint_timeline::stint_timeline; pub use timing_tower::timing_tower; diff --git a/desktop-gpui/src/views/settings_menu.rs b/desktop-gpui/src/views/settings_menu.rs index 0c5691e..98c9476 100644 --- a/desktop-gpui/src/views/settings_menu.rs +++ b/desktop-gpui/src/views/settings_menu.rs @@ -1,82 +1,159 @@ -//! The OpenF1 account settings popover — the port of +//! The account settings popover — the port of //! `frontend/src/components/SettingsMenu.tsx`, including its hand-rolled text //! inputs (gpui has no text field; echo's pattern: a focusable div, a String buffer, //! and key handling done by hand). //! -//! OpenF1 issues one-hour tokens against an account, so the panel asks for the -//! account rather than a token; the backend exchanges it and re-exchanges on expiry. +//! Two accounts live here, rendered by one `account_section`: the OpenF1 account for +//! live data (OpenF1 issues one-hour tokens, so the panel asks for the account rather +//! than a token; the backend exchanges it and re-exchanges on expiry) and the Formula 1 +//! account FastF1 signs in with to download historical timing, which needs an F1 TV +//! subscription linked to that account. Password inputs are never repopulated from the +//! saved state; the backend only ever reports the username. -use gpui::{Context, FocusHandle, SharedString, Window, div, prelude::*, px, rems, svg}; +use gpui::{Context, Div, FocusHandle, SharedString, Window, div, prelude::*, px, rems, svg}; +use interval_desktop_core::replay_quality::ChannelBadge; use interval_desktop_core::settings_panel::{ - self, OpenF1LoginProbe, OpenF1LoginSettings, is_submittable_login, + self, F1LoginProbe, F1LoginSettings, OpenF1AuthSource, OpenF1LoginProbe, OpenF1LoginSettings, + is_submittable_login, }; use super::ui; use crate::{IntervalApp, theme, updates::UpdateState}; -/// UI state for the popover. The settings themselves are `None` until the initial -/// fetch settles, and `Some(None)` when the routes are unavailable (a web deployment), -/// which hides the gear entirely. -pub struct SettingsUi { - pub open: bool, +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Account { + OpenF1, + F1, +} + +impl Account { + fn index(self) -> usize { + match self { + Account::OpenF1 => 0, + Account::F1 => 1, + } + } +} + +pub struct AccountUi { pub username_input: String, pub password_input: String, pub reveal: bool, - pub busy: bool, - pub probe: Option, pub error: Option, - pub settings: Option>, pub username_focus: FocusHandle, pub password_focus: FocusHandle, } -impl SettingsUi { - pub fn new(cx: &mut Context) -> Self { +impl AccountUi { + fn new(cx: &mut Context) -> Self { Self { - open: false, username_input: String::new(), password_input: String::new(), reveal: false, - busy: false, - probe: None, error: None, - settings: None, username_focus: cx.focus_handle(), password_focus: cx.focus_handle(), } } - pub fn can_submit(&self) -> bool { - !self.busy && is_submittable_login(&self.username_input, &self.password_input) + pub fn clear_inputs(&mut self) { + self.username_input.clear(); + self.password_input.clear(); + } +} + +/// UI state for the popover. The OpenF1 settings are `None` until the initial fetch +/// settles, and `Some(None)` when the routes are unavailable (a web deployment), which +/// hides the gear entirely. +pub struct SettingsUi { + pub open: bool, + pub busy: bool, + pub openf1: AccountUi, + pub f1: AccountUi, + pub probe: Option, + pub f1_probe: Option, + pub settings: Option>, + pub f1_settings: Option, +} + +impl SettingsUi { + pub fn new(cx: &mut Context) -> Self { + Self { + open: false, + busy: false, + openf1: AccountUi::new(cx), + f1: AccountUi::new(cx), + probe: None, + f1_probe: None, + settings: None, + f1_settings: None, + } + } + + pub fn account(&self, account: Account) -> &AccountUi { + match account { + Account::OpenF1 => &self.openf1, + Account::F1 => &self.f1, + } + } + + pub fn account_mut(&mut self, account: Account) -> &mut AccountUi { + match account { + Account::OpenF1 => &mut self.openf1, + Account::F1 => &mut self.f1, + } + } + + pub fn can_submit(&self, account: Account) -> bool { + let inputs = self.account(account); + !self.busy && is_submittable_login(&inputs.username_input, &inputs.password_input) + } + + pub fn begin(&mut self, account: Account) { + self.busy = true; + self.account_mut(account).error = None; + match account { + Account::OpenF1 => self.probe = None, + Account::F1 => self.f1_probe = None, + } } fn buffer_mut(&mut self, field: Field) -> &mut String { - match field { - Field::Username => &mut self.username_input, - Field::Password => &mut self.password_input, + let inputs = self.account_mut(field.account); + match field.half { + Half::Username => &mut inputs.username_input, + Half::Password => &mut inputs.password_input, } } fn focus_handle(&self, field: Field) -> &FocusHandle { - match field { - Field::Username => &self.username_focus, - Field::Password => &self.password_focus, + let inputs = self.account(field.account); + match field.half { + Half::Username => &inputs.username_focus, + Half::Password => &inputs.password_focus, } } } #[derive(Clone, Copy, PartialEq, Eq)] -enum Field { +enum Half { Username, Password, } +#[derive(Clone, Copy, PartialEq, Eq)] +struct Field { + account: Account, + half: Half, +} + impl Field { fn other(self) -> Self { - match self { - Field::Username => Field::Password, - Field::Password => Field::Username, - } + let half = match self.half { + Half::Username => Half::Password, + Half::Password => Half::Username, + }; + Field { half, ..self } } } @@ -86,6 +163,20 @@ fn is_paste_chord(modifiers: &gpui::Modifiers) -> bool { modifiers.secondary() || modifiers.control } +struct AccountSection { + account: Account, + title: &'static str, + source_line: String, + status_line: Option, + username_placeholder: &'static str, + can_test: bool, + can_clear: bool, + badge: Option, + probe_message: Option, + notice: Option<&'static str>, + hint: &'static str, +} + /// The gear cell for the selector bar: `None` while the settings probe is pending or /// the routes are absent. pub fn settings_menu( @@ -96,85 +187,52 @@ pub fn settings_menu( let current = app.settings.settings.as_ref()?.clone()?; let open = app.settings.open; let busy = app.settings.busy; - let reveal = app.settings.reveal; - let username_input = app.settings.username_input.clone(); - let password_input = app.settings.password_input.clone(); - let probe = app.settings.probe.clone(); - let error = app.settings.error.clone(); - - let source_line = settings_panel::login_source_line(¤t); - let env_notice = settings_panel::env_override_notice(¤t); - let probe_badge = probe.as_ref().map(settings_panel::probe_badge); - let can_save = app.settings.can_submit(); - let can_test = !busy && current.configured; - let can_clear = !busy && current.source == settings_panel::OpenF1AuthSource::Settings; - - let action_button = |id: &'static str, - label: SharedString, - enabled: bool, - accent: bool, - cx: &mut Context, - on_click: fn(&mut IntervalApp, &mut Context)| { - div() - .id(id) - .border_1() - .px_3() - .py(px(3.0)) - .font_weight(gpui::FontWeight::SEMIBOLD) - .map(|el| { - if !enabled { - el.border_color(theme::LINE()).text_color(ui::faint()) - } else if accent { - el.border_color(theme::ACCENT()) - .bg(theme::blend(theme::ACCENT(), theme::PANEL(), 0.1)) - .text_color(theme::ACCENT()) - .cursor_pointer() - } else { - el.border_color(theme::LINE()) - .text_color(ui::muted()) - .cursor_pointer() - .hover(|style| { - style.border_color(theme::ACCENT()).text_color(theme::ACCENT()) - }) - } - }) - .when(enabled, |el| { - el.on_click(cx.listener(move |this, _, _window, cx| on_click(this, cx))) - }) - .child(label) - }; - let username_placeholder: Option = username_input.is_empty().then(|| { - match current.username.as_deref().filter(|name| !name.is_empty()) { - Some(name) => SharedString::from(name.to_string()), - None => SharedString::from("OpenF1 account email"), - } - }); - let password_placeholder: Option = password_input - .is_empty() - .then(|| SharedString::from("Password")); - let password_display: SharedString = if reveal { - SharedString::from(password_input.clone()) - } else { - SharedString::from("•".repeat(password_input.chars().count())) + let openf1_section = AccountSection { + account: Account::OpenF1, + title: "OPENF1 ACCOUNT (LIVE)", + source_line: settings_panel::login_source_line(¤t), + status_line: None, + username_placeholder: "OpenF1 account email", + can_test: !busy && current.configured, + can_clear: !busy && current.source == OpenF1AuthSource::Settings, + badge: app.settings.probe.as_ref().map(settings_panel::probe_badge), + probe_message: app + .settings + .probe + .as_ref() + .map(|probe| probe.message.clone()), + notice: settings_panel::env_override_notice(¤t), + hint: "OpenF1 tokens last an hour; the app signs in again on its own.", }; - - let username_field = text_input( - app, - window, - cx, - Field::Username, - SharedString::from(username_input), - username_placeholder, - ); - let password_field = text_input( - app, - window, - cx, - Field::Password, - password_display, - password_placeholder, - ); + let f1_section = app.settings.f1_settings.as_ref().map(|f1| AccountSection { + account: Account::F1, + title: "F1 TV ACCOUNT (HISTORICAL)", + source_line: settings_panel::f1_login_source_line(f1), + status_line: settings_panel::f1_verification_line(f1), + username_placeholder: "Formula 1 account email", + can_test: !busy && f1.configured, + can_clear: !busy && f1.source == OpenF1AuthSource::Settings, + badge: app + .settings + .f1_probe + .as_ref() + .map(settings_panel::f1_probe_badge), + probe_message: app + .settings + .f1_probe + .as_ref() + .map(|probe| probe.message.clone()), + notice: settings_panel::f1_env_override_notice(f1), + hint: settings_panel::F1_ACCOUNT_HINT, + }); + let saved_usernames = [ + current.username.clone(), + app.settings + .f1_settings + .as_ref() + .and_then(|f1| f1.username.clone()), + ]; let (update_label, update_hint, update_active) = app.update_state.presentation(); let update_available = matches!(app.update_state, UpdateState::Available(_)); @@ -182,18 +240,7 @@ pub fn settings_menu( Some(version) => format!("Version {version}"), None => "Development build".to_string(), }; - let update_section = div() - .mt_2() - .pt_2() - .border_t_1() - .border_color(theme::LINE()) - .child( - div() - .mb_2() - .font_weight(gpui::FontWeight::SEMIBOLD) - .text_color(theme::ACCENT()) - .child("APP"), - ) + let update_section = section_frame("APP") .child(div().mb_2().text_color(ui::muted()).child(version_line)) .child(action_button( "settings-update", @@ -210,6 +257,22 @@ pub fn settings_menu( .text_color(ui::muted()) .child(hint) })); + let stored_line = current.path.clone().map(|path| { + div() + .mt_2() + .text_size(rems(0.62)) + .text_color(ui::faint()) + .child(format!("Stored in {path}")) + }); + + let max_height = window.viewport_size().height - px(96.0); + let sections = std::iter::once(openf1_section) + .chain(f1_section) + .map(|section| { + let saved = saved_usernames[section.account.index()].clone(); + account_section(app, window, cx, section, saved).into_any_element() + }) + .collect::>(); Some( div() @@ -232,7 +295,7 @@ pub fn settings_menu( .on_click(cx.listener(|this, _, window, cx| { this.settings.open = !this.settings.open; if this.settings.open { - window.focus(&this.settings.username_focus, cx); + window.focus(&this.settings.openf1.username_focus, cx); } cx.notify(); })) @@ -245,140 +308,250 @@ pub fn settings_menu( ) .when(open, |el| { el.child(gpui::deferred( - gpui::anchored() - .snap_to_window_with_margin(px(8.0)) - .child( - div() - .id("settings-panel") - .occlude() - .mt(px(4.0)) - .w(rems(24.0)) - .border_1() - .border_color(theme::LINE()) - .bg(theme::PANEL()) - .p_3() - .shadow_lg() - .text_size(rems(0.72)) - .on_mouse_down_out(cx.listener(|this, _, _window, cx| { - this.settings.open = false; - cx.notify(); - })) - .child( - div() - .mb_2() - .font_weight(gpui::FontWeight::SEMIBOLD) - .text_color(theme::ACCENT()) - .child("OPENF1 ACCOUNT"), - ) - .child( - div() - .mb_2() - .text_color(ui::muted()) - .child(source_line), - ) - .child(div().mb_2().child(username_field)) - .child( - div() - .mb_2() - .flex() - .flex_row() - .items_center() - .gap_2() - .child(password_field) - .child( - div() - .id("settings-reveal") - .border_1() - .border_color(theme::LINE()) - .px_2() - .py_1() - .font_weight(gpui::FontWeight::SEMIBOLD) - .text_color(ui::muted()) - .cursor_pointer() - .hover(|style| { - style - .border_color(theme::ACCENT()) - .text_color(theme::ACCENT()) - }) - .on_click(cx.listener(|this, _, _window, cx| { - this.settings.reveal = - !this.settings.reveal; - cx.notify(); - })) - .child(if reveal { "HIDE" } else { "SHOW" }), - ), - ) - .child( - div() - .mb_2() - .flex() - .flex_row() - .items_center() - .gap_2() - .child(action_button( - "settings-save", - "SIGN IN".into(), - can_save, - true, - cx, - |this, cx| this.settings_save(cx), - )) - .child(action_button( - "settings-test", - "TEST".into(), - can_test, - false, - cx, - |this, cx| this.settings_test(cx), - )) - .child(action_button( - "settings-clear", - "SIGN OUT".into(), - can_clear, - false, - cx, - |this, cx| this.settings_clear(cx), - )) - .children( - probe_badge - .as_ref() - .map(|badge| ui::channel_badge(badge)), - ), - ) - .children(probe.as_ref().map(|probe| { - div() - .mb_2() - .text_color(ui::muted()) - .child(probe.message.clone()) - })) - .children(error.map(|message| { - div().mb_2().text_color(theme::DANGER()).child(message) - })) - .children(env_notice.map(|notice| { - div().mb_2().text_color(theme::AMBER()).child(notice) - })) - .child( - div() - .mb_1() - .text_size(rems(0.62)) - .text_color(ui::faint()) - .child( - "OpenF1 tokens last an hour; the app signs in again on its own.", - ), - ) - .children(current.path.clone().map(|path| { - div() - .text_size(rems(0.62)) - .text_color(ui::faint()) - .child(format!("Stored in {path}")) - })) - .child(update_section), - ), + gpui::anchored().snap_to_window_with_margin(px(8.0)).child( + div() + .id("settings-panel") + .occlude() + .mt(px(4.0)) + .w(rems(25.0)) + .max_h(max_height) + .overflow_y_scroll() + .border_1() + .border_color(theme::LINE()) + .bg(theme::PANEL()) + .p_3() + .shadow_lg() + .text_size(rems(0.72)) + .on_mouse_down_out(cx.listener(|this, _, _window, cx| { + this.settings.open = false; + cx.notify(); + })) + .children(sections) + .child(update_section) + .children(stored_line), + ), )) }), ) } +fn section_frame(title: &'static str) -> Div { + div() + .mt_2() + .pt_2() + .border_t_1() + .border_color(theme::LINE()) + .child( + div() + .mb_2() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme::ACCENT()) + .child(title), + ) +} + +fn account_section( + app: &IntervalApp, + window: &Window, + cx: &mut Context, + section: AccountSection, + saved_username: Option, +) -> Div { + let account = section.account; + let inputs = app.settings.account(account); + let reveal = inputs.reveal; + let error = inputs.error.clone(); + let can_save = app.settings.can_submit(account); + + let username_placeholder: Option = inputs.username_input.is_empty().then(|| { + match saved_username.as_deref().filter(|name| !name.is_empty()) { + Some(name) => SharedString::from(name.to_string()), + None => SharedString::from(section.username_placeholder), + } + }); + let password_placeholder: Option = inputs + .password_input + .is_empty() + .then(|| SharedString::from("Password")); + let password_display: SharedString = if reveal { + SharedString::from(inputs.password_input.clone()) + } else { + SharedString::from("•".repeat(inputs.password_input.chars().count())) + }; + let username_display = SharedString::from(inputs.username_input.clone()); + + let username_field = text_input( + app, + window, + cx, + Field { + account, + half: Half::Username, + }, + username_display, + username_placeholder, + ); + let password_field = text_input( + app, + window, + cx, + Field { + account, + half: Half::Password, + }, + password_display, + password_placeholder, + ); + + let frame = match account { + Account::OpenF1 => div().child( + div() + .mb_2() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme::ACCENT()) + .child(section.title), + ), + Account::F1 => section_frame(section.title), + }; + + frame + .child( + div() + .mb_2() + .text_color(ui::muted()) + .child(section.source_line), + ) + .children(section.status_line.map(|line| { + div() + .mb_2() + .text_size(rems(0.62)) + .text_color(ui::muted()) + .child(line) + })) + .child(div().mb_2().child(username_field)) + .child( + div() + .mb_2() + .flex() + .flex_row() + .items_center() + .gap_2() + .child(password_field) + .child( + div() + .id(("settings-reveal", account.index())) + .border_1() + .border_color(theme::LINE()) + .px_2() + .py_1() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(ui::muted()) + .cursor_pointer() + .hover(|style| { + style + .border_color(theme::ACCENT()) + .text_color(theme::ACCENT()) + }) + .on_click(cx.listener(move |this, _, _window, cx| { + let inputs = this.settings.account_mut(account); + inputs.reveal = !inputs.reveal; + cx.notify(); + })) + .child(if reveal { "HIDE" } else { "SHOW" }), + ), + ) + .child( + div() + .mb_2() + .flex() + .flex_row() + .items_center() + .gap_2() + .child(action_button( + ("settings-save", account.index()), + "SIGN IN".into(), + can_save, + true, + cx, + move |this, cx| this.settings_save(account, cx), + )) + .child(action_button( + ("settings-test", account.index()), + "TEST".into(), + section.can_test, + false, + cx, + move |this, cx| this.settings_test(account, cx), + )) + .child(action_button( + ("settings-clear", account.index()), + "SIGN OUT".into(), + section.can_clear, + false, + cx, + move |this, cx| this.settings_clear(account, cx), + )) + .children(section.badge.as_ref().map(|badge| ui::channel_badge(badge))), + ) + .children( + section + .probe_message + .map(|message| div().mb_2().text_color(ui::muted()).child(message)), + ) + .children(error.map(|message| div().mb_2().text_color(theme::DANGER()).child(message))) + .children( + section + .notice + .map(|notice| div().mb_2().text_color(theme::AMBER()).child(notice)), + ) + .child( + div() + .mb_1() + .text_size(rems(0.62)) + .text_color(ui::faint()) + .child(section.hint), + ) +} + +fn action_button( + id: impl Into, + label: SharedString, + enabled: bool, + accent: bool, + cx: &mut Context, + on_click: impl Fn(&mut IntervalApp, &mut Context) + 'static, +) -> gpui::Stateful
{ + div() + .id(id) + .border_1() + .px_3() + .py(px(3.0)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .map(|el| { + if !enabled { + el.border_color(theme::LINE()).text_color(ui::faint()) + } else if accent { + el.border_color(theme::ACCENT()) + .bg(theme::blend(theme::ACCENT(), theme::PANEL(), 0.1)) + .text_color(theme::ACCENT()) + .cursor_pointer() + } else { + el.border_color(theme::LINE()) + .text_color(ui::muted()) + .cursor_pointer() + .hover(|style| { + style + .border_color(theme::ACCENT()) + .text_color(theme::ACCENT()) + }) + } + }) + .when(enabled, |el| { + el.on_click(cx.listener(move |this, _, _window, cx| on_click(this, cx))) + }) + .child(label) +} + /// One hand-rolled input. `display` is what to draw (already masked for the password), /// `placeholder` what to draw instead when the buffer is empty. fn text_input( @@ -391,9 +564,9 @@ fn text_input( ) -> gpui::AnyElement { let focus = app.settings.focus_handle(field).clone(); let focused = focus.is_focused(window); - let id: &'static str = match field { - Field::Username => "settings-username-input", - Field::Password => "settings-password-input", + let id: gpui::ElementId = match field.half { + Half::Username => ("settings-username-input", field.account.index()).into(), + Half::Password => ("settings-password-input", field.account.index()).into(), }; div() .id(id) @@ -414,11 +587,11 @@ fn text_input( window.focus(this.settings.focus_handle(field), cx); cx.notify(); })) - .on_key_down(cx.listener( - move |this, event: &gpui::KeyDownEvent, window, cx| { + .on_key_down( + cx.listener(move |this, event: &gpui::KeyDownEvent, window, cx| { handle_input_key(this, field, event, window, cx); - }, - )) + }), + ) .child(match placeholder { Some(placeholder) => div() .text_color(ui::faint()) @@ -456,18 +629,18 @@ fn handle_input_key( cx.notify(); return; } - // Two fields, so tab and shift-tab both mean "the other one". + // Two fields per account, so tab and shift-tab both mean "the other one". "tab" => { window.focus(this.settings.focus_handle(field.other()), cx); cx.notify(); return; } "enter" => { - if this.settings.can_submit() { - this.settings_save(cx); - } else if field == Field::Username { + if this.settings.can_submit(field.account) { + this.settings_save(field.account, cx); + } else if field.half == Half::Username { // Enter on a filled email is "next field", as in a browser form. - window.focus(&this.settings.password_focus, cx); + window.focus(this.settings.focus_handle(field.other()), cx); cx.notify(); } return; @@ -476,9 +649,9 @@ fn handle_input_key( if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) { // A pasted email never wants its surrounding whitespace; a pasted // password is taken as-is, since OpenF1 generated it. - let text = match field { - Field::Username => text.trim().to_string(), - Field::Password => text, + let text = match field.half { + Half::Username => text.trim().to_string(), + Half::Password => text, }; this.settings.buffer_mut(field).push_str(&text); cx.notify(); diff --git a/docs/project-overview.md b/docs/project-overview.md index e8f861a..157e8dc 100644 --- a/docs/project-overview.md +++ b/docs/project-overview.md @@ -216,3 +216,4 @@ The MVP is successful if it: 4. Replay, live simulation, and OpenF1 live use the same `replay.v1` snapshot/event contract. 5. The dashboard should stay fan-dense and data-first, with compact tables, timelines, map context, and channel-quality badges. 6. Real OpenF1 live behavior still needs validation during an active race or sprint window. +7. Historical ingestion signs in to the user's Formula 1 account (F1 TV subscription) through FastF1 3.9's native password login. The backend hands the login to the exporter only through the child's environment, FastF1 owns sign-in and token refresh, and tokens are held in memory for one exporter run (no token file), so a token is only ever used with the credentials that produced it. Live authenticated ingestion has only been exercised with mocked sign-ins so far. diff --git a/scripts/fastf1-requirements.txt b/scripts/fastf1-requirements.txt index 17689a5..9d0f565 100644 --- a/scripts/fastf1-requirements.txt +++ b/scripts/fastf1-requirements.txt @@ -1,3 +1,3 @@ -fastf1>=3.5,<4 +fastf1==3.9.0a0 numpy>=1.26 pandas>=2.1 diff --git a/scripts/fastf1_export_replay.py b/scripts/fastf1_export_replay.py index 2de7f94..a056a28 100644 --- a/scripts/fastf1_export_replay.py +++ b/scripts/fastf1_export_replay.py @@ -4,13 +4,17 @@ from __future__ import annotations import argparse +import contextlib +import inspect import json import math import os import re import sys +from collections.abc import Iterator from datetime import datetime, timezone from typing import Any +from urllib.parse import urlsplit import numpy as np import pandas as pd @@ -20,7 +24,156 @@ except ModuleNotFoundError: fastf1 = None +try: + import requests +except ModuleNotFoundError: + requests = None + POSITION_SAMPLE_SECONDS = 0.5 +F1_USERNAME_ENV = "F1_USERNAME" +F1_PASSWORD_ENV = "F1_PASSWORD" +SIGN_IN_HINT = ( + "Sign in with the Formula 1 account linked to your F1 TV subscription in Settings, " + f"or set {F1_USERNAME_ENV} and {F1_PASSWORD_ENV}." +) +MISSING_CREDENTIALS_MESSAGE = ( + "No Formula 1 account is configured. Enter the email and password of the Formula 1 " + f"account linked to your F1 TV subscription, or set {F1_USERNAME_ENV} and {F1_PASSWORD_ENV}." +) + + +class ExportError(RuntimeError): + pass + + +class SessionTokenStore: + def load(self) -> None: + return None + + def save(self, token: Any) -> None: + return None + + def delete(self) -> None: + return None + + +class LivetimingAuthWatch: + def __init__(self, hosts: frozenset[str]) -> None: + self.hosts = hosts + self.rejections: list[tuple[int, str]] = [] + + def record(self, response: Any) -> None: + if response.status_code in (401, 403) and urlsplit(response.url).netloc in self.hosts: + self.rejections.append((response.status_code, response.url)) + + def raise_if_rejected(self, authenticated: bool) -> None: + if not self.rejections: + return + status, url = self.rejections[0] + hint = ( + "The signed-in Formula 1 account was not accepted for this data; check that its " + "F1 TV subscription is active and try again." + if authenticated + else f"Historical timing data requires an F1 TV subscription. {SIGN_IN_HINT}" + ) + raise ExportError(f"The F1 archive rejected the request for {url} (HTTP {status}). {hint}") + + +def f1account() -> Any: + if fastf1 is None: + raise RuntimeError("fastf1 is required for Formula 1 account authentication") + return fastf1.internals.f1account + + +def f1_credentials_configured() -> bool: + return bool(os.environ.get(F1_USERNAME_ENV)) and bool(os.environ.get(F1_PASSWORD_ENV)) + + +def configure_auth() -> None: + account = f1account() + fastf1.set_auth_method("password") + account.token_manager._store = SessionTokenStore() + account.token_manager.clear() + + +def archive_hosts(api: Any) -> frozenset[str]: + return frozenset(urlsplit(url).netloc for url in (api.base_url, api.base_url_mirror)) + + +@contextlib.contextmanager +def watch_livetiming_auth(cache: Any, hosts: frozenset[str]) -> Iterator[LivetimingAuthWatch]: + watch = LivetimingAuthWatch(hosts) + original = inspect.getattr_static(cache, "requests_get") + requests_get = cache.requests_get + + def observed_get(url: Any, **kwargs: Any) -> Any: + response = requests_get(url, **kwargs) + watch.record(response) + return response + + cache.requests_get = observed_get + try: + yield watch + finally: + cache.requests_get = original + + +def preflight_auth() -> bool: + configure_auth() + if not f1_credentials_configured(): + return False + try: + f1account().get_token() + except fastf1.exceptions.F1AuthError as exc: + raise ExportError(f"Formula 1 account sign-in failed: {exc}") from exc + return True + + +def classify_auth_error(exc: Exception) -> str: + cause = exc.__cause__ + if requests is not None and isinstance(cause, requests.RequestException): + return "invalid" if isinstance(cause, requests.HTTPError) else "unreachable" + message = str(exc).lower() + if "subscription" in message: + return "no_subscription" + if "username and password" in message: + return "unauthorized" + return "invalid" + + +def auth_check() -> dict[str, str]: + configure_auth() + if not f1_credentials_configured(): + return {"result": "missing", "message": MISSING_CREDENTIALS_MESSAGE} + try: + token = f1account().get_token(force_renew=True, method="password") + except fastf1.exceptions.F1AuthError as exc: + return {"result": classify_auth_error(exc), "message": str(exc)} + try: + claims = token.claims + except Exception: + claims = {} + status = claims.get("SubscriptionStatus") or "unknown" + product = claims.get("SubscribedProduct") or "F1 TV" + return { + "result": "ok", + "message": f"Signed in to Formula 1. Subscription status: {status} ({product}).", + } + + +def require_lap_data(session: Any, authenticated: bool) -> None: + try: + laps = session.laps + except Exception: + laps = None + if laps is not None and not laps.empty: + return + hint = ( + "The session may not be available in the F1 archive yet." + if authenticated + else f"Historical timing data requires an F1 TV subscription. {SIGN_IN_HINT}" + ) + raise ExportError(f"FastF1 returned no lap timing data for this session. {hint}") def clean_float(value: Any) -> float | None: @@ -264,22 +417,30 @@ def parse_datetime(value: Any) -> datetime | None: return dt.astimezone(timezone.utc) -def load_session(year: int, round_number: int, session_code: str, cache_dir: str): +def load_session( + year: int, + round_number: int, + session_code: str, + cache_dir: str, + authenticated: bool = False, +): if fastf1 is None: raise RuntimeError("fastf1 is required to load historical replay sessions") os.makedirs(cache_dir, exist_ok=True) fastf1.Cache.enable_cache(cache_dir) session = fastf1.get_session(year, round_number, session_code) - try: - session.load(telemetry=True, weather=True, messages=True) - except TypeError: + with watch_livetiming_auth(fastf1.Cache, archive_hosts(fastf1._api)) as watch: try: - session.load(telemetry=True, weather=True) + session.load(telemetry=True, weather=True, messages=True) except TypeError: try: - session.load(telemetry=True) + session.load(telemetry=True, weather=True) except TypeError: - session.load() + try: + session.load(telemetry=True) + except TypeError: + session.load() + watch.raise_if_rejected(authenticated) return session @@ -670,13 +831,15 @@ def export_bundle( session_start: str | None, resolver_method: str, ) -> dict[str, Any]: + authenticated = preflight_auth() resolved_round, resolver = resolve_round( year, round_number, event_name, country, location, session_start ) if resolver_method == "curated_override": resolver["match_method"] = "curated_override" resolver["match_confidence"] = 1.0 - session = load_session(year, resolved_round, session_code, cache_dir) + session = load_session(year, resolved_round, session_code, cache_dir, authenticated) + require_lap_data(session, authenticated) laps, stints, pits = export_laps_and_stints(session) telemetry = export_telemetry(session) warnings = resolver.get("warnings", []) @@ -721,33 +884,53 @@ def export_bundle( } -def main() -> int: +def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser() - parser.add_argument("--year", type=int, required=True) + parser.add_argument("--year", type=int) parser.add_argument("--round", type=int) - parser.add_argument("--session", required=True) - parser.add_argument("--session-key", type=int, required=True) + parser.add_argument("--session") + parser.add_argument("--session-key", type=int) parser.add_argument("--event-name") parser.add_argument("--country") parser.add_argument("--location") parser.add_argument("--session-start") parser.add_argument("--resolver-method", default="provided_round") parser.add_argument("--cache-dir", default="cache/fastf1") - parser.add_argument("--output", required=True) - args = parser.parse_args() - - bundle = export_bundle( - args.year, - args.round, - args.session, - args.session_key, - args.cache_dir, - args.event_name, - args.country, - args.location, - args.session_start, - args.resolver_method, - ) + parser.add_argument("--auth-check", action="store_true") + parser.add_argument("--output") + args = parser.parse_args(argv) + + if args.auth_check: + result = auth_check() + print(json.dumps(result)) + return 0 if result["result"] == "ok" else 1 + + required = { + "--year": args.year, + "--session": args.session, + "--session-key": args.session_key, + "--output": args.output, + } + missing = [name for name, value in required.items() if value is None] + if missing: + parser.error(f"the following arguments are required: {', '.join(missing)}") + + try: + bundle = export_bundle( + args.year, + args.round, + args.session, + args.session_key, + args.cache_dir, + args.event_name, + args.country, + args.location, + args.session_start, + args.resolver_method, + ) + except ExportError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 os.makedirs(os.path.dirname(args.output), exist_ok=True) with open(args.output, "w", encoding="utf-8") as handle: json.dump(bundle, handle, separators=(",", ":")) diff --git a/scripts/test_fastf1_export_replay.py b/scripts/test_fastf1_export_replay.py index 69f6a05..92d9025 100644 --- a/scripts/test_fastf1_export_replay.py +++ b/scripts/test_fastf1_export_replay.py @@ -1,9 +1,18 @@ +import contextlib +import inspect +import io +import json +import os +import sys +import tempfile +import time import unittest from pathlib import Path -import sys +from typing import Any from unittest.mock import patch import pandas as pd +import urllib3 sys.path.insert(0, str(Path(__file__).resolve().parent)) import fastf1_export_replay as export @@ -143,6 +152,323 @@ def test_uses_session_date_when_text_metadata_is_not_confident(self): self.assertTrue(metadata["warnings"]) +class ExportGuardTests(unittest.TestCase): + def test_empty_laps_fail_with_a_sign_in_hint_when_not_authenticated(self) -> None: + with self.assertRaises(export.ExportError) as ctx: + export.require_lap_data(FakeSession(pd.DataFrame()), authenticated=False) + self.assertIn("F1 TV subscription", str(ctx.exception)) + self.assertIn("F1_USERNAME", str(ctx.exception)) + + def test_empty_laps_fail_with_an_archive_hint_when_authenticated(self) -> None: + with self.assertRaises(export.ExportError) as ctx: + export.require_lap_data(FakeSession(pd.DataFrame()), authenticated=True) + self.assertIn("archive", str(ctx.exception)) + self.assertNotIn("subscription", str(ctx.exception)) + + def test_unloaded_laps_fail_instead_of_exporting_an_empty_bundle(self) -> None: + with self.assertRaises(export.ExportError): + export.require_lap_data(FakeSession(None), authenticated=True) + + def test_loaded_laps_pass(self) -> None: + export.require_lap_data(FakeSession(pd.DataFrame([{"LapNumber": 1}])), authenticated=False) + + def test_main_reports_export_errors_as_one_stderr_line(self) -> None: + stderr = io.StringIO() + with patch.object(export, "export_bundle", side_effect=export.ExportError("boom")): + with contextlib.redirect_stderr(stderr): + code = export.main( + ["--year", "2024", "--session", "R", "--session-key", "1", "--output", "x.json"] + ) + self.assertEqual(code, 1) + self.assertEqual(stderr.getvalue(), "error: boom\n") + + def test_main_auth_check_prints_json_and_skips_export_arguments(self) -> None: + stdout = io.StringIO() + with patch.object(export, "auth_check", return_value={"result": "missing", "message": "m"}): + with contextlib.redirect_stdout(stdout): + code = export.main(["--auth-check"]) + self.assertEqual(code, 1) + self.assertEqual(json.loads(stdout.getvalue()), {"result": "missing", "message": "m"}) + + def test_main_still_requires_export_arguments(self) -> None: + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit): + export.main(["--year", "2024"]) + + +@unittest.skipIf(export.fastf1 is None, "fastf1 is not installed") +class ArchiveRejectionTests(unittest.TestCase): + PATH = "/static/2024/x/" + PRIMARY = "https://livetiming.formula1.com/" + MIRROR = "https://livetiming-mirror.fastf1.dev/" + + def setUp(self) -> None: + self.cache = export.fastf1.Cache + self.hosts = export.archive_hosts(export.fastf1._api) + cache_dir = tempfile.TemporaryDirectory(prefix="interval-cache-") + self.addCleanup(cache_dir.cleanup) + self.cache_dir = cache_dir.name + self.addCleanup(self.restore_cache, self.cache._CACHE_DIR, self.cache._requests_session_cached) + + def restore_cache(self, cache_dir: str | None, session: Any) -> None: + if self.cache._requests_session_cached is not None: + self.cache._requests_session_cached.close() + self.cache._CACHE_DIR = cache_dir + self.cache._requests_session_cached = session + + def configure(self, statuses: dict[str, int]) -> None: + self.cache.configure(cache_dir=self.cache_dir) + mount(self.cache, statuses) + + def fetch(self) -> Any: + return export.fastf1._api.fetch_page(self.PATH, "timing_data") + + def test_primary_rejection_followed_by_mirror_success_is_not_a_rejection(self) -> None: + self.configure({self.PRIMARY: 403, self.MIRROR: 200}) + with export.watch_livetiming_auth(self.cache, self.hosts) as watch: + page = self.fetch() + self.assertEqual(page, []) + self.assertEqual(watch.rejections, []) + watch.raise_if_rejected(authenticated=False) + + def test_unrelated_host_rejections_are_ignored(self) -> None: + self.configure({"https://api.jolpi.ca/": 403}) + with export.watch_livetiming_auth(self.cache, self.hosts) as watch: + response = self.cache.requests_get("https://api.jolpi.ca/ergast/f1/2024.json") + self.assertEqual(response.status_code, 403) + self.assertEqual(watch.rejections, []) + + def test_final_archive_rejections_are_recorded(self) -> None: + self.configure({}) + for status in (401, 403): + with self.subTest(status=status): + mount(self.cache, {self.PRIMARY: status, self.MIRROR: status}) + with export.watch_livetiming_auth(self.cache, self.hosts) as watch: + self.assertIsNone(self.fetch()) + self.assertEqual(watch.rejections, [(status, f"{self.MIRROR}static/2024/x/TimingData.jsonStream")]) + with self.assertRaises(export.ExportError) as ctx: + watch.raise_if_rejected(authenticated=False) + self.assertIn("HTTP 403", str(ctx.exception)) + self.assertIn("F1 TV subscription", str(ctx.exception)) + with self.assertRaises(export.ExportError) as ctx: + watch.raise_if_rejected(authenticated=True) + self.assertIn("subscription is active", str(ctx.exception)) + + def test_requests_get_is_restored_after_success_and_error(self) -> None: + original = inspect.getattr_static(self.cache, "requests_get") + with export.watch_livetiming_auth(self.cache, self.hosts): + self.assertIsNot(inspect.getattr_static(self.cache, "requests_get"), original) + self.assertIs(inspect.getattr_static(self.cache, "requests_get"), original) + with self.assertRaises(RuntimeError), export.watch_livetiming_auth(self.cache, self.hosts): + raise RuntimeError("load failed") + self.assertIs(inspect.getattr_static(self.cache, "requests_get"), original) + + def test_load_session_fails_when_telemetry_is_rejected_despite_cached_laps(self) -> None: + fake = FakeFastF1Loader(self.cache, {self.PRIMARY: 403, self.MIRROR: 403}) + with patch.object(export, "fastf1", fake): + with self.assertRaises(export.ExportError) as ctx: + export.load_session(2024, 1, "R", self.cache_dir, True) + self.assertIn("HTTP 403", str(ctx.exception)) + self.assertTrue(fake.session.loaded) + + def test_load_session_succeeds_when_the_mirror_serves_what_the_primary_rejects(self) -> None: + fake = FakeFastF1Loader(self.cache, {self.PRIMARY: 403, self.MIRROR: 200}) + with patch.object(export, "fastf1", fake): + session = export.load_session(2024, 1, "R", self.cache_dir, False) + self.assertIs(session, fake.session) + self.assertEqual(session.page, []) + + +@unittest.skipIf(export.fastf1 is None, "fastf1 is not installed") +class F1AccountAuthTests(unittest.TestCase): + def setUp(self) -> None: + self.account = export.f1account() + export.configure_auth() + self.addCleanup(self.account.token_manager.clear) + + def credentials(self, username: str = "driver@example.com", password: str = "hunter2") -> Any: + return patch.dict(os.environ, {"F1_USERNAME": username, "F1_PASSWORD": password}) + + def no_credentials(self) -> Any: + env = {key: value for key, value in os.environ.items() if key not in ("F1_USERNAME", "F1_PASSWORD")} + return patch.dict(os.environ, env, clear=True) + + def auth_error(self, message: str, cause: BaseException | None = None) -> Any: + error = export.fastf1.exceptions.F1AuthError(message) + + def raise_error(_credentials: Any) -> None: + raise error from cause + + return patch.object(self.account, "login", side_effect=raise_error) + + def token(self, subject: str) -> Any: + claims = {"exp": time.time() + 3600, "sub": subject, "SubscriptionStatus": "active"} + return self.account.SubscriptionToken.from_jwt(encode_jwt(claims)) + + def test_tokens_live_only_in_memory_for_one_run(self) -> None: + self.assertIsInstance(self.account.token_manager._store, export.SessionTokenStore) + self.assertEqual(self.account.get_auth_method(), "password") + with self.credentials(), patch.object(self.account, "login", return_value=self.token("a")) as login: + first = self.account.get_token() + second = self.account.get_token() + export.configure_auth() + third = self.account.get_token() + + self.assertEqual(first.value, second.value) + self.assertEqual(third.value, first.value) + self.assertEqual(login.call_count, 2) + + def test_switching_accounts_signs_in_as_the_new_account(self) -> None: + tokens = {"a@example.test": self.token("a"), "b@example.test": self.token("b")} + + def login(credentials: Any) -> Any: + return tokens[credentials.username] + + with patch.object(self.account, "login", side_effect=login) as login_mock: + with self.credentials("a@example.test", "pw-a"): + export.configure_auth() + self.assertEqual(self.account.get_token().value, tokens["a@example.test"].value) + with self.credentials("b@example.test", "pw-b"): + export.configure_auth() + self.assertEqual(self.account.get_token().value, tokens["b@example.test"].value) + + self.assertEqual([call.args[0].username for call in login_mock.call_args_list], ["a@example.test", "b@example.test"]) + + def test_changed_password_signs_in_again_with_the_new_password(self) -> None: + with patch.object(self.account, "login", return_value=self.token("a")) as login: + with self.credentials("a@example.test", "old-password"): + export.configure_auth() + self.account.get_token() + with self.credentials("a@example.test", "new-password"): + export.configure_auth() + self.account.get_token() + + self.assertEqual([call.args[0].password for call in login.call_args_list], ["old-password", "new-password"]) + + def test_failed_sign_in_leaves_no_token_behind(self) -> None: + with self.credentials(), self.auth_error("Sign-in was rejected. Check username and password."): + self.assertEqual(export.auth_check()["result"], "unauthorized") + with self.assertRaises(export.fastf1.exceptions.F1AuthError): + self.account.get_token() + self.assertIsNone(self.account.token_manager._store.load()) + + def test_auth_check_reports_missing_credentials_without_prompting(self) -> None: + with self.no_credentials(), patch.object(sys, "stdin", FakeTty()): + with patch("builtins.input", side_effect=AssertionError("prompted")): + with patch("getpass.getpass", side_effect=AssertionError("prompted")): + result = export.auth_check() + self.assertEqual(result["result"], "missing") + self.assertIn("F1_USERNAME", result["message"]) + + def test_auth_check_signs_in_with_the_configured_credentials(self) -> None: + token = self.account.SubscriptionToken.from_jwt( + encode_jwt({"exp": time.time() + 3600, "SubscriptionStatus": "active", "SubscribedProduct": "F1 TV Premium"}) + ) + with self.credentials(), patch.object(self.account, "login", return_value=token) as login: + result = export.auth_check() + + self.assertEqual(result["result"], "ok") + self.assertIn("active", result["message"]) + self.assertIn("F1 TV Premium", result["message"]) + self.assertEqual(login.call_args.args[0].username, "driver@example.com") + self.assertEqual(login.call_args.args[0].password, "hunter2") + + def test_auth_check_classifies_sign_in_failures(self) -> None: + connection_error = None + if export.requests is not None: + connection_error = export.requests.ConnectionError("no route") + cases = [ + ("Sign-in was rejected. Check username and password.", None, "unauthorized"), + ("Access token contains no subscription token. An active F1TV subscription is required.", None, "no_subscription"), + ("Resuming the sign-in did not redirect (status 500).", None, "invalid"), + ("Sign-in failed: no route", connection_error, "unreachable"), + ] + for message, cause, expected in cases: + with self.subTest(expected=expected), self.credentials(), self.auth_error(message, cause): + result = export.auth_check() + self.assertEqual(result["result"], expected) + self.assertEqual(result["message"], message) + + def test_export_preflight_fails_before_the_session_is_loaded(self) -> None: + with self.credentials(), self.auth_error("Sign-in was rejected. Check username and password."): + with patch.object(export, "load_session", side_effect=AssertionError("loaded")): + with self.assertRaises(export.ExportError) as ctx: + export.export_bundle(2024, 1, "R", 9472, "unused", None, None, None, None, "provided_round") + self.assertIn("sign-in failed", str(ctx.exception)) + self.assertIn("username and password", str(ctx.exception)) + + def test_export_without_credentials_does_not_sign_in_and_fails_on_empty_data(self) -> None: + with self.no_credentials(), patch.object(self.account, "login", side_effect=AssertionError("signed in")): + with patch.object(export, "load_session", return_value=FakeSession(pd.DataFrame())): + with self.assertRaises(export.ExportError) as ctx: + export.export_bundle(2024, 1, "R", 9472, "unused", None, None, None, None, "provided_round") + self.assertIn("F1 TV subscription", str(ctx.exception)) + + +def encode_jwt(claims: dict[str, Any]) -> str: + import jwt + + return jwt.encode(claims, "test-secret-that-is-long-enough-for-hs256", algorithm="HS256") + + +class FakeTty(io.StringIO): + def isatty(self) -> bool: + return True + + +class FakeSession: + def __init__(self, laps: pd.DataFrame | None) -> None: + self._laps = laps + + @property + def laps(self) -> pd.DataFrame: + if self._laps is None: + raise RuntimeError("not loaded") + return self._laps + + +class StatusAdapter(export.requests.adapters.HTTPAdapter if export.requests else object): + def __init__(self, status: int) -> None: + super().__init__() + self.status = status + + def send(self, request: Any, **kwargs: Any) -> Any: + raw = urllib3.response.HTTPResponse( + body=io.BytesIO(b""), status=self.status, headers={}, preload_content=False, request_url=request.url + ) + return self.build_response(request, raw) + + +def mount(cache: Any, statuses: dict[str, int]) -> None: + for prefix, status in statuses.items(): + cache._requests_session_cached.mount(prefix, StatusAdapter(status)) + + +class LoadingSession: + def __init__(self, cache: Any, api: Any, statuses: dict[str, int]) -> None: + self.cache = cache + self.api = api + self.statuses = statuses + self.loaded = False + self.page: Any = None + self.laps = pd.DataFrame([{"LapNumber": 1}]) + + def load(self, **_kwargs: Any) -> None: + mount(self.cache, self.statuses) + self.page = self.api.fetch_page(ArchiveRejectionTests.PATH, "timing_data") + self.loaded = True + + +class FakeFastF1Loader: + def __init__(self, cache: Any, statuses: dict[str, int]) -> None: + self.Cache = cache + self._api = export.fastf1._api + self.session = LoadingSession(cache, self._api, statuses) + + def get_session(self, year: int, round_number: int, session_code: str) -> LoadingSession: + return self.session + + def sample(driver_number, t, lap_number, relative_distance, lap_duration=90.0): return { "driver_number": driver_number,