Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ Downloaded sessions, caches, logs, and a user-editable `.env` file live here:
| macOS | `~/Library/Application Support/interval/data` |
| Linux | `~/.local/share/interval/data` |

While the app runs, `server.json` in this directory records the embedded backend's loopback address (`url`), process id and start time, so local companion tools can find it. It is removed on exit; a stale copy after a crash fails `GET <url>/healthz`. The backend only ever listens on `127.0.0.1`.

Set `INTERVAL_DESKTOP_DATA_DIR` in your environment to use another directory. For other settings, edit `.env` there and restart the app. Environment variables take precedence over `.env`.

| Variable | Purpose |
Expand Down
45 changes: 40 additions & 5 deletions backend/src/api/live_simulation.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
use super::{ApiError, AppState};
use crate::{domain::ReplaySnapshot, replay, storage};
use crate::{
domain::ReplaySnapshot,
live_simulation::{self, SimulationError, StartOptions},
replay, storage,
};
use axum::{
extract::{Path, Query, State},
response::sse::{Event, KeepAlive, Sse},
Expand All @@ -14,22 +18,53 @@ pub struct LiveSimulationStreamQuery {
speed: Option<f64>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SpeedBody {
speed: f64,
}

impl From<SimulationError> for ApiError {
fn from(error: SimulationError) -> Self {
match error {
SimulationError::InvalidRequest(message) => ApiError::BadRequest(message),
SimulationError::NotCached => ApiError::NotFound,
SimulationError::Storage(error) => ApiError::Storage(error),
}
}
}

pub async fn start(
State(state): State<AppState>,
Path(session_key): Path<i64>,
body: Option<Json<StartOptions>>,
) -> Result<Json<crate::domain::LiveSessionStatus>, ApiError> {
if replay::metadata(&state.pool, session_key).await?.is_none() {
return Err(ApiError::NotFound);
}
let options = body.map_or_else(StartOptions::default, |Json(options)| options);
Ok(Json(
state
.live_simulation
.start(&state.pool, session_key)
.await
.map_err(ApiError::Storage)?,
.start(&state.pool, session_key, options)
.await?,
))
}

pub async fn speed(
State(state): State<AppState>,
Path(session_key): Path<i64>,
Json(body): Json<SpeedBody>,
) -> Result<Json<crate::domain::LiveSessionStatus>, ApiError> {
let speed = live_simulation::normalize_speed(body.speed)?;
state
.live_simulation
.set_speed(session_key, speed)
.await
.map(Json)
.ok_or(ApiError::NotFound)
}

pub async fn status(
State(state): State<AppState>,
Path(session_key): Path<i64>,
Expand Down Expand Up @@ -78,7 +113,7 @@ pub async fn stream(
.speed
.filter(|value| value.is_finite() && *value > 0.0)
.unwrap_or(1.0)
.clamp(0.25, 1_000.0);
.clamp(live_simulation::MIN_SPEED, live_simulation::MAX_SPEED);
state.live_simulation.set_speed(session_key, speed).await;
let frame_delay = Duration::from_secs_f64((frame_step / speed).clamp(0.01, 2.0));
let replay_events = storage::get_replay_events(&state.pool, session_key).await?;
Expand Down
4 changes: 4 additions & 0 deletions backend/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ pub fn router(state: AppState) -> Router {
"/api/sessions/{session_key}/live-simulation/start",
post(live_simulation::start),
)
.route(
"/api/sessions/{session_key}/live-simulation/speed",
post(live_simulation::speed),
)
.route(
"/api/sessions/{session_key}/live-simulation/status",
get(live_simulation::status),
Expand Down
91 changes: 91 additions & 0 deletions backend/src/api/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1944,6 +1944,75 @@ async fn live_simulation_start_status_snapshot_and_stop_use_cached_replay() {
assert_eq!(stopped["active"], false);
}

#[tokio::test]
async fn live_simulation_start_accepts_lap_and_speed_and_speed_route_rebases() {
let app = seeded_router().await;

let start = post_json_body(
app.clone(),
"/api/sessions/9839/live-simulation/start",
json!({ "start_lap": 3, "speed": 4.0 }),
StatusCode::OK,
)
.await;
assert_eq!(start["active"], true);
assert!(start["current_t"].as_f64().unwrap() >= 120.0);

let snapshot = get_json(
app.clone(),
"/api/sessions/9839/live-simulation/snapshot",
StatusCode::OK,
)
.await;
assert_eq!(snapshot["race_state"]["lap"], 3);

let status = post_json_body(
app.clone(),
"/api/sessions/9839/live-simulation/speed",
json!({ "speed": 1.0 }),
StatusCode::OK,
)
.await;
assert_eq!(status["active"], true);
assert!(status["current_t"].as_f64().unwrap() >= 120.0);

post_json_body(
app.clone(),
"/api/sessions/9839/live-simulation/speed",
json!({ "speed": -1.0 }),
StatusCode::BAD_REQUEST,
)
.await;
post_json_body(
app.clone(),
"/api/sessions/9839/live-simulation/start",
json!({ "start_t": 0.0, "start_lap": 1 }),
StatusCode::BAD_REQUEST,
)
.await;
post_json_body(
app.clone(),
"/api/sessions/9839/live-simulation/start",
json!({ "start_lap": 99 }),
StatusCode::BAD_REQUEST,
)
.await;

post_json(
app.clone(),
"/api/sessions/9839/live-simulation/stop",
StatusCode::OK,
)
.await;
post_json_body(
app,
"/api/sessions/9839/live-simulation/speed",
json!({ "speed": 2.0 }),
StatusCode::NOT_FOUND,
)
.await;
}

#[tokio::test]
async fn live_simulation_requires_cached_replay() {
let app = seeded_router().await;
Expand Down Expand Up @@ -2411,6 +2480,28 @@ 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 {
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), expected_status);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
serde_json::from_slice(&body).unwrap()
}

async fn post_json(app: Router, uri: &str, expected_status: StatusCode) -> Value {
let response = app
.oneshot(
Expand Down
Loading
Loading