feat: party kiosk flows, loco setup, and tablet UI - #1
Conversation
Add drive/account/loco wizards with function templates and F2 pulse, SSO layout picker, fullscreen controls, and kiosk chrome (assistant, programming art). Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Review — compliance with microinit/CODING-GUIDELINES.md, performance, reliability
Thanks for this PR — the scope (kiosk, SSO, F2 pulse, i18n) is coherent and well fragmented. Input validation in programming_api.rs (checked address, MAX_FUNCTION, clamp(1, MAX_PULSE_MS)) is done correctly and is the reference point for the rest. Notes below ordered by priority. (Review submitted as COMMENT — GitHub does not allow REQUEST_CHANGES on your own PR.)
🔴 Blockers / High
-
Port mismatch in
BUILTIN_REDIRECT_URIS(src/config.rs:13-17) — built-ins use8081, the wizard listens on8091, defaultredirect_urisalso use8091.dev-config.jsonrepeats the mistake. SSO over LAN hostnames (bigfred.local/bigfred-wizard.local/wizard.local) is broken — BigFred redirects to a port where nothing listens. Violates §1.1 (invalid states impossible) and §16 (determinism). A test verifying built-in port consistency withhttpwould catch this. -
No rollback/cancel in
pulse_function(src/dccbus_client.rs:225-253) — ifOFFfails, the function stays on on the ops track.tokio::time::sleepignores cancellation. For F2 (horn/lights) that's annoying; for other functions potentially unsafe. Missing# Errors/# Panicsin docs. Recommendation: RAII guard sending OFF indrop/finally+tokio::select!with cancellation + timeout shorter thanACK_TIMEOUT.
🟠 Important / Should
-
expect()on the production path (§4.2) —config.rs:243(write_example_config, called frommain),ensure_client.rs:91and×2insync_redirect_uris. Guidelines: MUST NOTexpect/unwrapfor recoverable conditions. Replace withmap_errto typed errors (ConfigError::Serialize,EnsureError::Serialize). -
expect("status mutex")/expect("session")indccbus_client.rs(§4.2/§10) — poisoned mutex panics the daemon on a request. ReturnApiError::internal. Additionally: ifstatusisstd::sync::Mutexin async — considertokio::sync::Mutex(§10.2). -
ensureon every/oauth/token(oauth_proxy.rs:59) — auth hot path now does fs read/parse/write/chmod/chown/touch per exchange.touch_for_reloadbumps mtime even without changes → spurious fsnotify in BigFred. Callensureonly at startup + whenload_secretreturnsNotFound/Parse. -
Silent hardening errors (
ensure_client.rs:176-196) —let _ = set_permissions/chownmasks missing root → runtimeinvalid_clientwith a "success" log. At minimumtracing::warn!and post-operation metadata verification (§2.4, §1.2). -
No concurrency bound on
function_pulse(programming_api.rs:152) — concurrent pulses to the same locomotive can interleave ON/OFF. Per-(address,function)Semaphore/Mutexwithtry_lock→429. Additionallyas_loginis not validated against roster/layout — authz-relevant (impersonation); at minimum document the trust assumption.
🟡 Minor / performance
bigfred_gid()reads/parses/etc/groupper call (ensure_client.rs:213-224) — cache inOnceLock/AppState, log parse errors, don't returnNoneon a malformed line.request_asopens a new WS per call (dccbus_client.rs:195-210) — connection churn for repeated drive commands. Consider bounded per-login session cache (§11.4) or document that impersonated calls are one-shot by design.
Frontend (reliability principles from the guidelines)
DriveFlowPage.tsx:230-241— polling.catch(() => undefined)swallows errors (infinite spinner) and doesn't stop afterexpired. Add failure counter →setErrorand!expiredin the interval condition.DriveFlowPage.tsx:406— duplicatephase === "user"block; merge.ConfigureLocoPage.tsx:234— persist→program ordering leaves vehicle "incomplete" if participant interrupts between steps. Consider program-first or an "incomplete" marker.ConfigureLocoPage.tsx:297-307— 409 swallowing too broad (err.status === 409masks other conflicts); narrow tolayout_vehicle_already_on_rosterand log.
Tests (§13)
Good coverage of merge_builtin_redirect_uris, qr_url, write_example_config. Missing tests: OFF failure in pulse_function, function_pulse bounds (function=32, durationMs clamp), and — most importantly — built-in port consistency with http. Recommend release-assertions profile (§5.7/§17.1) in CI.
Summary: changes requested mainly for #1 (SSO correctness on tablet) and #2 (unsafe missing pulse rollback). The rest are should-fix items that improve kiosk reliability at events. After #1 and #2 + expect→typed error (#3), safe to merge.
| pub const BUILTIN_REDIRECT_URIS: &[&str] = &[ | ||
| "http://bigfred.local:8081/auth/callback", | ||
| "http://bigfred-wizard.local:8081/auth/callback", | ||
| "http://wizard.local:8081/auth/callback", |
There was a problem hiding this comment.
Correctness bug (HIGH): port mismatch.
Built-in URIs use port 8081, but the wizard listens on 8091 (http: "0.0.0.0:8091") and the default redirect_uris also use 8091. BigFred will redirect the tablet browser to http://bigfred.local:8081/auth/callback, where nothing is listening → SSO over LAN hostnames is broken.
Per §1.1 CODING-GUIDELINES (make invalid states impossible) and §16 (determinism): the allowlist must match the actual endpoint. dev-config.json repeats the same mistake (bigfred.local:8081), so neither the default nor the example is self-consistent.
Recommendation: either fix to 8091 (if there is no reverse proxy), or — if 8081 is the intentional public port behind a proxy — document that explicitly and derive the port from a shared constant (e.g. WIZARD_PUBLIC_PORT) used by both BUILTIN_REDIRECT_URIS and http. Add a test that every built-in URI points at the port the wizard actually binds to.
| *guard = None; | ||
| *guard = Some(self.connect_with_backoff(token).await?); | ||
| *guard = Some(self.connect_with_backoff_as(token, None).await?); | ||
| send_and_wait(guard.as_ref().expect("session"), frame, payload).await |
There was a problem hiding this comment.
§4.2 / §10 — expect("status mutex") on the request path. Poisoned mutexes panic the daemon on an organizer request. This is a recoverable condition — it should return ApiError::internal("dcc_bus_internal").
Applies to all .lock().expect("status mutex") in connect_with_backoff_as and .as_ref().expect("session") in request. If status is tokio::sync::Mutex, consider lock().await with map_err; if std::sync::Mutex in async — that's a separate issue (§10.2, blocking in async).
| let session = self.connect_with_backoff_as(token, Some(login)).await?; | ||
| let on_result = send_and_wait( | ||
| &session, | ||
| FRAME_SET_FUNCTION, | ||
| serde_json::json!({ | ||
| "address": address, | ||
| "function": function, | ||
| "on": true, | ||
| }), | ||
| ) | ||
| .await; | ||
| if let Err(err) = on_result { | ||
| drop(session); | ||
| return Err(err); | ||
| } | ||
| tokio::time::sleep(Duration::from_millis(duration_ms)).await; |
There was a problem hiding this comment.
Reliability / safety: no rollback and no cancellation.
- If
OFFfails (network, command station), the function stays on on the ops track. For F2 (often horn/lights) that's annoying; for other functions it can be unsafe. Missing# Errors/# Panicsdocumentation. tokio::time::sleepignores cancellation — if the client disconnects mid-pulse, the task keeps running and will still send OFF after the fullduration_ms.- No timeout on
send_and_waitin this path (only the globalACK_TIMEOUT=30s) — with a 5s pulse and a stuck station, the client waits up to 30s.
Recommendation: guard/RAII pattern — send OFF in drop/finally even on error; use tokio::select! with an on_drop cancellation token; document failure semantics. Consider whether the pulse should be idempotent under concurrent calls (see comment on function_pulse).
| source, | ||
| })?; | ||
| } | ||
| let mut body = serde_json::to_vec_pretty(&Config::default()).expect("serialize example config"); |
There was a problem hiding this comment.
§4.2 — expect() forbidden on the production path. write_example_config is called on every daemon start (main.rs). Config::default() is trivially Serialize, so in practice this won't fail, but the guidelines say MUST NOT use expect/unwrap for recoverable conditions — and here a silent panic at startup is especially bad.
Recommendation: serde_json::to_vec_pretty(&Config::default()).map_err(|source| ConfigError::Write { path: path.clone(), source })? or a dedicated ConfigError::Serialize variant. Same applies to ensure_client.rs (expect("serialize client") ×2 — see comment below).
| @@ -79,10 +90,49 @@ pub fn ensure(cfg: &Config) -> Result<PathBuf, EnsureError> { | |||
| &path, | |||
| &serde_json::to_vec_pretty(&file).expect("serialize client"), | |||
There was a problem hiding this comment.
§4.2 — expect("serialize client") in ensure. Same as in config.rs: OAuthClientFile is Serialize, so this isn't truly recoverable in practice, but the guidelines forbid expect in production. Additionally, this function is now called from oauth_proxy::token on every token exchange (see comment on oauth_proxy.rs), so a silent panic would kill auth.
Recommendation: .map_err(|source| EnsureError::Serialize { path: path.to_path_buf(), source })? with a new EnsureError::Serialize variant. Second occurrence in sync_redirect_uris — same fix.
| /// Turns a function on, waits `durationMs` (default 1s), then turns it off. | ||
| /// Ops-mode main track — not programming track. Requires `as` (participant | ||
| /// login) so the dcc-bus drive gate runs as the vehicle owner. | ||
| pub async fn function_pulse( |
There was a problem hiding this comment.
§8.5 / §1.3 — no concurrency bound for ops-track pulse. The endpoint allows concurrent pulses to the same locomotive: two parallel requests can interleave ON/OFF (e.g. ON1 → ON2 → OFF1 → OFF2, or ON1 → OFF1 → ON2 with OFF2 never sent if pulse_function fails mid-way). The organizer (token) can also spam requests.
Recommendation: per-address Mutex/Semaphore (or tokio::sync::Mutex with try_lock → 429), or a global Semaphore with a bound of e.g. 1 per (address, function). Additionally: as_login is accepted as any non-empty string — no verification that the participant exists and belongs to the layout. Authz-relevant (impersonation); at minimum document the trust assumption, or validate via BigFred before sending to dcc-bus.
| if (!pairing || paired || !me || !user || !station) { | ||
| return; | ||
| } | ||
| const timer = window.setInterval(() => { | ||
| api | ||
| .remoteStatus(me.layoutId, station.id, user.login) | ||
| .then((status) => { | ||
| if (status.paired) setPaired(true); | ||
| }) | ||
| .catch(() => undefined); | ||
| }, POLL_MS); | ||
| return () => window.clearInterval(timer); |
There was a problem hiding this comment.
Reliability: polling swallows errors and doesn't stop after expiry.
.catch(() => undefined)— if/remotes/statusfails (network, 5xx), the user sees an infinite spinner with no message. After N failed attempts, an error should be shown (e.g.t("drive.statusLost")).expired(line 272) is computed, but the interval still runs after expiry — wasted requests. The effect condition should include!expired.
Recommendation: add a failure counter (e.g. 3×) → setError; in the useEffect, add !expired to the interval start condition and clear it when expired becomes true.
| /> | ||
| )} | ||
|
|
||
| {phase === "user" && ( |
There was a problem hiding this comment.
Duplicated block. Two separate {phase === "user" && (...)} blocks render at the same time (UserPicker + Cancel). It works, but it's fragile — a refactor can easily add a third condition and end up with double rendering. Remove the duplicate: merge Cancel into the UserPicker block or extract buttons into a separate section driven by phase.
| goToAddress(null); | ||
| }; | ||
|
|
||
| const persistAndGoProgram = async () => { |
There was a problem hiding this comment.
Reliability: persist → program ordering creates inconsistent state. persistAndGoProgram saves the vehicle in BigFred (create/update + attach template) before programming the address on the track (programAddress in the next phase). If the participant leaves after persist but before program, the vehicle exists in the catalog with an address the decoder doesn't have stored. That's data inconsistency, and on a party (kiosk) an interrupted flow is a typical scenario.
Recommendation (product): either program the address first and persist the vehicle after success (or in one backend-side transaction), or mark the vehicle as "incomplete" until programAddress succeeds and block it on the roster list until then. At minimum document this behavior in a comment.
| const testF2 = async () => { | ||
| if (!me || !savedVehicle || !user) return; | ||
| setTestingF2(true); | ||
| setError(null); | ||
| try { | ||
| try { | ||
| await api.addVehicleToLayout(me.layoutId, savedVehicle.id, user.login); | ||
| } catch (err) { | ||
| if ( | ||
| !(err instanceof ApiError) || | ||
| (err.code !== "layout_vehicle_already_on_roster" && err.status !== 409) |
There was a problem hiding this comment.
Reliability: 409 swallowing too broad. layout_vehicle_already_on_roster || err.status === 409 swallows every 409, e.g. a conflict from another vehicle or a different semantic error. The pulse still runs, masking a real problem.
Recommendation: narrow to exact err.code === "layout_vehicle_already_on_roster" and only for that specific vehicleId (if the API returns context). Other 409s → setError. Log the ignored case via console.warn/telemetry so it doesn't disappear without a trace.
Align built-in redirect URIs with wizard port 8091, add typed errors instead of expect(), pulse OFF rollback with concurrency bounds, lazy oauth ensure, and frontend polling/error-handling fixes. Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
Test plan
/auth/melayout matchesMade with Cursor