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
747 changes: 385 additions & 362 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,4 @@ edition = "2024"
homepage = "https://www.omnect.io/home"
license = "MIT OR Apache-2.0"
repository = "git@github.com:omnect/omnect-ui.git"
version = "1.2.3"
version = "1.2.4"
6 changes: 3 additions & 3 deletions src/app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ crux_http = { version = "0.16", default-features = false }
crux_macros = { version = "0.8", default-features = false }
crux_time = { version = "0.15", default-features = false }
hex = { version = "0.4", default-features = false, features = ["alloc"] }
hmac = { version = "0.12", default-features = false }
hmac = { version = "0.13", default-features = false }
log = { version = "0.4", default-features = false }
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
pbkdf2 = { version = "0.13", default-features = false, features = ["hmac"] }
serde = { version = "1.0", default-features = false, features = ["derive"] }
serde_json = { version = "1.0", default-features = false }
serde_repr = { version = "0.1", default-features = false }
serde_valid = { version = "2.0", default-features = false }
sha1 = { version = "0.10", default-features = false }
sha1 = { version = "0.11", default-features = false }
wasm-bindgen = { version = "0.2", default-features = false }

[target.'cfg(target_arch = "wasm32")'.dependencies]
Expand Down
87 changes: 86 additions & 1 deletion src/app/src/update/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ pub fn update(event: Event, model: &mut Model) -> Command<Effect, Event> {

match event {
Event::Initialize => {
model.start_loading();
// Init fans out to deliberately silent background fetches (LoadSettings,
// CheckAvailability). It must NOT touch the global `is_loading` flag:
// none of their completion handlers clear it, so setting it here leaves
// it stuck true and spins loading-bound buttons (e.g. Settings "Save").
Command::all([
render(),
wifi::handle(crate::events::WifiEvent::CheckAvailability, model),
Expand All @@ -29,3 +32,85 @@ pub fn update(event: Event, model: &mut Model) -> Command<Effect, Event> {
Event::Wifi(wifi_event) => wifi::handle(wifi_event, model),
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::{
events::{UiEvent, WifiEvent},
types::{TimeoutSettings, WifiAvailability},
};

/// Initialization must not leave the global `is_loading` flag stuck on.
///
/// Regression: the Settings "Save" button (bound to `is_loading`) showed a
/// spinner permanently after load because the init fan-out set
/// `is_loading=true` but no completion handler ever cleared it.
/// Exercises the real repro path: authenticated + WiFi available.
#[test]
fn initialize_clears_loading_when_wifi_available_and_authenticated() {
let mut model = Model {
is_authenticated: true,
auth_token: Some("test-token".into()),
..Default::default()
};

let _ = update(Event::Initialize, &mut model);
// Init itself must never set the global loading flag — guards against
// reintroducing start_loading() that a later response happens to clear.
assert!(
!model.is_loading,
"Initialize must not set the global loading flag"
);

let _ = update(
Event::Ui(UiEvent::LoadSettingsResponse(
Ok(TimeoutSettings::default()),
)),
&mut model,
);
let _ = update(
Event::Wifi(WifiEvent::CheckAvailabilityResponse(Ok(
WifiAvailability::Available {
version: "1.0.0".into(),
interface_name: "wlan0".into(),
},
))),
&mut model,
);

assert!(
!model.is_loading,
"is_loading must be cleared after init settles"
);
}

/// Isolates the Initialize + CheckAvailability loading sources on the
/// WiFi-unavailable path (no GetStatus/GetSavedNetworks follow-up).
#[test]
fn initialize_clears_loading_when_wifi_unavailable() {
let mut model = Model::default();

let _ = update(Event::Initialize, &mut model);
assert!(
!model.is_loading,
"Initialize must not set the global loading flag"
);

let _ = update(
Event::Wifi(WifiEvent::CheckAvailabilityResponse(Ok(
WifiAvailability::Unavailable {
socket_present: false,
version: None,
min_required_version: "0.1.0".into(),
},
))),
&mut model,
);

assert!(
!model.is_loading,
"is_loading must be cleared after init settles"
);
}
}
34 changes: 17 additions & 17 deletions src/app/src/update/wifi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@ use std::collections::HashMap;
use crux_core::{Command, render::render};

use crate::{
Effect, TimeCmd, auth_get, auth_post,
Effect, TimeCmd, auth_get, auth_post, build_url,
events::{Event, WifiEvent},
http_get,
model::Model,
types::{
WifiAvailability, WifiConnectionState, WifiConnectionStatus, WifiNetwork,
WifiSavedNetworksResponse, WifiScanResultsResponse, WifiScanState, WifiState,
WifiStatusResponse,
},
unauth_post, wifi_psk,
wifi_psk,
};

/// Max scan poll attempts (500ms each → 30s total)
Expand Down Expand Up @@ -48,12 +49,14 @@ macro_rules! with_ready_state {
pub fn handle(event: WifiEvent, model: &mut Model) -> Command<Effect, Event> {
match event {
WifiEvent::CheckAvailability => {
unauth_post!(
Wifi, WifiEvent, model,
"/wifi/available",
CheckAvailabilityResponse, "Check WiFi availability",
method: get,
expect_json: WifiAvailability
// Silent background check fired on init — must not drive the global
// loading spinner (CheckAvailabilityResponse never clears it).
http_get!(
Wifi,
WifiEvent,
build_url("/wifi/available"),
CheckAvailabilityResponse,
WifiAvailability
)
}

Expand Down Expand Up @@ -532,19 +535,16 @@ mod tests {
let mut model = Model::default();
let mut cmd = handle(WifiEvent::CheckAvailability, &mut model);

// unauth_post! GET pattern produces render + http effects
let effects = [cmd.expect_effect(), cmd.expect_effect()];
let http_req = effects
.into_iter()
.find_map(|e| match e {
Effect::Http(req) => Some(req),
_ => None,
})
.expect("Expected Http effect");
// Silent http_get! produces a single Http effect (no loading, no render).
let Effect::Http(http_req) = cmd.expect_one_effect() else {
panic!("Expected Http effect");
};
let (http_request, _) = http_req.split();

assert_eq!(http_request.url, "https://relative/wifi/available");
assert_eq!(http_request.method, "GET");
// The silent check must not set the global loading flag.
assert!(!model.is_loading);
}

#[test]
Expand Down
2 changes: 1 addition & 1 deletion src/ui/biome.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"root": false,
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.5.0/schema.json",
"linter": {
"enabled": true,
"rules": {
Expand Down
Loading
Loading