From d069c54481e5acf4acc5ad5274d718b4eae05fc2 Mon Sep 17 00:00:00 2001 From: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:52:31 +0100 Subject: [PATCH 1/3] feat(wifi): implement WiFi management via wifi-commissioning-service - Add scanning, connecting, disconnecting, and saved-network management through the wifi-commissioning-service (unix domain socket) - E2E coverage: auth flow, WiFi operations, form interaction edge cases Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> --- Cargo.lock | 429 ++++++--- Cargo.toml | 2 +- scripts/build-and-deploy-image.sh | 8 +- src/app/Cargo.toml | 4 + src/app/src/events.rs | 118 +++ src/app/src/lib.rs | 1 + src/app/src/macros.rs | 28 + src/app/src/model.rs | 3 + src/app/src/types/mod.rs | 2 + src/app/src/types/wifi.rs | 73 ++ src/app/src/update/auth.rs | 61 +- src/app/src/update/mod.rs | 7 +- src/app/src/update/websocket.rs | 42 +- src/app/src/update/wifi.rs | 909 ++++++++++++++++++ src/app/src/wifi_psk.rs | 48 + src/backend/src/api.rs | 96 ++ src/backend/src/config.rs | 20 + src/backend/src/lib.rs | 1 + src/backend/src/main.rs | 77 ++ src/backend/src/wifi_commissioning_client.rs | 373 +++++++ src/shared_types/build.rs | 14 +- .../src/components/network/DeviceNetworks.vue | 9 +- .../components/network/WifiConnectDialog.vue | 68 ++ .../components/network/WifiForgetDialog.vue | 41 + src/ui/src/components/network/WifiPanel.vue | 146 +++ src/ui/src/composables/core/index.ts | 26 + src/ui/src/composables/core/state.ts | 2 + src/ui/src/composables/core/sync.ts | 4 + src/ui/src/composables/core/timers.ts | 55 ++ src/ui/src/composables/core/types.ts | 116 +++ src/ui/src/composables/useCore.ts | 6 + src/ui/src/pages/SetPassword.vue | 10 + src/ui/tests/auth.spec.ts | 50 + src/ui/tests/wifi.spec.ts | 578 +++++++++++ 34 files changed, 3284 insertions(+), 143 deletions(-) create mode 100644 src/app/src/types/wifi.rs create mode 100644 src/app/src/update/wifi.rs create mode 100644 src/app/src/wifi_psk.rs create mode 100644 src/backend/src/wifi_commissioning_client.rs create mode 100644 src/ui/src/components/network/WifiConnectDialog.vue create mode 100644 src/ui/src/components/network/WifiForgetDialog.vue create mode 100644 src/ui/src/components/network/WifiPanel.vue create mode 100644 src/ui/tests/wifi.spec.ts diff --git a/Cargo.lock b/Cargo.lock index 1a7dc064..beb26af8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,9 +59,9 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.11.2" +version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7926860314cbe2fb5d1f13731e387ab43bd32bca224e82e6e2db85de0a3dba49" +checksum = "f860ee6746d0c5b682147b2f7f8ef036d4f92fe518251a3a35ffa3650eafdf0e" dependencies = [ "actix-codec", "actix-rt", @@ -97,7 +97,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -135,14 +135,14 @@ dependencies = [ "parse-size", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "actix-router" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" +checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" dependencies = [ "bytestring", "cfg-if", @@ -237,9 +237,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.12.1" +version = "4.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1654a77ba142e37f049637a3e5685f864514af11fcbc51cb51eb6596afe5b8d6" +checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" dependencies = [ "actix-codec", "actix-http", @@ -287,7 +287,7 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -385,9 +385,9 @@ checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "argon2" @@ -420,7 +420,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -437,9 +437,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.15.4" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" dependencies = [ "aws-lc-sys", "zeroize", @@ -447,9 +447,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.37.0" +version = "0.37.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" dependencies = [ "cc", "cmake", @@ -501,9 +501,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "blake2" @@ -525,9 +525,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "byteorder" @@ -552,9 +552,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.55" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", "jobserver", @@ -803,7 +803,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -857,7 +857,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -870,7 +870,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -881,7 +881,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -892,14 +892,14 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", ] @@ -922,7 +922,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -932,7 +932,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -945,7 +945,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -967,7 +967,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.114", + "syn 2.0.117", "unicode-xid", ] @@ -990,7 +990,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1031,18 +1031,18 @@ dependencies = [ [[package]] name = "env_filter" -version = "0.1.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" dependencies = [ "log", ] [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" dependencies = [ "env_filter", "log", @@ -1169,9 +1169,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -1184,9 +1184,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -1194,15 +1194,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -1211,9 +1211,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -1230,32 +1230,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -1265,7 +1265,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -1306,6 +1305,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "ghash" version = "0.5.1" @@ -1353,6 +1365,15 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1374,6 +1395,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hkdf" version = "0.12.4" @@ -1616,6 +1643,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1774,9 +1807,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "c7e709f3e3d22866f9c25b3aff01af289b18422cc8b4262fb19103ee80fe513d" dependencies = [ "once_cell", "wasm-bindgen", @@ -1809,11 +1842,17 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.180" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "linux-raw-sys" @@ -1930,7 +1969,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1942,7 +1981,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1996,7 +2035,7 @@ dependencies = [ [[package]] name = "omnect-ui" -version = "1.1.2" +version = "1.2.0" dependencies = [ "actix-cors", "actix-files", @@ -2038,7 +2077,7 @@ dependencies = [ [[package]] name = "omnect-ui-core" -version = "1.1.2" +version = "1.2.0" dependencies = [ "base64 0.22.1", "console_log", @@ -2046,12 +2085,16 @@ dependencies = [ "crux_http", "crux_macros", "getrandom 0.3.4", + "hex", + "hmac", "lazy_static", "log", + "pbkdf2", "serde", "serde_json", "serde_repr", "serde_valid", + "sha1", "wasm-bindgen", ] @@ -2156,6 +2199,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e91099d4268b0e11973f036e885d652fb0b21fedcf69738c627f94db6a44f42" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "pem" version = "3.0.6" @@ -2266,9 +2319,9 @@ dependencies = [ [[package]] name = "predicates" -version = "3.1.3" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" dependencies = [ "anstyle", "predicates-core", @@ -2276,20 +2329,30 @@ dependencies = [ [[package]] name = "predicates-core" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" [[package]] name = "predicates-tree" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" dependencies = [ "predicates-core", "termtree", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -2742,9 +2805,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "security-framework" -version = "3.5.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", "core-foundation", @@ -2755,9 +2818,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -2831,7 +2894,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2875,7 +2938,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2922,7 +2985,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2935,6 +2998,17 @@ dependencies = [ "regex", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2948,7 +3022,7 @@ dependencies = [ [[package]] name = "shared_types" -version = "1.1.2" +version = "1.2.0" dependencies = [ "anyhow", "crux_core", @@ -3087,9 +3161,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -3113,17 +3187,17 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "tempfile" -version = "3.24.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3171,7 +3245,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3182,7 +3256,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3275,7 +3349,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3366,7 +3440,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3386,7 +3460,7 @@ checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3409,9 +3483,9 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.23" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" @@ -3479,11 +3553,11 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.20.0" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.1", "js-sys", "wasm-bindgen", ] @@ -3534,11 +3608,20 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "ec1adf1535672f5b7824f817792b1afd731d7e843d2d04ec8f27e8cb51edd8ac" dependencies = [ "cfg-if", "once_cell", @@ -3549,9 +3632,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "fe88540d1c934c4ec8e6db0afa536876c5441289d7f9f9123d4f065ac1250a6b" dependencies = [ "cfg-if", "futures-util", @@ -3563,9 +3646,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "19e638317c08b21663aed4d2b9a2091450548954695ff4efa75bff5fa546b3b1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3573,31 +3656,65 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "2c64760850114d03d5f65457e96fc988f11f01d38fbaa51b254e4ab5809102af" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "60eecd4fe26177cfa3339eb00b4a36445889ba3ad37080c2429879718e20ca41" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "9d6bb20ed2d9572df8584f6dc81d68a41a625cadc6f15999d649a70ce7e3597a" dependencies = [ "js-sys", "wasm-bindgen", @@ -3864,6 +3981,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -3890,7 +4089,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -3911,7 +4110,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3931,7 +4130,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -3971,11 +4170,11 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "zmij" -version = "1.0.20" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index c96bef9c..664162e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,4 +18,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.1.2" +version = "1.2.0" diff --git a/scripts/build-and-deploy-image.sh b/scripts/build-and-deploy-image.sh index f3d05054..83cb660f 100755 --- a/scripts/build-and-deploy-image.sh +++ b/scripts/build-and-deploy-image.sh @@ -176,9 +176,9 @@ if [[ "$DEPLOY" == "true" ]]; then echo "Copying image to device $DEVICE_HOST..." if [ -n "$DEVICE_PASS" ]; then - sshpass -p "$DEVICE_PASS" scp "$IMAGE_TAR" "${DEVICE_USER}@${DEVICE_HOST}:/tmp/" + sshpass -p "$DEVICE_PASS" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR "$IMAGE_TAR" "${DEVICE_USER}@${DEVICE_HOST}:/tmp/" else - scp "$IMAGE_TAR" "${DEVICE_USER}@${DEVICE_HOST}:/tmp/" + scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR "$IMAGE_TAR" "${DEVICE_USER}@${DEVICE_HOST}:/tmp/" fi echo "Loading image on device and restarting container..." @@ -203,9 +203,9 @@ if [[ "$DEPLOY" == "true" ]]; then sudo iotedge system restart" if [ -n "$DEVICE_PASS" ]; then - sshpass -p "$DEVICE_PASS" ssh "${DEVICE_USER}@${DEVICE_HOST}" "$CMD" + sshpass -p "$DEVICE_PASS" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR "${DEVICE_USER}@${DEVICE_HOST}" "$CMD" else - ssh "${DEVICE_USER}@${DEVICE_HOST}" "$CMD" + ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR "${DEVICE_USER}@${DEVICE_HOST}" "$CMD" fi echo "Cleaning up local tar file..." diff --git a/src/app/Cargo.toml b/src/app/Cargo.toml index 06dfc48d..ed5b820e 100644 --- a/src/app/Cargo.toml +++ b/src/app/Cargo.toml @@ -32,6 +32,10 @@ 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 } +hex = { version = "0.4", default-features = false, features = ["alloc"] } +hmac = { version = "0.12", default-features = false } +pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } +sha1 = { version = "0.10", default-features = false } wasm-bindgen = { version = "0.2", default-features = false } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/src/app/src/events.rs b/src/app/src/events.rs index b803f41e..bb58072a 100644 --- a/src/app/src/events.rs +++ b/src/app/src/events.rs @@ -102,6 +102,122 @@ pub enum WebSocketEvent { Disconnected, } +/// WiFi management events +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +pub enum WifiEvent { + // User actions + CheckAvailability, + Scan, + Connect { + ssid: String, + password: String, + }, + Disconnect, + GetStatus, + GetSavedNetworks, + ForgetNetwork { + ssid: String, + }, + // Timer ticks (driven by Shell) + ScanPollTick, + ConnectPollTick, + // Responses from HTTP effects + #[serde(skip)] + CheckAvailabilityResponse(Result), + #[serde(skip)] + ScanResponse(Result<(), String>), + #[serde(skip)] + ScanResultsResponse(Result), + #[serde(skip)] + ConnectResponse(Result<(), String>), + #[serde(skip)] + DisconnectResponse(Result<(), String>), + #[serde(skip)] + StatusResponse(Result), + #[serde(skip)] + SavedNetworksResponse(Result), + #[serde(skip)] + ForgetNetworkResponse(Result<(), String>), +} + +/// API response types for WiFi (match backend JSON shapes) +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WifiScanResultsApiResponse { + pub status: String, + pub state: String, + pub networks: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct WifiNetworkApiResponse { + pub ssid: String, + pub mac: String, + pub ch: u16, + pub rssi: i16, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct WifiStatusApiResponse { + pub status: String, + pub state: String, + pub ssid: Option, + pub ip_address: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct WifiSavedNetworksApiResponse { + pub status: String, + pub networks: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct WifiSavedNetworkApiResponse { + pub ssid: String, + pub flags: String, +} + +/// Custom Debug for WifiEvent to redact password +impl fmt::Debug for WifiEvent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WifiEvent::Connect { ssid, .. } => f + .debug_struct("Connect") + .field("ssid", ssid) + .field("password", &"") + .finish(), + WifiEvent::CheckAvailability => write!(f, "CheckAvailability"), + WifiEvent::Scan => write!(f, "Scan"), + WifiEvent::Disconnect => write!(f, "Disconnect"), + WifiEvent::GetStatus => write!(f, "GetStatus"), + WifiEvent::GetSavedNetworks => write!(f, "GetSavedNetworks"), + WifiEvent::ForgetNetwork { ssid } => { + f.debug_struct("ForgetNetwork").field("ssid", ssid).finish() + } + WifiEvent::ScanPollTick => write!(f, "ScanPollTick"), + WifiEvent::ConnectPollTick => write!(f, "ConnectPollTick"), + WifiEvent::CheckAvailabilityResponse(r) => { + f.debug_tuple("CheckAvailabilityResponse").field(r).finish() + } + WifiEvent::ScanResponse(r) => f.debug_tuple("ScanResponse").field(r).finish(), + WifiEvent::ScanResultsResponse(r) => { + f.debug_tuple("ScanResultsResponse").field(r).finish() + } + WifiEvent::ConnectResponse(r) => f.debug_tuple("ConnectResponse").field(r).finish(), + WifiEvent::DisconnectResponse(r) => { + f.debug_tuple("DisconnectResponse").field(r).finish() + } + WifiEvent::StatusResponse(r) => f.debug_tuple("StatusResponse").field(r).finish(), + WifiEvent::SavedNetworksResponse(r) => { + f.debug_tuple("SavedNetworksResponse").field(r).finish() + } + WifiEvent::ForgetNetworkResponse(r) => { + f.debug_tuple("ForgetNetworkResponse").field(r).finish() + } + } + } +} + /// UI action events #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub enum UiEvent { @@ -118,6 +234,7 @@ pub enum Event { Device(DeviceEvent), WebSocket(WebSocketEvent), Ui(UiEvent), + Wifi(WifiEvent), } /// Custom Debug implementation for AuthEvent to redact sensitive data @@ -180,6 +297,7 @@ impl fmt::Debug for Event { Event::Device(e) => write!(f, "Device({e:?})"), Event::WebSocket(e) => write!(f, "WebSocket({e:?})"), Event::Ui(e) => write!(f, "Ui({e:?})"), + Event::Wifi(e) => write!(f, "Wifi({e:?})"), } } } diff --git a/src/app/src/lib.rs b/src/app/src/lib.rs index 8ac8fb1a..bb124e14 100644 --- a/src/app/src/lib.rs +++ b/src/app/src/lib.rs @@ -5,6 +5,7 @@ pub mod macros; pub mod model; pub mod types; pub mod update; +pub mod wifi_psk; #[cfg(target_arch = "wasm32")] pub mod wasm; diff --git a/src/app/src/macros.rs b/src/app/src/macros.rs index a1b32010..bdb2eea0 100644 --- a/src/app/src/macros.rs +++ b/src/app/src/macros.rs @@ -420,6 +420,34 @@ macro_rules! http_get { }; } +/// Macro for authenticated GET requests expecting JSON response. +/// Does not set loading state — used for background polling and status checks. +/// +/// # Example +/// ```ignore +/// auth_get!(Wifi, WifiEvent, model, "/wifi/status", StatusResponse, "WiFi status", +/// expect_json: WifiStatusApiResponse) +/// ``` +#[macro_export] +macro_rules! auth_get { + ($domain:ident, $domain_event:ident, $model:expr, $endpoint:expr, $response_event:ident, $action:expr, expect_json: $response_type:ty) => {{ + if let Some(token) = &$model.auth_token { + $crate::HttpCmd::get($crate::build_url($endpoint)) + .header("Authorization", format!("Bearer {token}")) + .build() + .then_send(|result| { + let event_result: Result<$response_type, String> = + $crate::process_json_response($action, result); + $crate::events::Event::$domain($crate::events::$domain_event::$response_event( + event_result, + )) + }) + } else { + $crate::handle_auth_error($model, $action) + } + }}; +} + /// Silent HTTP GET - no loading state, custom success/error event handlers. /// /// Used for background polling where failures should not show errors to user. diff --git a/src/app/src/model.rs b/src/app/src/model.rs index 795db071..55d613d4 100644 --- a/src/app/src/model.rs +++ b/src/app/src/model.rs @@ -67,6 +67,9 @@ pub struct Model { // Overlay spinner state pub overlay_spinner: OverlaySpinnerState, + + // WiFi state + pub wifi_state: WifiState, } impl Model { diff --git a/src/app/src/types/mod.rs b/src/app/src/types/mod.rs index 00a2a0ce..269b99e0 100644 --- a/src/app/src/types/mod.rs +++ b/src/app/src/types/mod.rs @@ -18,6 +18,7 @@ pub mod factory_reset; pub mod network; pub mod ods; pub mod update; +pub mod wifi; // Re-export all types for backward compatibility pub use auth::*; @@ -27,3 +28,4 @@ pub use factory_reset::*; pub use network::*; pub use ods::*; pub use update::*; +pub use wifi::*; diff --git a/src/app/src/types/wifi.rs b/src/app/src/types/wifi.rs new file mode 100644 index 00000000..be73b7d0 --- /dev/null +++ b/src/app/src/types/wifi.rs @@ -0,0 +1,73 @@ +use serde::{Deserialize, Serialize}; + +/// WiFi service availability info returned by the backend +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WifiAvailability { + pub available: bool, + pub interface_name: Option, +} + +/// A WiFi network discovered during scanning +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WifiNetwork { + pub ssid: String, + pub mac: String, + pub channel: u16, + pub rssi: i16, +} + +/// A saved WiFi network from wpa_supplicant +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WifiSavedNetwork { + pub ssid: String, + pub flags: String, +} + +/// WiFi connection status from the service +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WifiConnectionStatus { + pub state: WifiConnectionState, + pub ssid: Option, + pub ip_address: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum WifiScanState { + #[default] + Idle, + Scanning, + Finished, + Error(String), +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum WifiConnectionState { + #[default] + Idle, + Connecting, + Connected, + Failed(String), +} + +/// Top-level WiFi state machine exposed in the ViewModel +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum WifiState { + #[default] + Unavailable, + Ready { + interface_name: String, + status: WifiConnectionStatus, + scan_state: WifiScanState, + scan_results: Vec, + saved_networks: Vec, + scan_poll_attempt: u32, + connect_poll_attempt: u32, + }, +} diff --git a/src/app/src/update/auth.rs b/src/app/src/update/auth.rs index b83cb1c1..4276237e 100644 --- a/src/app/src/update/auth.rs +++ b/src/app/src/update/auth.rs @@ -1,12 +1,12 @@ use base64::prelude::*; -use crux_core::Command; +use crux_core::{render::render, Command}; use crate::{ auth_post, auth_post_basic, - events::{AuthEvent, Event}, + events::{AuthEvent, Event, WifiEvent}, handle_response, model::Model, - types::{AuthToken, SetPasswordRequest, UpdatePasswordRequest}, + types::{AuthToken, SetPasswordRequest, UpdatePasswordRequest, WifiState}, unauth_post, Effect, }; @@ -20,12 +20,20 @@ pub fn handle(event: AuthEvent, model: &mut Model) -> Command { map: |token| AuthToken { token }) } - AuthEvent::LoginResponse(result) => handle_response!(model, result, { - on_success: |model, auth| { - model.auth_token = Some(auth.token); - model.is_authenticated = true; - }, - }), + AuthEvent::LoginResponse(result) => { + model.stop_loading(); + match result { + Ok(auth) => { + model.auth_token = Some(auth.token); + model.is_authenticated = true; + post_auth_commands(model) + } + Err(e) => { + model.set_error(e); + render() + } + } + } AuthEvent::Logout => { auth_post!(Auth, AuthEvent, model, "/logout", LogoutResponse, "Logout") @@ -44,13 +52,21 @@ pub fn handle(event: AuthEvent, model: &mut Model) -> Command { map: |token| AuthToken { token }) } - AuthEvent::SetPasswordResponse(result) => handle_response!(model, result, { - on_success: |model, auth| { - model.requires_password_set = false; - model.auth_token = Some(auth.token); - model.is_authenticated = true; - }, - }), + AuthEvent::SetPasswordResponse(result) => { + model.stop_loading(); + match result { + Ok(auth) => { + model.requires_password_set = false; + model.auth_token = Some(auth.token); + model.is_authenticated = true; + post_auth_commands(model) + } + Err(e) => { + model.set_error(e); + render() + } + } + } AuthEvent::UpdatePassword { current_password, @@ -85,6 +101,19 @@ pub fn handle(event: AuthEvent, model: &mut Model) -> Command { } } +/// After successful authentication, fetch WiFi data if WiFi is available +fn post_auth_commands(model: &mut Model) -> Command { + if matches!(model.wifi_state, WifiState::Ready { .. }) { + Command::all([ + render(), + super::wifi::handle(WifiEvent::GetStatus, model), + super::wifi::handle(WifiEvent::GetSavedNetworks, model), + ]) + } else { + render() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/app/src/update/mod.rs b/src/app/src/update/mod.rs index 46df55f9..acca95f1 100644 --- a/src/app/src/update/mod.rs +++ b/src/app/src/update/mod.rs @@ -2,6 +2,7 @@ mod auth; mod device; mod ui; mod websocket; +mod wifi; use crux_core::{render::render, Command}; @@ -17,11 +18,15 @@ pub fn update(event: Event, model: &mut Model) -> Command { match event { Event::Initialize => { model.start_loading(); - render() + Command::all([ + render(), + wifi::handle(crate::events::WifiEvent::CheckAvailability, model), + ]) } Event::Auth(auth_event) => auth::handle(auth_event, model), Event::Device(device_event) => device::handle(device_event, model), Event::WebSocket(ws_event) => websocket::handle(ws_event, model), Event::Ui(ui_event) => ui::handle(ui_event, model), + Event::Wifi(wifi_event) => wifi::handle(wifi_event, model), } } diff --git a/src/app/src/update/websocket.rs b/src/app/src/update/websocket.rs index e4331c8b..2756c405 100644 --- a/src/app/src/update/websocket.rs +++ b/src/app/src/update/websocket.rs @@ -1,12 +1,17 @@ +use std::collections::HashMap; + use crux_core::Command; use crate::{ events::{Event, WebSocketEvent}, model::Model, parse_ods_update, - types::ods::{ - OdsFactoryReset, OdsNetworkStatus, OdsOnlineStatus, OdsSystemInfo, OdsTimeouts, - OdsUpdateValidationStatus, + types::{ + ods::{ + OdsFactoryReset, OdsNetworkStatus, OdsOnlineStatus, OdsSystemInfo, OdsTimeouts, + OdsUpdateValidationStatus, + }, + NetworkFormData, NetworkFormState, }, update_field, CentrifugoCmd, Effect, }; @@ -40,6 +45,37 @@ pub fn handle(event: WebSocketEvent, model: &mut Model) -> Command { + if let WifiState::Ready { + interface_name: $iface, + status: $status, + scan_state: $scan_state, + scan_results: $scan_results, + saved_networks: $saved, + scan_poll_attempt: $scan_poll, + connect_poll_attempt: $connect_poll, + } = &mut $model.wifi_state + { + $body + } else { + log::warn!("WiFi event received but state is Unavailable"); + Command::done() + } + }; +} + +pub fn handle(event: WifiEvent, model: &mut Model) -> Command { + match event { + WifiEvent::CheckAvailability => { + unauth_post!( + Wifi, WifiEvent, model, + "/wifi/available", + CheckAvailabilityResponse, "Check WiFi availability", + method: get, + expect_json: WifiAvailability + ) + } + + WifiEvent::CheckAvailabilityResponse(result) => match result { + Ok(availability) if availability.available => { + let iface = availability.interface_name.unwrap_or_default(); + model.wifi_state = WifiState::Ready { + interface_name: iface, + status: WifiConnectionStatus::default(), + scan_state: WifiScanState::Idle, + scan_results: Vec::new(), + saved_networks: Vec::new(), + scan_poll_attempt: 0, + connect_poll_attempt: 0, + }; + // Only fetch status and saved networks if authenticated + if model.is_authenticated { + Command::all([ + render(), + handle(WifiEvent::GetStatus, model), + handle(WifiEvent::GetSavedNetworks, model), + ]) + } else { + render() + } + } + Ok(_) => { + // WiFi not available + model.wifi_state = WifiState::Unavailable; + render() + } + Err(e) => { + log::error!("WiFi availability check failed: {e}"); + model.wifi_state = WifiState::Unavailable; + render() + } + }, + + WifiEvent::Scan => { + with_ready_state!(model, |_iface, + _status, + scan_state, + _scan_results, + _saved, + scan_poll, + _connect_poll| { + *scan_state = WifiScanState::Scanning; + *scan_poll = 0; + auth_post!( + Wifi, + WifiEvent, + model, + "/wifi/scan", + ScanResponse, + "WiFi scan" + ) + }) + } + + WifiEvent::ScanResponse(result) => { + if let Err(e) = result { + with_ready_state!( + model, + |_iface, + _status, + scan_state, + _scan_results, + _saved, + _scan_poll, + _connect_poll| { + *scan_state = WifiScanState::Error(e); + render() + } + ) + } else { + // Scan started; Shell will drive polling via ScanPollTick + render() + } + } + + WifiEvent::ScanPollTick => { + with_ready_state!(model, |_iface, + _status, + scan_state, + _scan_results, + _saved, + scan_poll, + _connect_poll| { + if !matches!(scan_state, WifiScanState::Scanning) { + return Command::done(); + } + if *scan_poll >= SCAN_POLL_MAX_ATTEMPTS { + *scan_state = WifiScanState::Error("Scan timed out".to_string()); + return render(); + } + *scan_poll += 1; + auth_get!( + Wifi, WifiEvent, model, + "/wifi/scan/results", + ScanResultsResponse, "WiFi scan results", + expect_json: WifiScanResultsApiResponse + ) + }) + } + + WifiEvent::ScanResultsResponse(result) => match result { + Ok(response) => { + with_ready_state!( + model, + |_iface, + _status, + scan_state, + scan_results, + _saved, + _scan_poll, + _connect_poll| { + if response.state == "finished" { + // Deduplicate by SSID, keeping strongest signal per SSID + let mut best: HashMap = HashMap::new(); + for net in response.networks { + let entry = + best.entry(net.ssid.clone()).or_insert_with(|| WifiNetwork { + ssid: net.ssid.clone(), + mac: net.mac.clone(), + channel: net.ch, + rssi: net.rssi, + }); + if net.rssi > entry.rssi { + *entry = WifiNetwork { + ssid: net.ssid, + mac: net.mac, + channel: net.ch, + rssi: net.rssi, + }; + } + } + // Sort by RSSI descending (strongest first) + let mut networks: Vec = best.into_values().collect(); + networks.sort_by(|a, b| b.rssi.cmp(&a.rssi)); + + *scan_results = networks; + *scan_state = WifiScanState::Finished; + } + // If state is still "scanning", keep polling (Shell timer continues) + render() + } + ) + } + Err(e) => { + with_ready_state!( + model, + |_iface, + _status, + scan_state, + _scan_results, + _saved, + _scan_poll, + _connect_poll| { + *scan_state = WifiScanState::Error(e); + render() + } + ) + } + }, + + WifiEvent::Connect { ssid, password } => { + // Input validation + if ssid.trim().is_empty() { + return with_ready_state!( + model, + |_iface, + _status, + _scan_state, + _scan_results, + _saved, + _scan_poll, + _connect_poll| { + _status.state = + WifiConnectionState::Failed("SSID cannot be empty".to_string()); + render() + } + ); + } + if password.is_empty() { + return with_ready_state!( + model, + |_iface, + _status, + _scan_state, + _scan_results, + _saved, + _scan_poll, + _connect_poll| { + _status.state = + WifiConnectionState::Failed("Password cannot be empty".to_string()); + render() + } + ); + } + + // Compute WPA PSK + let psk = wifi_psk::compute_wpa_psk(&password, &ssid); + + with_ready_state!(model, |_iface, + status, + _scan_state, + _scan_results, + _saved, + _scan_poll, + connect_poll| { + status.state = WifiConnectionState::Connecting; + *connect_poll = 0; + + #[derive(serde::Serialize)] + struct ConnectBody { + ssid: String, + psk: String, + } + let body = ConnectBody { ssid, psk }; + auth_post!( + Wifi, WifiEvent, model, + "/wifi/connect", + ConnectResponse, "WiFi connect", + body_json: &body + ) + }) + } + + WifiEvent::ConnectResponse(result) => { + if let Err(e) = result { + with_ready_state!( + model, + |_iface, + status, + _scan_state, + _scan_results, + _saved, + _scan_poll, + _connect_poll| { + status.state = WifiConnectionState::Failed(e); + render() + } + ) + } else { + // Connect request accepted; Shell will drive polling via ConnectPollTick + model.stop_loading(); + render() + } + } + + WifiEvent::ConnectPollTick => { + with_ready_state!(model, |_iface, + status, + _scan_state, + _scan_results, + _saved, + _scan_poll, + connect_poll| { + if !matches!(status.state, WifiConnectionState::Connecting) { + return Command::done(); + } + if *connect_poll >= CONNECT_POLL_MAX_ATTEMPTS { + status.state = WifiConnectionState::Failed("Connection timed out".to_string()); + return render(); + } + *connect_poll += 1; + auth_get!( + Wifi, WifiEvent, model, + "/wifi/status", + StatusResponse, "WiFi connect status", + expect_json: WifiStatusApiResponse + ) + }) + } + + WifiEvent::StatusResponse(result) => match result { + Ok(response) => { + with_ready_state!( + model, + |_iface, + status, + _scan_state, + _scan_results, + _saved, + _scan_poll, + _connect_poll| { + let was_connecting = + matches!(status.state, WifiConnectionState::Connecting); + + status.ssid = response.ssid; + status.ip_address = response.ip_address; + + match response.state.as_str() { + "connected" => { + status.state = WifiConnectionState::Connected; + if was_connecting { + // Auto-refresh saved networks after successful connect + return Command::all([ + render(), + handle(WifiEvent::GetSavedNetworks, model), + ]); + } + } + "failed" if was_connecting => { + status.state = + WifiConnectionState::Failed("Connection failed".to_string()); + } + "idle" if !was_connecting => { + status.state = WifiConnectionState::Idle; + } + _ => { + // "connecting" or other states — keep polling + } + } + render() + } + ) + } + Err(e) => { + log::error!("WiFi status poll failed: {e}"); + render() + } + }, + + WifiEvent::Disconnect => { + auth_post!( + Wifi, + WifiEvent, + model, + "/wifi/disconnect", + DisconnectResponse, + "WiFi disconnect" + ) + } + + WifiEvent::DisconnectResponse(result) => { + model.stop_loading(); + if let Err(e) = result { + with_ready_state!( + model, + |_iface, + status, + _scan_state, + _scan_results, + _saved, + _scan_poll, + _connect_poll| { + status.state = WifiConnectionState::Failed(e); + render() + } + ) + } else { + // Refresh status after disconnect + handle(WifiEvent::GetStatus, model) + } + } + + WifiEvent::GetStatus => { + auth_get!( + Wifi, WifiEvent, model, + "/wifi/status", + StatusResponse, "WiFi status", + expect_json: WifiStatusApiResponse + ) + } + + WifiEvent::GetSavedNetworks => { + auth_get!( + Wifi, WifiEvent, model, + "/wifi/networks", + SavedNetworksResponse, "WiFi saved networks", + expect_json: WifiSavedNetworksApiResponse + ) + } + + WifiEvent::SavedNetworksResponse(result) => match result { + Ok(response) => { + with_ready_state!( + model, + |_iface, + _status, + _scan_state, + _scan_results, + saved, + _scan_poll, + _connect_poll| { + *saved = response + .networks + .into_iter() + .map(|n| WifiSavedNetwork { + ssid: n.ssid, + flags: n.flags, + }) + .collect(); + render() + } + ) + } + Err(e) => { + log::error!("Failed to load saved networks: {e}"); + render() + } + }, + + WifiEvent::ForgetNetwork { ssid } => { + #[derive(serde::Serialize)] + struct ForgetBody { + ssid: String, + } + let body = ForgetBody { ssid }; + auth_post!( + Wifi, WifiEvent, model, + "/wifi/networks/forget", + ForgetNetworkResponse, "WiFi forget network", + body_json: &body + ) + } + + WifiEvent::ForgetNetworkResponse(result) => { + model.stop_loading(); + if let Err(e) = result { + log::error!("Failed to forget network: {e}"); + render() + } else { + // Auto-refresh saved networks and status after forget + Command::all([ + render(), + handle(WifiEvent::GetSavedNetworks, model), + handle(WifiEvent::GetStatus, model), + ]) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::WifiAvailability; + + fn model_with_ready_state() -> Model { + Model { + auth_token: Some("test-token".to_string()), + is_authenticated: true, + wifi_state: WifiState::Ready { + interface_name: "wlan0".to_string(), + status: WifiConnectionStatus::default(), + scan_state: WifiScanState::Idle, + scan_results: Vec::new(), + saved_networks: Vec::new(), + scan_poll_attempt: 0, + connect_poll_attempt: 0, + }, + ..Default::default() + } + } + + mod check_availability { + use super::*; + + #[test] + fn available_response_transitions_to_ready() { + let mut model = Model::default(); + let result = Ok(WifiAvailability { + available: true, + interface_name: Some("wlan0".to_string()), + }); + let _ = handle(WifiEvent::CheckAvailabilityResponse(result), &mut model); + + match &model.wifi_state { + WifiState::Ready { interface_name, .. } => { + assert_eq!(interface_name, "wlan0"); + } + _ => panic!("Expected Ready state"), + } + } + + #[test] + fn unavailable_response_stays_unavailable() { + let mut model = Model::default(); + let result = Ok(WifiAvailability { + available: false, + interface_name: None, + }); + let _ = handle(WifiEvent::CheckAvailabilityResponse(result), &mut model); + assert_eq!(model.wifi_state, WifiState::Unavailable); + } + + #[test] + fn error_response_stays_unavailable() { + let mut model = Model::default(); + let result = Err("network error".to_string()); + let _ = handle(WifiEvent::CheckAvailabilityResponse(result), &mut model); + assert_eq!(model.wifi_state, WifiState::Unavailable); + } + } + + mod scan { + use super::*; + use crate::events::WifiNetworkApiResponse; + + #[test] + fn scan_sets_scanning_state() { + let mut model = model_with_ready_state(); + let _ = handle(WifiEvent::Scan, &mut model); + + if let WifiState::Ready { + scan_state, + scan_poll_attempt, + .. + } = &model.wifi_state + { + assert_eq!(*scan_state, WifiScanState::Scanning); + assert_eq!(*scan_poll_attempt, 0); + } else { + panic!("Expected Ready state"); + } + } + + #[test] + fn scan_error_sets_error_state() { + let mut model = model_with_ready_state(); + let _ = handle( + WifiEvent::ScanResponse(Err("scan failed".to_string())), + &mut model, + ); + + if let WifiState::Ready { scan_state, .. } = &model.wifi_state { + assert_eq!(*scan_state, WifiScanState::Error("scan failed".to_string())); + } + } + + #[test] + fn scan_poll_increments_attempt() { + let mut model = model_with_ready_state(); + // Set scanning state + if let WifiState::Ready { scan_state, .. } = &mut model.wifi_state { + *scan_state = WifiScanState::Scanning; + } + let _ = handle(WifiEvent::ScanPollTick, &mut model); + + if let WifiState::Ready { + scan_poll_attempt, .. + } = &model.wifi_state + { + assert_eq!(*scan_poll_attempt, 1); + } + } + + #[test] + fn scan_poll_timeout() { + let mut model = model_with_ready_state(); + if let WifiState::Ready { + scan_state, + scan_poll_attempt, + .. + } = &mut model.wifi_state + { + *scan_state = WifiScanState::Scanning; + *scan_poll_attempt = SCAN_POLL_MAX_ATTEMPTS; + } + let _ = handle(WifiEvent::ScanPollTick, &mut model); + + if let WifiState::Ready { scan_state, .. } = &model.wifi_state { + assert!( + matches!(scan_state, WifiScanState::Error(msg) if msg.contains("timed out")) + ); + } + } + + #[test] + fn scan_results_deduplicate_by_ssid() { + let mut model = model_with_ready_state(); + if let WifiState::Ready { scan_state, .. } = &mut model.wifi_state { + *scan_state = WifiScanState::Scanning; + } + let response = WifiScanResultsApiResponse { + status: "ok".to_string(), + state: "finished".to_string(), + networks: vec![ + WifiNetworkApiResponse { + ssid: "MyNet".to_string(), + mac: "aa:bb:cc:dd:ee:ff".to_string(), + ch: 6, + rssi: -70, + }, + WifiNetworkApiResponse { + ssid: "MyNet".to_string(), + mac: "11:22:33:44:55:66".to_string(), + ch: 11, + rssi: -50, + }, + WifiNetworkApiResponse { + ssid: "Other".to_string(), + mac: "ff:ff:ff:ff:ff:ff".to_string(), + ch: 1, + rssi: -80, + }, + ], + }; + let _ = handle(WifiEvent::ScanResultsResponse(Ok(response)), &mut model); + + if let WifiState::Ready { + scan_state, + scan_results, + .. + } = &model.wifi_state + { + assert_eq!(*scan_state, WifiScanState::Finished); + assert_eq!(scan_results.len(), 2); + // Strongest first + assert_eq!(scan_results[0].ssid, "MyNet"); + assert_eq!(scan_results[0].rssi, -50); + assert_eq!(scan_results[1].ssid, "Other"); + } + } + + #[test] + fn scan_results_while_still_scanning_keeps_state() { + let mut model = model_with_ready_state(); + if let WifiState::Ready { scan_state, .. } = &mut model.wifi_state { + *scan_state = WifiScanState::Scanning; + } + let response = WifiScanResultsApiResponse { + status: "ok".to_string(), + state: "scanning".to_string(), + networks: vec![], + }; + let _ = handle(WifiEvent::ScanResultsResponse(Ok(response)), &mut model); + + if let WifiState::Ready { scan_state, .. } = &model.wifi_state { + assert_eq!(*scan_state, WifiScanState::Scanning); + } + } + } + + mod connect { + use super::*; + + #[test] + fn empty_ssid_fails_validation() { + let mut model = model_with_ready_state(); + let _ = handle( + WifiEvent::Connect { + ssid: " ".to_string(), + password: "secret".to_string(), + }, + &mut model, + ); + + if let WifiState::Ready { status, .. } = &model.wifi_state { + assert!(matches!( + &status.state, + WifiConnectionState::Failed(msg) if msg.contains("SSID") + )); + } + } + + #[test] + fn empty_password_fails_validation() { + let mut model = model_with_ready_state(); + let _ = handle( + WifiEvent::Connect { + ssid: "MyNet".to_string(), + password: "".to_string(), + }, + &mut model, + ); + + if let WifiState::Ready { status, .. } = &model.wifi_state { + assert!(matches!( + &status.state, + WifiConnectionState::Failed(msg) if msg.contains("Password") + )); + } + } + + #[test] + fn valid_connect_sets_connecting_state() { + let mut model = model_with_ready_state(); + let _ = handle( + WifiEvent::Connect { + ssid: "MyNet".to_string(), + password: "password123".to_string(), + }, + &mut model, + ); + + if let WifiState::Ready { + status, + connect_poll_attempt, + .. + } = &model.wifi_state + { + assert_eq!(status.state, WifiConnectionState::Connecting); + assert_eq!(*connect_poll_attempt, 0); + } + } + + #[test] + fn connect_error_sets_failed() { + let mut model = model_with_ready_state(); + if let WifiState::Ready { status, .. } = &mut model.wifi_state { + status.state = WifiConnectionState::Connecting; + } + let _ = handle( + WifiEvent::ConnectResponse(Err("auth failed".to_string())), + &mut model, + ); + + if let WifiState::Ready { status, .. } = &model.wifi_state { + assert!(matches!( + &status.state, + WifiConnectionState::Failed(msg) if msg == "auth failed" + )); + } + } + + #[test] + fn connect_poll_timeout() { + let mut model = model_with_ready_state(); + if let WifiState::Ready { + status, + connect_poll_attempt, + .. + } = &mut model.wifi_state + { + status.state = WifiConnectionState::Connecting; + *connect_poll_attempt = CONNECT_POLL_MAX_ATTEMPTS; + } + let _ = handle(WifiEvent::ConnectPollTick, &mut model); + + if let WifiState::Ready { status, .. } = &model.wifi_state { + assert!(matches!( + &status.state, + WifiConnectionState::Failed(msg) if msg.contains("timed out") + )); + } + } + + #[test] + fn status_connected_while_connecting_transitions() { + let mut model = model_with_ready_state(); + if let WifiState::Ready { status, .. } = &mut model.wifi_state { + status.state = WifiConnectionState::Connecting; + } + let response = WifiStatusApiResponse { + status: "ok".to_string(), + state: "connected".to_string(), + ssid: Some("MyNet".to_string()), + ip_address: Some("192.168.1.100".to_string()), + }; + let _ = handle(WifiEvent::StatusResponse(Ok(response)), &mut model); + + if let WifiState::Ready { status, .. } = &model.wifi_state { + assert_eq!(status.state, WifiConnectionState::Connected); + assert_eq!(status.ssid.as_deref(), Some("MyNet")); + assert_eq!(status.ip_address.as_deref(), Some("192.168.1.100")); + } + } + + #[test] + fn status_failed_while_connecting_transitions() { + let mut model = model_with_ready_state(); + if let WifiState::Ready { status, .. } = &mut model.wifi_state { + status.state = WifiConnectionState::Connecting; + } + let response = WifiStatusApiResponse { + status: "ok".to_string(), + state: "failed".to_string(), + ssid: None, + ip_address: None, + }; + let _ = handle(WifiEvent::StatusResponse(Ok(response)), &mut model); + + if let WifiState::Ready { status, .. } = &model.wifi_state { + assert!(matches!( + &status.state, + WifiConnectionState::Failed(msg) if msg.contains("failed") + )); + } + } + } + + mod disconnect { + use super::*; + + #[test] + fn disconnect_error_sets_failed() { + let mut model = model_with_ready_state(); + let _ = handle( + WifiEvent::DisconnectResponse(Err("disconnect failed".to_string())), + &mut model, + ); + + if let WifiState::Ready { status, .. } = &model.wifi_state { + assert!(matches!( + &status.state, + WifiConnectionState::Failed(msg) if msg == "disconnect failed" + )); + } + } + } + + mod forget_network { + use super::*; + use crate::events::WifiSavedNetworkApiResponse; + + #[test] + fn forget_error_logs_and_renders() { + let mut model = model_with_ready_state(); + let _ = handle( + WifiEvent::ForgetNetworkResponse(Err("not found".to_string())), + &mut model, + ); + // No crash, renders + } + + #[test] + fn saved_networks_response_updates_state() { + let mut model = model_with_ready_state(); + let response = WifiSavedNetworksApiResponse { + status: "ok".to_string(), + networks: vec![ + WifiSavedNetworkApiResponse { + ssid: "Home".to_string(), + flags: "[CURRENT]".to_string(), + }, + WifiSavedNetworkApiResponse { + ssid: "Work".to_string(), + flags: "".to_string(), + }, + ], + }; + let _ = handle(WifiEvent::SavedNetworksResponse(Ok(response)), &mut model); + + if let WifiState::Ready { saved_networks, .. } = &model.wifi_state { + assert_eq!(saved_networks.len(), 2); + assert_eq!(saved_networks[0].ssid, "Home"); + assert_eq!(saved_networks[0].flags, "[CURRENT]"); + } + } + } + + mod unavailable_state { + use super::*; + + #[test] + fn scan_on_unavailable_state_returns_done() { + let mut model = Model::default(); + assert_eq!(model.wifi_state, WifiState::Unavailable); + let _ = handle(WifiEvent::Scan, &mut model); + // Should not panic, just returns done + } + } +} diff --git a/src/app/src/wifi_psk.rs b/src/app/src/wifi_psk.rs new file mode 100644 index 00000000..b4a1f335 --- /dev/null +++ b/src/app/src/wifi_psk.rs @@ -0,0 +1,48 @@ +use pbkdf2::pbkdf2_hmac; +use sha1::Sha1; + +const WPA_PSK_ITERATIONS: u32 = 4096; +const WPA_PSK_KEY_LENGTH: usize = 32; + +/// Compute WPA PSK from password and SSID per IEEE 802.11i. +/// +/// Uses PBKDF2(HMAC-SHA1, password, SSID, 4096, 256 bits) → 64-char hex. +pub fn compute_wpa_psk(password: &str, ssid: &str) -> String { + let mut key = [0u8; WPA_PSK_KEY_LENGTH]; + pbkdf2_hmac::( + password.as_bytes(), + ssid.as_bytes(), + WPA_PSK_ITERATIONS, + &mut key, + ); + hex::encode(key) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// IEEE 802.11i test vector + #[test] + fn ieee_test_vector() { + let psk = compute_wpa_psk("password", "IEEE"); + assert_eq!( + psk, + "f42c6fc52df0ebef9ebb4b90b38a5f902e83fe1b135a70e23aed762e9710a12e" + ); + } + + #[test] + fn different_ssid_produces_different_psk() { + let psk1 = compute_wpa_psk("password", "NetworkA"); + let psk2 = compute_wpa_psk("password", "NetworkB"); + assert_ne!(psk1, psk2); + } + + #[test] + fn output_is_64_hex_chars() { + let psk = compute_wpa_psk("testpass", "testssid"); + assert_eq!(psk.len(), 64); + assert!(psk.chars().all(|c| c.is_ascii_hexdigit())); + } +} diff --git a/src/backend/src/api.rs b/src/backend/src/api.rs index 505d1d48..d504801f 100644 --- a/src/backend/src/api.rs +++ b/src/backend/src/api.rs @@ -9,6 +9,9 @@ use crate::{ marker, network::{NetworkConfigRequest, NetworkConfigService}, }, + wifi_commissioning_client::{ + WifiAvailability, WifiCommissioningClient, WifiConnectRequest, WifiForgetRequest, + }, }; use actix_files::NamedFile; use actix_multipart::Multipart; @@ -287,3 +290,96 @@ where HttpResponse::Ok().body(token) } } + +// --- WiFi API handlers --- +// Stored as separate web::Data resources to avoid changing the Api generic structure. + +use crate::wifi_commissioning_client::WifiCommissioningServiceClient; + +type WifiClient = Option; + +pub async fn wifi_available(availability: web::Data) -> impl Responder { + debug!("wifi_available() called"); + HttpResponse::Ok().json(availability.as_ref()) +} + +macro_rules! wifi_client_or_404 { + ($wifi:expr) => { + match $wifi.as_ref().as_ref() { + Some(client) => client, + None => return HttpResponse::NotFound().body("WiFi service unavailable"), + } + }; +} + +pub async fn wifi_scan(wifi: web::Data) -> impl Responder { + debug!("wifi_scan() called"); + let client = wifi_client_or_404!(wifi); + handle_service_result(client.scan().await.map(|_| ()), "wifi_scan") +} + +pub async fn wifi_scan_results(wifi: web::Data) -> impl Responder { + debug!("wifi_scan_results() called"); + let client = wifi_client_or_404!(wifi); + match client.scan_results().await { + Ok(results) => HttpResponse::Ok().json(&results), + Err(e) => { + error!("wifi_scan_results failed: {e:#}"); + HttpResponse::InternalServerError().body(e.to_string()) + } + } +} + +pub async fn wifi_connect( + body: web::Json, + wifi: web::Data, +) -> impl Responder { + debug!("wifi_connect() called"); + let client = wifi_client_or_404!(wifi); + handle_service_result( + client.connect(body.into_inner()).await.map(|_| ()), + "wifi_connect", + ) +} + +pub async fn wifi_disconnect(wifi: web::Data) -> impl Responder { + debug!("wifi_disconnect() called"); + let client = wifi_client_or_404!(wifi); + handle_service_result(client.disconnect().await.map(|_| ()), "wifi_disconnect") +} + +pub async fn wifi_status(wifi: web::Data) -> impl Responder { + debug!("wifi_status() called"); + let client = wifi_client_or_404!(wifi); + match client.status().await { + Ok(status) => HttpResponse::Ok().json(&status), + Err(e) => { + error!("wifi_status failed: {e:#}"); + HttpResponse::InternalServerError().body(e.to_string()) + } + } +} + +pub async fn wifi_saved_networks(wifi: web::Data) -> impl Responder { + debug!("wifi_saved_networks() called"); + let client = wifi_client_or_404!(wifi); + match client.saved_networks().await { + Ok(networks) => HttpResponse::Ok().json(&networks), + Err(e) => { + error!("wifi_saved_networks failed: {e:#}"); + HttpResponse::InternalServerError().body(e.to_string()) + } + } +} + +pub async fn wifi_forget_network( + body: web::Json, + wifi: web::Data, +) -> impl Responder { + debug!("wifi_forget_network() called"); + let client = wifi_client_or_404!(wifi); + handle_service_result( + client.forget_network(body.into_inner()).await.map(|_| ()), + "wifi_forget_network", + ) +} diff --git a/src/backend/src/config.rs b/src/backend/src/config.rs index 699c6b06..e8e3e93b 100644 --- a/src/backend/src/config.rs +++ b/src/backend/src/config.rs @@ -24,6 +24,9 @@ pub struct AppConfig { #[cfg_attr(feature = "mock", allow(dead_code))] pub iot_edge: IoTEdgeConfig, + /// WiFi commissioning service configuration + pub wifi: WifiConfig, + /// Path configuration pub paths: PathConfig, @@ -81,6 +84,11 @@ pub struct PathConfig { pub local_update_file: PathBuf, } +#[derive(Clone, Debug)] +pub struct WifiConfig { + pub socket_path: PathBuf, +} + impl AppConfig { /// Get or load the application configuration /// @@ -116,6 +124,7 @@ impl AppConfig { let device_service = DeviceServiceConfig::load()?; let certificate = CertificateConfig::load()?; let iot_edge = IoTEdgeConfig::load()?; + let wifi = WifiConfig::load(); let paths = PathConfig::load()?; let tenant = env::var("TENANT").unwrap_or_else(|_| "cp".to_string()); @@ -126,6 +135,7 @@ impl AppConfig { device_service, certificate, iot_edge, + wifi, paths, tenant, }) @@ -271,6 +281,16 @@ impl IoTEdgeConfig { } } +impl WifiConfig { + fn load() -> Self { + let socket_path = env::var("WIFI_COMMISSIONING_SOCKET_PATH") + .unwrap_or_else(|_| "/socket/wifi-commissioning.sock".to_string()) + .into(); + + Self { socket_path } + } +} + impl PathConfig { fn load() -> Result { #[cfg(not(any(test, feature = "mock")))] diff --git a/src/backend/src/lib.rs b/src/backend/src/lib.rs index fb5103d3..f8f14457 100644 --- a/src/backend/src/lib.rs +++ b/src/backend/src/lib.rs @@ -5,6 +5,7 @@ pub mod keycloak_client; pub mod middleware; pub mod omnect_device_service_client; pub mod services; +pub mod wifi_commissioning_client; // Re-exports from services for backward compatibility pub use services::auth; diff --git a/src/backend/src/main.rs b/src/backend/src/main.rs index 485ed458..7e6f15f9 100644 --- a/src/backend/src/main.rs +++ b/src/backend/src/main.rs @@ -5,6 +5,7 @@ mod keycloak_client; mod middleware; mod omnect_device_service_client; mod services; +mod wifi_commissioning_client; use crate::{ api::Api, @@ -16,6 +17,9 @@ use crate::{ certificate::{CertificateService, CreateCertPayload}, network::NetworkConfigService, }, + wifi_commissioning_client::{ + WifiAvailability, WifiCommissioningClient, WifiCommissioningServiceClient, + }, }; use actix_cors::Cors; use actix_multipart::form::MultipartFormConfig; @@ -282,6 +286,35 @@ fn optimal_worker_count() -> usize { workers } +async fn initialize_wifi_client() -> (Option, WifiAvailability) { + let unavailable = WifiAvailability { + available: false, + interface_name: None, + }; + + let config = &AppConfig::get().wifi; + + let Some(client) = WifiCommissioningServiceClient::try_new(&config.socket_path) else { + return (None, unavailable); + }; + + // Probe the service to discover the WiFi interface name + match client.status().await { + Ok(status) => { + let availability = WifiAvailability { + available: true, + interface_name: status.interface_name, + }; + info!("WiFi service available: {availability:?}"); + (Some(client), availability) + } + Err(e) => { + log::error!("WiFi service probe failed: {e:#}"); + (None, unavailable) + } + } +} + async fn run_server( service_client: OmnectDeviceServiceClient, ) -> Result<( @@ -292,6 +325,10 @@ async fn run_server( .await .context("failed to create api")?; + let (wifi_client, wifi_availability) = initialize_wifi_client().await; + let wifi_data: Data> = Data::new(wifi_client); + let wifi_availability_data = Data::new(wifi_availability); + let tls_config = load_tls_config().context("failed to load tls config")?; let config = &AppConfig::get(); let ui_port = config.ui.port; @@ -327,6 +364,8 @@ async fn run_server( .app_data(Data::new(token_manager.clone())) .app_data(Data::new(api.clone())) .app_data(Data::new(static_files())) + .app_data(wifi_data.clone()) + .app_data(wifi_availability_data.clone()) .route("/", web::get().to(UiApi::index)) .route("/config.js", web::get().to(UiApi::config)) .route( @@ -384,6 +423,44 @@ async fn run_server( "/ack-update-validation", web::post().to(UiApi::ack_update_validation), ) + // WiFi management routes + .route("/wifi/available", web::get().to(api::wifi_available)) + .route( + "/wifi/scan", + web::post().to(api::wifi_scan).wrap(middleware::AuthMw), + ) + .route( + "/wifi/scan/results", + web::get() + .to(api::wifi_scan_results) + .wrap(middleware::AuthMw), + ) + .route( + "/wifi/connect", + web::post().to(api::wifi_connect).wrap(middleware::AuthMw), + ) + .route( + "/wifi/disconnect", + web::post() + .to(api::wifi_disconnect) + .wrap(middleware::AuthMw), + ) + .route( + "/wifi/status", + web::get().to(api::wifi_status).wrap(middleware::AuthMw), + ) + .route( + "/wifi/networks", + web::get() + .to(api::wifi_saved_networks) + .wrap(middleware::AuthMw), + ) + .route( + "/wifi/networks/forget", + web::post() + .to(api::wifi_forget_network) + .wrap(middleware::AuthMw), + ) .service(ResourceFiles::new("/static", static_files())) .default_service(web::route().to(UiApi::index)) }) diff --git a/src/backend/src/wifi_commissioning_client.rs b/src/backend/src/wifi_commissioning_client.rs new file mode 100644 index 00000000..4498d9c6 --- /dev/null +++ b/src/backend/src/wifi_commissioning_client.rs @@ -0,0 +1,373 @@ +#![cfg_attr(feature = "mock", allow(dead_code, unused_imports))] + +use crate::http_client::{handle_http_response, unix_socket_client}; +use anyhow::{Context, Result}; +use log::info; +#[cfg(feature = "mock")] +use mockall::automock; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::fmt::Debug; +use std::path::Path; +use trait_variant::make; + +// --- Request DTOs --- + +#[derive(Debug, Deserialize, Serialize)] +pub struct WifiConnectRequest { + pub ssid: String, + pub psk: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct WifiForgetRequest { + pub ssid: String, +} + +// --- Response DTOs --- + +#[derive(Debug, Deserialize, Serialize)] +pub struct WifiScanStartedResponse { + pub status: String, + pub state: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct WifiScanResultsResponse { + pub status: String, + pub state: String, + pub networks: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct WifiNetwork { + pub ssid: String, + pub mac: String, + pub ch: u16, + pub rssi: i16, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct WifiConnectResponse { + pub status: String, + pub state: String, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct WifiDisconnectResponse { + pub status: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct WifiStatusResponse { + pub status: String, + pub state: String, + pub ssid: Option, + pub ip_address: Option, + pub interface_name: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct WifiSavedNetworksResponse { + pub status: String, + pub networks: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct WifiSavedNetwork { + pub ssid: String, + pub flags: String, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct WifiForgetResponse { + pub status: String, +} + +// --- Availability response (our own, not from the service) --- + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WifiAvailability { + pub available: bool, + pub interface_name: Option, +} + +// --- Client trait --- + +#[make(Send)] +#[cfg_attr(feature = "mock", automock)] +pub trait WifiCommissioningClient { + async fn scan(&self) -> Result; + async fn scan_results(&self) -> Result; + async fn connect(&self, request: WifiConnectRequest) -> Result; + async fn disconnect(&self) -> Result; + async fn status(&self) -> Result; + async fn saved_networks(&self) -> Result; + async fn forget_network(&self, request: WifiForgetRequest) -> Result; +} + +#[cfg(feature = "mock")] +impl Clone for MockWifiCommissioningClient { + fn clone(&self) -> Self { + Self::new() + } +} + +// --- Client implementation --- + +#[derive(Clone)] +pub struct WifiCommissioningServiceClient { + client: Client, +} + +impl WifiCommissioningServiceClient { + const SCAN_ENDPOINT: &str = "/api/v1/scan"; + const SCAN_RESULTS_ENDPOINT: &str = "/api/v1/scan/results"; + const CONNECT_ENDPOINT: &str = "/api/v1/connect"; + const DISCONNECT_ENDPOINT: &str = "/api/v1/disconnect"; + const STATUS_ENDPOINT: &str = "/api/v1/status"; + const NETWORKS_ENDPOINT: &str = "/api/v1/networks"; + const FORGET_ENDPOINT: &str = "/api/v1/networks/forget"; + + /// Try to create a client. Returns `None` if the socket does not exist. + pub fn try_new(socket_path: &Path) -> Option { + let path_str = socket_path.to_string_lossy(); + + if !socket_path.exists() { + info!("WiFi socket not found at {path_str}, WiFi management disabled"); + return None; + } + + match unix_socket_client(&path_str) { + Ok(client) => { + info!("WiFi commissioning client created for socket {path_str}"); + Some(Self { client }) + } + Err(e) => { + log::error!("Failed to create WiFi socket client at {path_str}: {e:#}"); + None + } + } + } + + fn build_url(path: &str) -> String { + let normalized = path.trim_start_matches('/'); + format!("http://localhost/{normalized}") + } + + async fn get(&self, path: &str) -> Result { + let url = Self::build_url(path); + info!("WiFi GET {url}"); + + let res = self + .client + .get(&url) + .send() + .await + .context(format!("failed to send GET to {url}"))?; + + handle_http_response(res, &format!("WiFi GET {url}")).await + } + + async fn post(&self, path: &str) -> Result { + let url = Self::build_url(path); + info!("WiFi POST {url}"); + + let res = self + .client + .post(&url) + .send() + .await + .context(format!("failed to send POST to {url}"))?; + + handle_http_response(res, &format!("WiFi POST {url}")).await + } + + async fn post_json(&self, path: &str, body: impl Debug + Serialize) -> Result { + let url = Self::build_url(path); + info!("WiFi POST {url} with body: {body:?}"); + + let res = self + .client + .post(&url) + .json(&body) + .send() + .await + .context(format!("failed to send POST to {url}"))?; + + handle_http_response(res, &format!("WiFi POST {url}")).await + } +} + +impl WifiCommissioningClient for WifiCommissioningServiceClient { + async fn scan(&self) -> Result { + let body = self.post(Self::SCAN_ENDPOINT).await?; + serde_json::from_str(&body).context("failed to parse scan response") + } + + async fn scan_results(&self) -> Result { + let body = self.get(Self::SCAN_RESULTS_ENDPOINT).await?; + serde_json::from_str(&body).context("failed to parse scan results") + } + + async fn connect(&self, request: WifiConnectRequest) -> Result { + let body = self.post_json(Self::CONNECT_ENDPOINT, request).await?; + serde_json::from_str(&body).context("failed to parse connect response") + } + + async fn disconnect(&self) -> Result { + let body = self.post(Self::DISCONNECT_ENDPOINT).await?; + serde_json::from_str(&body).context("failed to parse disconnect response") + } + + async fn status(&self) -> Result { + let body = self.get(Self::STATUS_ENDPOINT).await?; + serde_json::from_str(&body).context("failed to parse status response") + } + + async fn saved_networks(&self) -> Result { + let body = self.get(Self::NETWORKS_ENDPOINT).await?; + serde_json::from_str(&body).context("failed to parse saved networks response") + } + + async fn forget_network(&self, request: WifiForgetRequest) -> Result { + let body = self.post_json(Self::FORGET_ENDPOINT, request).await?; + serde_json::from_str(&body).context("failed to parse forget response") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod build_url { + use super::*; + + #[test] + fn normalizes_path_with_leading_slash() { + let url = WifiCommissioningServiceClient::build_url("/api/v1/scan"); + assert_eq!(url, "http://localhost/api/v1/scan"); + } + + #[test] + fn normalizes_path_without_leading_slash() { + let url = WifiCommissioningServiceClient::build_url("api/v1/scan"); + assert_eq!(url, "http://localhost/api/v1/scan"); + } + } + + mod dto_serialization { + use super::*; + + #[test] + fn connect_request_serializes_correctly() { + let req = WifiConnectRequest { + ssid: "MyNetwork".to_string(), + psk: "a".repeat(64), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"ssid\":\"MyNetwork\"")); + assert!(json.contains("\"psk\":\"")); + } + + #[test] + fn forget_request_serializes_correctly() { + let req = WifiForgetRequest { + ssid: "OldNetwork".to_string(), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"ssid\":\"OldNetwork\"")); + } + + #[test] + fn status_response_deserializes_with_all_fields() { + let json = r#"{"status":"ok","state":"connected","ssid":"MyNet","ip_address":"192.168.1.100","interface_name":"wlan0"}"#; + let resp: WifiStatusResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.state, "connected"); + assert_eq!(resp.ssid.as_deref(), Some("MyNet")); + assert_eq!(resp.ip_address.as_deref(), Some("192.168.1.100")); + assert_eq!(resp.interface_name.as_deref(), Some("wlan0")); + } + + #[test] + fn status_response_deserializes_without_optional_fields() { + let json = r#"{"status":"ok","state":"idle","ssid":null,"ip_address":null,"interface_name":"wlan0"}"#; + let resp: WifiStatusResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.state, "idle"); + assert!(resp.ssid.is_none()); + assert!(resp.ip_address.is_none()); + assert_eq!(resp.interface_name.as_deref(), Some("wlan0")); + } + + #[test] + fn scan_results_deserializes_network_list() { + let json = r#"{"status":"ok","state":"finished","networks":[{"ssid":"Net1","mac":"aa:bb:cc:dd:ee:ff","ch":6,"rssi":-55}]}"#; + let resp: WifiScanResultsResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.state, "finished"); + assert_eq!(resp.networks.len(), 1); + assert_eq!(resp.networks[0].ssid, "Net1"); + assert_eq!(resp.networks[0].ch, 6); + assert_eq!(resp.networks[0].rssi, -55); + } + + #[test] + fn saved_networks_deserializes_with_flags() { + let json = r#"{"status":"ok","networks":[{"ssid":"Home","flags":"[CURRENT]"},{"ssid":"Work","flags":""}]}"#; + let resp: WifiSavedNetworksResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.networks.len(), 2); + assert_eq!(resp.networks[0].flags, "[CURRENT]"); + assert!(resp.networks[1].flags.is_empty()); + } + } + + mod try_new { + use super::*; + + #[test] + fn returns_none_for_nonexistent_socket() { + let result = + WifiCommissioningServiceClient::try_new(Path::new("/tmp/nonexistent.sock")); + assert!(result.is_none()); + } + } + + mod constants { + use super::*; + + #[test] + fn api_endpoints_are_correctly_defined() { + assert_eq!( + WifiCommissioningServiceClient::SCAN_ENDPOINT, + "/api/v1/scan" + ); + assert_eq!( + WifiCommissioningServiceClient::SCAN_RESULTS_ENDPOINT, + "/api/v1/scan/results" + ); + assert_eq!( + WifiCommissioningServiceClient::CONNECT_ENDPOINT, + "/api/v1/connect" + ); + assert_eq!( + WifiCommissioningServiceClient::DISCONNECT_ENDPOINT, + "/api/v1/disconnect" + ); + assert_eq!( + WifiCommissioningServiceClient::STATUS_ENDPOINT, + "/api/v1/status" + ); + assert_eq!( + WifiCommissioningServiceClient::NETWORKS_ENDPOINT, + "/api/v1/networks" + ); + assert_eq!( + WifiCommissioningServiceClient::FORGET_ENDPOINT, + "/api/v1/networks/forget" + ); + } + } +} diff --git a/src/shared_types/build.rs b/src/shared_types/build.rs index 1b80feb7..abcff210 100644 --- a/src/shared_types/build.rs +++ b/src/shared_types/build.rs @@ -1,10 +1,11 @@ use anyhow::Result; use crux_core::typegen::TypeGen; use omnect_ui_core::{ - events::{AuthEvent, DeviceEvent, UiEvent, WebSocketEvent}, + events::{AuthEvent, DeviceEvent, UiEvent, WebSocketEvent, WifiEvent}, types::{ DeviceOperationState, FactoryResetStatus, NetworkChangeState, NetworkConfigRequest, - NetworkFormData, NetworkFormState, UploadState, + NetworkFormData, NetworkFormState, UploadState, WifiConnectionState, WifiConnectionStatus, + WifiNetwork, WifiSavedNetwork, WifiScanState, WifiState, }, App, }; @@ -22,6 +23,7 @@ fn main() -> Result<()> { gen.register_type::()?; gen.register_type::()?; gen.register_type::()?; + gen.register_type::()?; // Explicitly register other enums/structs to ensure all variants are traced gen.register_type::()?; @@ -32,6 +34,14 @@ fn main() -> Result<()> { gen.register_type::()?; gen.register_type::()?; + // Register WiFi types + gen.register_type::()?; + gen.register_type::()?; + gen.register_type::()?; + gen.register_type::()?; + gen.register_type::()?; + gen.register_type::()?; + // Register ODS types gen.register_type::()?; gen.register_type::()?; diff --git a/src/ui/src/components/network/DeviceNetworks.vue b/src/ui/src/components/network/DeviceNetworks.vue index fa04a793..dab5c828 100644 --- a/src/ui/src/components/network/DeviceNetworks.vue +++ b/src/ui/src/components/network/DeviceNetworks.vue @@ -1,11 +1,16 @@ + + diff --git a/src/ui/src/components/network/WifiForgetDialog.vue b/src/ui/src/components/network/WifiForgetDialog.vue new file mode 100644 index 00000000..ac84672e --- /dev/null +++ b/src/ui/src/components/network/WifiForgetDialog.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/ui/src/components/network/WifiPanel.vue b/src/ui/src/components/network/WifiPanel.vue new file mode 100644 index 00000000..94d886a4 --- /dev/null +++ b/src/ui/src/components/network/WifiPanel.vue @@ -0,0 +1,146 @@ + + + diff --git a/src/ui/src/composables/core/index.ts b/src/ui/src/composables/core/index.ts index c7625c51..02b5841d 100644 --- a/src/ui/src/composables/core/index.ts +++ b/src/ui/src/composables/core/index.ts @@ -48,6 +48,7 @@ import { EventVariantDevice, EventVariantWebSocket, EventVariantUi, + EventVariantWifi, AuthEventVariantLogin, AuthEventVariantLogout, AuthEventVariantSetPassword, @@ -69,6 +70,14 @@ import { UiEventVariantClearError, UiEventVariantClearSuccess, UiEventVariantSetBrowserHostname, + WifiEventVariantScan, + WifiEventVariantConnect, + WifiEventVariantDisconnect, + WifiEventVariantGetStatus, + WifiEventVariantGetSavedNetworks, + WifiEventVariantForgetNetwork, + WifiEventVariantScanPollTick, + WifiEventVariantConnectPollTick, } from '../../../../shared_types/generated/typescript/types/shared_types' // Re-export types for external use @@ -80,6 +89,12 @@ export type { NetworkFormDataType, OverlaySpinnerStateType, FactoryResetStatusString, + WifiStateType, + WifiNetworkType, + WifiSavedNetworkType, + WifiConnectionStatusType, + WifiScanStateType, + WifiConnectionStateType, SystemInfo, NetworkStatus, OnlineStatus, @@ -295,5 +310,16 @@ export function useCore() { sendEventToCore(new EventVariantDevice(new DeviceEventVariantAckFactoryResetResult())), ackUpdateValidation: () => sendEventToCore(new EventVariantDevice(new DeviceEventVariantAckUpdateValidation())), + + // WiFi management + wifiScan: () => sendEventToCore(new EventVariantWifi(new WifiEventVariantScan())), + wifiConnect: (ssid: string, password: string) => + sendEventToCore(new EventVariantWifi(new WifiEventVariantConnect(ssid, password))), + wifiDisconnect: () => sendEventToCore(new EventVariantWifi(new WifiEventVariantDisconnect())), + wifiGetStatus: () => sendEventToCore(new EventVariantWifi(new WifiEventVariantGetStatus())), + wifiGetSavedNetworks: () => + sendEventToCore(new EventVariantWifi(new WifiEventVariantGetSavedNetworks())), + wifiForgetNetwork: (ssid: string) => + sendEventToCore(new EventVariantWifi(new WifiEventVariantForgetNetwork(ssid))), } } diff --git a/src/ui/src/composables/core/state.ts b/src/ui/src/composables/core/state.ts index 0bab9469..b5148e17 100644 --- a/src/ui/src/composables/core/state.ts +++ b/src/ui/src/composables/core/state.ts @@ -52,6 +52,8 @@ export const viewModel = reactive({ firmwareUploadState: { type: 'idle' }, // Overlay spinner state overlaySpinner: { overlay: false, title: '', text: null, timedOut: false, progress: null, countdownSeconds: null }, + // WiFi state + wifiState: { type: 'unavailable' }, }) /** diff --git a/src/ui/src/composables/core/sync.ts b/src/ui/src/composables/core/sync.ts index fc624335..a40274dd 100644 --- a/src/ui/src/composables/core/sync.ts +++ b/src/ui/src/composables/core/sync.ts @@ -12,6 +12,7 @@ import { convertNetworkChangeState, convertNetworkFormState, convertUploadState, + convertWifiState, } from './types' import { setViewModelUpdater } from './effects' import { Model as GeneratedViewModel } from '../../../../shared_types/generated/typescript/types/shared_types' @@ -199,6 +200,9 @@ export function updateViewModelFromCore(): void { // Firmware upload state viewModel.firmwareUploadState = convertUploadState(coreViewModel.firmwareUploadState) + // WiFi state + viewModel.wifiState = convertWifiState(coreViewModel.wifiState) + // Auto-subscribe logic based on authentication state transition if (viewModel.isAuthenticated && !wasAuthenticated) { console.log('[useCore] User authenticated, triggering subscription') diff --git a/src/ui/src/composables/core/timers.ts b/src/ui/src/composables/core/timers.ts index 6f7fc465..915bbc7d 100644 --- a/src/ui/src/composables/core/timers.ts +++ b/src/ui/src/composables/core/timers.ts @@ -12,10 +12,13 @@ import { viewModel, isInitialized, wasmModule } from './state' import type { Event } from '../../../../shared_types/generated/typescript/types/shared_types' import { EventVariantDevice, + EventVariantWifi, DeviceEventVariantReconnectionCheckTick, DeviceEventVariantReconnectionTimeout, DeviceEventVariantNewIpCheckTick, DeviceEventVariantNewIpCheckTimeout, + WifiEventVariantScanPollTick, + WifiEventVariantConnectPollTick, } from '../../../../shared_types/generated/typescript/types/shared_types' // Timer callback type - will be set by index.ts to avoid circular dependency @@ -34,6 +37,8 @@ export function setEventSender(callback: (event: Event) => Promise): void const RECONNECTION_POLL_INTERVAL_MS = Number(import.meta.env.VITE_RECONNECTION_POLL_INTERVAL_MS) || 5000 // 5 seconds const NEW_IP_POLL_INTERVAL_MS = Number(import.meta.env.VITE_NEW_IP_POLL_INTERVAL_MS) || 5000 // 5 seconds +const WIFI_SCAN_POLL_INTERVAL_MS = 500 +const WIFI_CONNECT_POLL_INTERVAL_MS = 1000 // Optional test overrides for reconnection timeouts (production values come from Core) const REBOOT_TIMEOUT_OVERRIDE_MS = import.meta.env.VITE_REBOOT_TIMEOUT_MS ? Number(import.meta.env.VITE_REBOOT_TIMEOUT_MS) : null @@ -51,6 +56,8 @@ let reconnectionCountdownDeadline: number | null = null let newIpIntervalId: ReturnType | null = null let newIpTimeoutId: ReturnType | null = null let newIpCountdownIntervalId: ReturnType | null = null +let wifiScanPollIntervalId: ReturnType | null = null +let wifiConnectPollIntervalId: ReturnType | null = null // Countdown deadline for network changes (Unix timestamp in milliseconds) let countdownDeadline: number | null = null @@ -409,4 +416,52 @@ export function initializeTimerWatchers(): void { }, { deep: true } ) + + // Watch WiFi scan state for scan polling + watch( + () => viewModel.wifiState.type === 'ready' ? (viewModel.wifiState as any).scanState?.type : null, + (newType, oldType) => { + if (newType === oldType) return + + if (newType === 'scanning') { + // Start scan poll interval + if (wifiScanPollIntervalId !== null) clearInterval(wifiScanPollIntervalId) + wifiScanPollIntervalId = setInterval(() => { + if (isInitialized.value && wasmModule.value && sendEventCallback) { + sendEventCallback(new EventVariantWifi(new WifiEventVariantScanPollTick())) + } + }, WIFI_SCAN_POLL_INTERVAL_MS) + } else { + // Stop scan poll interval + if (wifiScanPollIntervalId !== null) { + clearInterval(wifiScanPollIntervalId) + wifiScanPollIntervalId = null + } + } + } + ) + + // Watch WiFi connection state for connect polling + watch( + () => viewModel.wifiState.type === 'ready' ? (viewModel.wifiState as any).status?.state?.type : null, + (newType, oldType) => { + if (newType === oldType) return + + if (newType === 'connecting') { + // Start connect poll interval + if (wifiConnectPollIntervalId !== null) clearInterval(wifiConnectPollIntervalId) + wifiConnectPollIntervalId = setInterval(() => { + if (isInitialized.value && wasmModule.value && sendEventCallback) { + sendEventCallback(new EventVariantWifi(new WifiEventVariantConnectPollTick())) + } + }, WIFI_CONNECT_POLL_INTERVAL_MS) + } else { + // Stop connect poll interval + if (wifiConnectPollIntervalId !== null) { + clearInterval(wifiConnectPollIntervalId) + wifiConnectPollIntervalId = null + } + } + } + ) } \ No newline at end of file diff --git a/src/ui/src/composables/core/types.ts b/src/ui/src/composables/core/types.ts index b62cb041..09fd5b90 100644 --- a/src/ui/src/composables/core/types.ts +++ b/src/ui/src/composables/core/types.ts @@ -27,6 +27,19 @@ export { NetworkConfigRequest } from '../../../../shared_types/generated/typescr // Import types and variant classes for conversions import { + WifiState, + WifiStateVariantunavailable, + WifiStateVariantready, + WifiConnectionState, + WifiConnectionStateVariantidle, + WifiConnectionStateVariantconnecting, + WifiConnectionStateVariantconnected, + WifiConnectionStateVariantfailed, + WifiScanState, + WifiScanStateVariantidle, + WifiScanStateVariantscanning, + WifiScanStateVariantfinished, + WifiScanStateVarianterror, DeviceOperationState, DeviceOperationStateVariantidle, DeviceOperationStateVariantrebooting, @@ -69,6 +82,7 @@ export { FactoryResetStatus, UploadState, DeviceNetwork, + WifiState, } // ============================================================================ @@ -121,6 +135,49 @@ export interface OverlaySpinnerStateType { countdownSeconds: number | null } +export type WifiScanStateType = + | { type: 'idle' } + | { type: 'scanning' } + | { type: 'finished' } + | { type: 'error'; message: string } + +export type WifiConnectionStateType = + | { type: 'idle' } + | { type: 'connecting' } + | { type: 'connected' } + | { type: 'failed'; message: string } + +export interface WifiConnectionStatusType { + state: WifiConnectionStateType + ssid: string | null + ipAddress: string | null +} + +export interface WifiNetworkType { + ssid: string + mac: string + channel: number + rssi: number +} + +export interface WifiSavedNetworkType { + ssid: string + flags: string +} + +export type WifiStateType = + | { type: 'unavailable' } + | { + type: 'ready' + interfaceName: string + status: WifiConnectionStatusType + scanState: WifiScanStateType + scanResults: WifiNetworkType[] + savedNetworks: WifiSavedNetworkType[] + scanPollAttempt: number + connectPollAttempt: number + } + export type FactoryResetStatusString = 'unknown' | 'modeSupported' | 'modeUnsupported' | 'backupRestoreError' | 'configurationError' // ============================================================================ @@ -195,6 +252,9 @@ export interface ViewModel { // Overlay spinner state overlaySpinner: OverlaySpinnerStateType + + // WiFi state + wifiState: WifiStateType } // ============================================================================ @@ -351,3 +411,59 @@ export function convertUploadState(state: UploadState): UploadStateType { } return { type: 'idle' } } + +/** + * Convert WifiScanState variant to typed object + */ +export function convertWifiScanState(state: WifiScanState): WifiScanStateType { + if (state instanceof WifiScanStateVariantidle) return { type: 'idle' } + if (state instanceof WifiScanStateVariantscanning) return { type: 'scanning' } + if (state instanceof WifiScanStateVariantfinished) return { type: 'finished' } + if (state instanceof WifiScanStateVarianterror) return { type: 'error', message: state.value } + return { type: 'idle' } +} + +/** + * Convert WifiConnectionState variant to typed object + */ +export function convertWifiConnectionState(state: WifiConnectionState): WifiConnectionStateType { + if (state instanceof WifiConnectionStateVariantidle) return { type: 'idle' } + if (state instanceof WifiConnectionStateVariantconnecting) return { type: 'connecting' } + if (state instanceof WifiConnectionStateVariantconnected) return { type: 'connected' } + if (state instanceof WifiConnectionStateVariantfailed) return { type: 'failed', message: state.value } + return { type: 'idle' } +} + +/** + * Convert WifiState variant to typed object + */ +export function convertWifiState(state: WifiState): WifiStateType { + if (state instanceof WifiStateVariantunavailable) { + return { type: 'unavailable' } + } + if (state instanceof WifiStateVariantready) { + return { + type: 'ready', + interfaceName: state.interface_name, + status: { + state: convertWifiConnectionState(state.status.state), + ssid: state.status.ssid || null, + ipAddress: state.status.ipAddress || null, + }, + scanState: convertWifiScanState(state.scan_state), + scanResults: state.scan_results.map(n => ({ + ssid: n.ssid, + mac: n.mac, + channel: n.channel, + rssi: n.rssi, + })), + savedNetworks: state.saved_networks.map(n => ({ + ssid: n.ssid, + flags: n.flags, + })), + scanPollAttempt: state.scan_poll_attempt, + connectPollAttempt: state.connect_poll_attempt, + } + } + return { type: 'unavailable' } +} diff --git a/src/ui/src/composables/useCore.ts b/src/ui/src/composables/useCore.ts index 7871d13a..ae87dc14 100644 --- a/src/ui/src/composables/useCore.ts +++ b/src/ui/src/composables/useCore.ts @@ -23,6 +23,12 @@ export type { NetworkFormDataType, OverlaySpinnerStateType, FactoryResetStatusString, + WifiStateType, + WifiNetworkType, + WifiSavedNetworkType, + WifiConnectionStatusType, + WifiScanStateType, + WifiConnectionStateType, SystemInfo, NetworkStatus, OnlineStatus, diff --git a/src/ui/src/pages/SetPassword.vue b/src/ui/src/pages/SetPassword.vue index 8e0befca..a0b3ac99 100644 --- a/src/ui/src/pages/SetPassword.vue +++ b/src/ui/src/pages/SetPassword.vue @@ -6,6 +6,9 @@ import { useCore } from "../composables/useCore" import { useCoreInitialization } from "../composables/useCoreInitialization" import { usePasswordForm } from "../composables/usePasswordForm" import { useAuthNavigation } from "../composables/useAuthNavigation" +import { removeUser, login } from "../auth/auth-service" + +const PORTAL_AUTH_ERROR = "portal authentication required" const { viewModel, setPassword } = useCore() const { password, repeatPassword, errorMsg, validatePasswords } = usePasswordForm() @@ -23,6 +26,13 @@ watch( { flush: 'sync' } ) +watch(errorMsg, (msg) => { + if (msg === PORTAL_AUTH_ERROR) { + // Backend session lost — clear stale OIDC user and re-authenticate + removeUser().then(() => login()) + } +}) + const handleSubmit = async (): Promise => { if (!validatePasswords()) return await setPassword(password.value) diff --git a/src/ui/tests/auth.spec.ts b/src/ui/tests/auth.spec.ts index bf908dd4..fc57a4a9 100644 --- a/src/ui/tests/auth.spec.ts +++ b/src/ui/tests/auth.spec.ts @@ -146,6 +146,56 @@ test.describe('Authentication', () => { await redirectPromise; }); + test('re-triggers OIDC login when session expires during submission', async ({ page }) => { + // Simulate: OIDC user exists in localStorage (router guard passes) + // but backend rejects because portal_validated session flag is missing. + // The frontend should clear the stale OIDC user and redirect to Keycloak. + await mockPortalAuth(page); + await page.route('**/set-password', async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill({ + status: 401, + contentType: 'text/plain', + body: 'portal authentication required', + }); + } else { + await route.continue(); + } + }); + + // Mock OIDC discovery so signinRedirect() can proceed + await page.route('**/.well-known/openid-configuration', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + issuer: 'http://localhost:8080', + authorization_endpoint: 'http://localhost:8080/auth', + token_endpoint: 'http://localhost:8080/token', + jwks_uri: 'http://localhost:8080/certs', + response_types_supported: ['code'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], + }), + }); + }); + + await page.goto('/set-password'); + await expect(page.getByRole('heading', { name: /set password/i })).toBeVisible(); + + // Intercept the Keycloak redirect to verify it happens + const oidcRedirect = page.waitForURL(/localhost:8080/, { timeout: 5000 }).catch(() => null); + + await page.locator('input[type="password"]').nth(0).fill('new-password'); + await page.locator('input[type="password"]').nth(1).fill('new-password'); + await page.getByRole('button', { name: /set password/i }).click(); + + // Should redirect to Keycloak for re-authentication + await oidcRedirect; + // Page navigated away from the app — either to Keycloak or chrome-error (no real Keycloak) + await expect(page).not.toHaveURL(/localhost:5173/, { timeout: 5000 }); + }); + test('no auth errors on set-password page when WiFi is available', async ({ page }) => { await mockPortalAuth(page); await page.route('**/require-set-password', async (route) => { diff --git a/src/ui/tests/wifi.spec.ts b/src/ui/tests/wifi.spec.ts new file mode 100644 index 00000000..c7eb4e20 --- /dev/null +++ b/src/ui/tests/wifi.spec.ts @@ -0,0 +1,578 @@ +import { test, expect, Page } from '@playwright/test'; +import { mockConfig, mockLoginSuccess, mockRequireSetPassword } from './fixtures/mock-api'; +import { publishToCentrifugo } from './fixtures/centrifugo'; + +// Run all tests in this file serially to avoid state interference +test.describe.configure({ mode: 'serial' }); + +// --- Mock helpers --- + +async function mockWifiAvailable(page: Page, available = true, interfaceName = 'wlan0') { + await page.route('**/wifi/available', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ available, interfaceName: available ? interfaceName : null }), + }); + }); +} + +async function mockWifiStatus(page: Page, state = 'idle', ssid: string | null = null, ipAddress: string | null = null) { + await page.route('**/wifi/status', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'ok', state, ssid, ip_address: ipAddress }), + }); + } else { + await route.continue(); + } + }); +} + +async function mockWifiScanStart(page: Page) { + await page.route('**/wifi/scan', async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'ok', state: 'scanning' }), + }); + } else { + await route.continue(); + } + }); +} + +async function mockWifiScanResults(page: Page, state = 'finished', networks: any[] = []) { + await page.route('**/wifi/scan/results', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'ok', state, networks }), + }); + } else { + await route.continue(); + } + }); +} + +async function mockWifiConnect(page: Page, succeed = true) { + await page.route('**/wifi/connect', async (route) => { + if (route.request().method() === 'POST') { + if (succeed) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'ok', state: 'connecting' }), + }); + } else { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: 'Connection refused' }), + }); + } + } else { + await route.continue(); + } + }); +} + +async function mockWifiDisconnect(page: Page) { + await page.route('**/wifi/disconnect', async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'ok' }), + }); + } else { + await route.continue(); + } + }); +} + +async function mockWifiSavedNetworks(page: Page, networks: any[] = []) { + await page.route('**/wifi/networks', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'ok', networks }), + }); + } else { + await route.continue(); + } + }); +} + +async function mockWifiForget(page: Page) { + await page.route('**/wifi/networks/forget', async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'ok' }), + }); + } else { + await route.continue(); + } + }); +} + +async function mockHealthcheck(page: Page) { + await page.route('**/healthcheck', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + versionInfo: { current: '1.0.0', required: '1.0.0', mismatch: false }, + updateValidationStatus: { status: 'NoUpdate' }, + networkRollbackOccurred: false, + factoryResetResultAcked: true, + updateValidationAcked: true, + }), + }); + }); +} + +const defaultAdapters = [ + { + name: 'eth0', + mac: 'aa:bb:cc:dd:ee:f0', + online: true, + ipv4: { addrs: [{ addr: 'localhost', dhcp: true, prefix_len: 24 }], dns: ['8.8.8.8'], gateways: ['192.168.1.1'] }, + }, + { + name: 'wlan0', + mac: 'aa:bb:cc:dd:ee:f1', + online: false, + ipv4: { addrs: [{ addr: '192.168.1.50', dhcp: true, prefix_len: 24 }], dns: [], gateways: [] }, + }, +]; + +const scanNetworks = [ + { ssid: 'HomeNetwork', mac: 'aa:bb:cc:dd:ee:01', ch: 6, rssi: -45 }, + { ssid: 'OfficeWiFi', mac: 'aa:bb:cc:dd:ee:02', ch: 11, rssi: -65 }, + { ssid: 'GuestNet', mac: 'aa:bb:cc:dd:ee:03', ch: 1, rssi: -80 }, +]; + +const savedNetworks = [ + { ssid: 'HomeNetwork', flags: '[CURRENT]' }, + { ssid: 'OldNetwork', flags: '' }, +]; + +/** Set up all route mocks before navigation */ +async function setupMocks(page: Page, wifiAvailable = true) { + await mockConfig(page); + await mockLoginSuccess(page); + await mockRequireSetPassword(page); + await mockHealthcheck(page); + await mockWifiAvailable(page, wifiAvailable); + await mockWifiStatus(page, 'idle'); + await mockWifiSavedNetworks(page, []); +} + +/** Login, publish network data via Centrifugo, navigate to Network page */ +async function loginAndNavigate(page: Page) { + await page.goto('/'); + await page.getByPlaceholder(/enter your password/i).fill('password'); + await page.getByRole('button', { name: /log in/i }).click(); + await expect(page.getByText('Common Info')).toBeVisible({ timeout: 10000 }); + + // Publish network adapter data via Centrifugo (after login so WebSocket is subscribed) + await publishToCentrifugo('NetworkStatusV1', { network_status: defaultAdapters }); + + // Navigate to network page + await page.getByRole('link', { name: /network/i }).click(); + await expect(page.locator('.text-h4', { hasText: 'Network' })).toBeVisible({ timeout: 5000 }); + + // Wait for adapter tabs to appear from Centrifugo data + await expect(page.getByRole('tab', { name: /eth0/i })).toBeVisible({ timeout: 10000 }); +} + +// Static-IP adapters for tests that interact with the network config form +const staticAdapters = [ + { + name: 'eth0', + mac: 'aa:bb:cc:dd:ee:f0', + online: true, + ipv4: { addrs: [{ addr: '192.168.1.100', dhcp: false, prefix_len: 24 }], dns: ['8.8.8.8'], gateways: ['192.168.1.1'] }, + }, + { + name: 'wlan0', + mac: 'aa:bb:cc:dd:ee:f1', + online: true, + ipv4: { addrs: [{ addr: '192.168.1.50', dhcp: false, prefix_len: 24 }], dns: ['8.8.8.8'], gateways: ['192.168.1.1'] }, + }, +]; + +// Adapter state after WiFi connects and the OS assigns a new IP to wlan0 +const wlan0AfterWifiAdapters = [ + { + name: 'eth0', + mac: 'aa:bb:cc:dd:ee:f0', + online: true, + ipv4: { addrs: [{ addr: '192.168.1.100', dhcp: false, prefix_len: 24 }], dns: ['8.8.8.8'], gateways: ['192.168.1.1'] }, + }, + { + name: 'wlan0', + mac: 'aa:bb:cc:dd:ee:f1', + online: true, + ipv4: { addrs: [{ addr: '192.168.100.50', dhcp: false, prefix_len: 24 }], dns: ['10.0.0.1'], gateways: ['192.168.100.1'] }, + }, +]; + +async function mockNetworkConfigSuccess(page: Page) { + await page.route('**/network', async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ rollbackTimeoutSeconds: 90, uiPort: 5173, rollbackEnabled: false }), + }); + } else { + await route.continue(); + } + }); +} + +/** Login, publish custom adapters, navigate to Network page */ +async function loginAndNavigateWith(page: Page, adapters: any[]) { + await page.goto('/'); + await page.getByPlaceholder(/enter your password/i).fill('password'); + await page.getByRole('button', { name: /log in/i }).click(); + await expect(page.getByText('Common Info')).toBeVisible({ timeout: 10000 }); + + await publishToCentrifugo('NetworkStatusV1', { network_status: adapters }); + + await page.getByRole('link', { name: /network/i }).click(); + await expect(page.locator('.text-h4', { hasText: 'Network' })).toBeVisible({ timeout: 5000 }); + await expect(page.getByRole('tab', { name: /eth0/i })).toBeVisible({ timeout: 10000 }); +} + +test.describe('WiFi Management', () => { + test('WiFi unavailable - no WiFi panel shown', async ({ page }) => { + await setupMocks(page, false); + + await page.goto('/'); + await page.getByPlaceholder(/enter your password/i).fill('password'); + await page.getByRole('button', { name: /log in/i }).click(); + await expect(page.getByText('Common Info')).toBeVisible({ timeout: 10000 }); + + await publishToCentrifugo('NetworkStatusV1', { network_status: defaultAdapters }); + + await page.getByRole('link', { name: /network/i }).click(); + await expect(page.getByRole('tab', { name: /wlan0/i })).toBeVisible({ timeout: 10000 }); + + // Click wlan0 tab + await page.getByRole('tab', { name: /wlan0/i }).click(); + + // WiFi panel should NOT be visible + await expect(page.getByText('WiFi Connection')).not.toBeVisible(); + }); + + test('WiFi panel visible on WiFi adapter tab only', async ({ page }) => { + await setupMocks(page); + await loginAndNavigate(page); + + // eth0 tab should not show WiFi panel + await page.getByRole('tab', { name: /eth0/i }).click(); + await expect(page.getByText('WiFi Connection')).not.toBeVisible(); + + // wlan0 tab should show WiFi panel + await page.getByRole('tab', { name: /wlan0/i }).click(); + await expect(page.getByText('WiFi Connection')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Available Networks')).toBeVisible(); + await expect(page.getByText('Saved Networks', { exact: true })).toBeVisible(); + }); + + test('WiFi icon shown on WiFi adapter tab', async ({ page }) => { + await setupMocks(page); + await loginAndNavigate(page); + + // wlan0 tab should have WiFi icon + const wlan0Tab = page.getByRole('tab', { name: /wlan0/i }); + await expect(wlan0Tab.locator('.mdi-wifi')).toBeVisible(); + + // eth0 tab should NOT have WiFi icon + const eth0Tab = page.getByRole('tab', { name: /eth0/i }); + await expect(eth0Tab.locator('.mdi-wifi')).not.toBeVisible(); + }); + + test('scan flow - discovers networks', async ({ page }) => { + await setupMocks(page); + await mockWifiScanStart(page); + await mockWifiScanResults(page, 'finished', scanNetworks); + await loginAndNavigate(page); + + await page.getByRole('tab', { name: /wlan0/i }).click(); + await expect(page.getByText('WiFi Connection')).toBeVisible(); + + // Click scan button + await page.locator('[data-cy=wifi-scan-button]').click(); + + // Networks should appear (after polling) + await expect(page.locator('[data-cy="wifi-network-HomeNetwork"]')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('[data-cy="wifi-network-OfficeWiFi"]')).toBeVisible(); + await expect(page.locator('[data-cy="wifi-network-GuestNet"]')).toBeVisible(); + }); + + test('connect flow - password dialog and connection', async ({ page }) => { + await setupMocks(page); + await mockWifiScanStart(page); + await mockWifiScanResults(page, 'finished', scanNetworks); + await mockWifiConnect(page); + await loginAndNavigate(page); + + await page.getByRole('tab', { name: /wlan0/i }).click(); + + // Scan first + await page.locator('[data-cy=wifi-scan-button]').click(); + await expect(page.locator('[data-cy="wifi-network-HomeNetwork"]')).toBeVisible({ timeout: 10000 }); + + // Click on a network to connect + await page.locator('[data-cy="wifi-network-HomeNetwork"]').click(); + + // Password dialog should open + await expect(page.getByText('Connect to HomeNetwork')).toBeVisible(); + await page.locator('[data-cy=wifi-password-input] input').fill('mypassword'); + + // Mock status to return connected after connect + await page.unroute('**/wifi/status'); + await mockWifiStatus(page, 'connected', 'HomeNetwork', '192.168.1.100'); + + await page.locator('[data-cy=wifi-connect-button]').click(); + + // Should show SSID and disconnect button + await expect(page.locator('[data-cy=wifi-disconnect-button]')).toBeVisible({ timeout: 15000 }); + }); + + test('disconnect flow', async ({ page }) => { + // Start with connected status + await setupMocks(page); + await page.unroute('**/wifi/status'); + await mockWifiStatus(page, 'connected', 'HomeNetwork', '192.168.1.100'); + await mockWifiDisconnect(page); + await loginAndNavigate(page); + + await page.getByRole('tab', { name: /wlan0/i }).click(); + + // Should show SSID and disconnect button + await expect(page.getByText('HomeNetwork')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('[data-cy=wifi-disconnect-button]')).toBeVisible(); + + // Mock status to return idle after disconnect + await page.unroute('**/wifi/status'); + await mockWifiStatus(page, 'idle'); + + await page.locator('[data-cy=wifi-disconnect-button]').click(); + + // Should show not connected + await expect(page.getByText('Not connected')).toBeVisible({ timeout: 10000 }); + }); + + test('saved networks and forget', async ({ page }) => { + await setupMocks(page); + await page.unroute('**/wifi/networks'); + await mockWifiSavedNetworks(page, savedNetworks); + await mockWifiForget(page); + await loginAndNavigate(page); + + await page.getByRole('tab', { name: /wlan0/i }).click(); + + // Saved networks should be visible + await expect(page.locator('[data-cy="wifi-saved-HomeNetwork"]')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('[data-cy="wifi-saved-OldNetwork"]')).toBeVisible(); + + // Click forget on OldNetwork + await page.locator('[data-cy="wifi-forget-OldNetwork"]').click(); + + // Confirmation dialog + await expect(page.getByText('Forget Network')).toBeVisible(); + await expect(page.getByRole('strong')).toHaveText('OldNetwork'); + + // After confirming, mock returns only HomeNetwork + await page.unroute('**/wifi/networks'); + await mockWifiSavedNetworks(page, [{ ssid: 'HomeNetwork', flags: '[CURRENT]' }]); + + await page.locator('[data-cy=wifi-forget-confirm-button]').click(); + + // OldNetwork should disappear + await expect(page.locator('[data-cy="wifi-saved-OldNetwork"]')).not.toBeVisible({ timeout: 10000 }); + await expect(page.locator('[data-cy="wifi-saved-HomeNetwork"]')).toBeVisible(); + }); +}); + +test.describe('WiFi and Network Config Interaction', () => { + // Both v-window-items are rendered in the DOM at all times. All helpers below scope + // their selectors to the currently active tab panel to avoid strict-mode violations. + const activePanel = (page: Page) => page.locator('.v-window-item--active'); + const activeIpField = (page: Page) => + activePanel(page).getByRole('textbox', { name: /IP Address/i }); + const activeApplyButton = (page: Page) => + activePanel(page).locator('[data-cy=network-apply-button]'); + const activeDiscardButton = (page: Page) => + activePanel(page).locator('[data-cy=network-discard-button]'); + + test('network config form syncs with new IP after WiFi assigns one (clean form)', async ({ page }) => { + // This test exposes a bug: after a NetworkStatusV1 update (simulating WiFi assigning a new + // IP to wlan0), the network config form should show the new IP, not the stale one. + await setupMocks(page); + await loginAndNavigateWith(page, staticAdapters); + + await page.getByRole('tab', { name: /wlan0/i }).click(); + await expect(page.getByText('WiFi Connection')).toBeVisible({ timeout: 10000 }); + + // Verify initial IP in form + await expect(activeIpField(page)).toHaveValue('192.168.1.50', { timeout: 5000 }); + await expect(activeApplyButton(page)).toBeDisabled(); + + // Simulate WiFi connecting and the OS assigning a new IP to wlan0 + await publishToCentrifugo('NetworkStatusV1', { network_status: wlan0AfterWifiAdapters }); + + // Form must sync to the WiFi-assigned IP and remain clean + await expect(activeIpField(page)).toHaveValue('192.168.100.50', { timeout: 5000 }); + await expect(activeApplyButton(page)).toBeDisabled(); + }); + + test('network config form preserves user edits when WiFi assigns a new IP (dirty form)', async ({ page }) => { + await setupMocks(page); + await loginAndNavigateWith(page, staticAdapters); + + await page.getByRole('tab', { name: /wlan0/i }).click(); + await expect(page.getByText('WiFi Connection')).toBeVisible({ timeout: 10000 }); + + // User edits the IP → form becomes dirty + await activeIpField(page).fill('192.168.1.99'); + await expect(activeApplyButton(page)).toBeEnabled({ timeout: 5000 }); + + // Simulate WiFi assigning a different IP to wlan0 + await publishToCentrifugo('NetworkStatusV1', { network_status: wlan0AfterWifiAdapters }); + + // Dirty flag must protect the user's edit — form must NOT be overwritten + await expect(activeIpField(page)).toHaveValue('192.168.1.99'); + await expect(activeApplyButton(page)).toBeEnabled(); + }); + + test('WiFi scan does not reset or modify network config form state', async ({ page }) => { + await setupMocks(page); + await mockWifiScanStart(page); + await mockWifiScanResults(page, 'finished', scanNetworks); + await loginAndNavigateWith(page, staticAdapters); + + await page.getByRole('tab', { name: /wlan0/i }).click(); + await expect(page.getByText('WiFi Connection')).toBeVisible({ timeout: 10000 }); + + // User edits the IP → form becomes dirty + await activeIpField(page).fill('192.168.1.99'); + await expect(activeApplyButton(page)).toBeEnabled({ timeout: 5000 }); + + // Trigger WiFi scan + await page.locator('[data-cy=wifi-scan-button]').click(); + await expect(page.locator('[data-cy="wifi-network-HomeNetwork"]')).toBeVisible({ timeout: 10000 }); + + // Network config form must be unchanged + await expect(activeIpField(page)).toHaveValue('192.168.1.99'); + await expect(activeApplyButton(page)).toBeEnabled(); + }); + + test('discard after WiFi assigns new IP resets form to WiFi-assigned IP', async ({ page }) => { + await setupMocks(page); + await loginAndNavigateWith(page, staticAdapters); + + await page.getByRole('tab', { name: /wlan0/i }).click(); + await expect(page.getByText('WiFi Connection')).toBeVisible({ timeout: 10000 }); + + // User edits the IP → form becomes dirty + await activeIpField(page).fill('192.168.1.99'); + await expect(activeApplyButton(page)).toBeEnabled({ timeout: 5000 }); + + // Simulate WiFi assigning a new IP to wlan0 while user has unsaved edits + await publishToCentrifugo('NetworkStatusV1', { network_status: wlan0AfterWifiAdapters }); + + // Discard changes — must reload from the current network_status, not the original + await activeDiscardButton(page).click(); + + // Form must show the WiFi-assigned IP (192.168.100.50), not the user edit or the original + await expect(activeIpField(page)).toHaveValue('192.168.100.50', { timeout: 5000 }); + await expect(activeApplyButton(page)).toBeDisabled(); + }); + + test('network config apply succeeds while WiFi scan is in progress', async ({ page }) => { + await setupMocks(page); + await mockWifiScanStart(page); + // Scan stays in scanning state indefinitely + await page.route('**/wifi/scan/results', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'ok', state: 'scanning', networks: [] }), + }); + } else { + await route.continue(); + } + }); + await mockNetworkConfigSuccess(page); + await loginAndNavigateWith(page, staticAdapters); + + // Start WiFi scan on wlan0 tab + await page.getByRole('tab', { name: /wlan0/i }).click(); + await expect(page.getByText('WiFi Connection')).toBeVisible({ timeout: 10000 }); + await page.locator('[data-cy=wifi-scan-button]').click(); + // Scan spinner should be visible (scan in progress) + await expect(page.locator('[data-cy=wifi-scan-button]')).toBeVisible(); + + // Switch to eth0 tab (no network form unsaved changes) and edit config + await page.getByRole('tab', { name: /eth0/i }).click(); + // Wait for networkFormStartEdit('eth0') to be processed before editing + await expect(activeApplyButton(page)).toBeDisabled({ timeout: 5000 }); + await activeIpField(page).fill('192.168.1.200'); + await expect(activeApplyButton(page)).toBeEnabled({ timeout: 5000 }); + + // Apply eth0 config while WiFi scan is still in progress + await activeApplyButton(page).click(); + + // Config should apply independently of WiFi scan state + await expect(activeApplyButton(page)).toBeDisabled({ timeout: 10000 }); + }); + + test('network config apply succeeds while WiFi is connecting', async ({ page }) => { + await setupMocks(page); + await mockWifiScanStart(page); + await mockWifiScanResults(page, 'finished', scanNetworks); + await mockWifiConnect(page); + // Status stays at 'connecting' indefinitely so WiFi never completes + await mockWifiStatus(page, 'connecting', 'HomeNetwork', null); + await mockNetworkConfigSuccess(page); + await loginAndNavigateWith(page, staticAdapters); + + // Start connecting to a WiFi AP on wlan0 tab + await page.getByRole('tab', { name: /wlan0/i }).click(); + await expect(page.getByText('WiFi Connection')).toBeVisible({ timeout: 10000 }); + await page.locator('[data-cy=wifi-scan-button]').click(); + await expect(page.locator('[data-cy="wifi-network-HomeNetwork"]')).toBeVisible({ timeout: 10000 }); + await page.locator('[data-cy="wifi-network-HomeNetwork"]').click(); + await page.locator('[data-cy=wifi-password-input] input').fill('mypassword'); + await page.locator('[data-cy=wifi-connect-button]').click(); + // WiFi is now in Connecting state + await expect(page.getByText('Connecting to HomeNetwork...')).toBeVisible({ timeout: 5000 }); + + // Switch to eth0 tab and edit config + await page.getByRole('tab', { name: /eth0/i }).click(); + // Wait for networkFormStartEdit('eth0') to be processed before editing + await expect(activeApplyButton(page)).toBeDisabled({ timeout: 5000 }); + await activeIpField(page).fill('192.168.1.200'); + await expect(activeApplyButton(page)).toBeEnabled({ timeout: 5000 }); + + // Apply eth0 config while WiFi connection is still in progress + await activeApplyButton(page).click(); + + // Config should apply independently of WiFi connecting state + await expect(activeApplyButton(page)).toBeDisabled({ timeout: 10000 }); + }); +}); From 531374dc039fc9bd563ea769315c3d6f7d37c01b Mon Sep 17 00:00:00 2001 From: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> Date: Fri, 27 Feb 2026 20:53:44 +0100 Subject: [PATCH 2/3] refactor(ui): consolidate healthcheck state and modernize e2e test infrastructure - **Model & Synchronization:** Moved and from the persistent state to ephemeral . This ensures that these one-time notification flags do not persist across device reboots or accidental state synchronization cycles, preventing redundant modal popups. - **Core Logic:** Implemented in the Crux Core to decouple the initial application hydration from subsequent polling cycles. This allows for a deterministic startup sequence where the UI can react to the device state immediately after WASM initialization. - **Test Infrastructure:** - Refactored and to utilize for modal suppression, replacing the fragile injection pattern. - Introduced and in the harness to reduce boilerplate and improve reliability of complex network redirection tests. - Standardized healthcheck mocking across all E2E tests to ensure consistent behavior during version-mismatch and rollback scenarios. - **Backend & Utilities:** Added to handle cases where the backend returns valid JSON alongside non-2xx status codes (e.g., 503 during version mismatch). Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> --- project-context.md | 6 + src/app/src/events.rs | 1 + src/app/src/http_helpers.rs | 57 ++++ src/app/src/macros.rs | 3 +- src/app/src/model.rs | 4 + src/app/src/update/device/mod.rs | 56 ++-- src/app/src/update/device/reconnection.rs | 127 +++++-- src/app/src/update/websocket.rs | 62 ++-- .../src/services/auth/authorization.rs | 317 ++++++------------ src/backend/src/services/auth/password.rs | 49 +-- src/ui/src/App.vue | 85 ++--- src/ui/src/auth/validate-portal-token.ts | 31 ++ src/ui/src/composables/core/index.ts | 6 + src/ui/src/composables/core/state.ts | 3 + src/ui/src/composables/core/sync.ts | 99 +++--- src/ui/src/composables/core/types.ts | 4 + src/ui/src/pages/Callback.vue | 27 +- src/ui/src/plugins/router.ts | 24 +- src/ui/tests/factory-reset.spec.ts | 7 +- src/ui/tests/fixtures/network-test-harness.ts | 134 ++++++-- src/ui/tests/fixtures/test-setup.ts | 54 ++- src/ui/tests/network-configuration.spec.ts | 78 +---- src/ui/tests/network-multi-adapter.spec.ts | 112 +++---- src/ui/tests/wifi.spec.ts | 63 +--- 24 files changed, 703 insertions(+), 706 deletions(-) create mode 100644 src/ui/src/auth/validate-portal-token.ts diff --git a/project-context.md b/project-context.md index 4a468f0a..6271d5fb 100644 --- a/project-context.md +++ b/project-context.md @@ -155,6 +155,7 @@ omnect-ui/ │ │ ├── wasm.rs # WASM FFI bindings │ │ ├── macros.rs # URL and log macros │ │ ├── http_helpers.rs # HTTP request utilities +│ │ ├── wifi_psk.rs # WiFi PSK utilities │ │ ├── commands/ # Custom side-effect commands │ │ │ ├── mod.rs │ │ │ └── centrifugo.rs # Centrifugo WebSocket commands @@ -164,6 +165,7 @@ omnect-ui/ │ │ │ ├── common.rs # Common shared types │ │ │ ├── device.rs # Device information types │ │ │ ├── network.rs # Network configuration types +│ │ │ ├── wifi.rs # WiFi types │ │ │ ├── ods.rs # ODS-specific DTOs │ │ │ ├── factory_reset.rs │ │ │ └── update.rs # Update validation types @@ -171,6 +173,7 @@ omnect-ui/ │ │ ├── mod.rs # Main dispatcher │ │ ├── auth.rs # Auth event handlers │ │ ├── ui.rs # UI state handlers +│ │ ├── wifi.rs # WiFi event handlers │ │ ├── websocket.rs # WebSocket state handlers │ │ └── device/ # Device domain handlers │ │ ├── mod.rs @@ -187,11 +190,13 @@ omnect-ui/ │ │ │ ├── http_client.rs # Internal HTTP client │ │ │ ├── keycloak_client.rs │ │ │ ├── omnect_device_service_client.rs +│ │ │ ├── wifi_commissioning_client.rs │ │ │ └── services/ # Business logic services │ │ │ ├── mod.rs │ │ │ ├── certificate.rs │ │ │ ├── firmware.rs │ │ │ ├── network.rs +│ │ │ ├── marker.rs │ │ │ └── auth/ # Auth logic │ │ │ ├── mod.rs │ │ │ ├── authorization.rs # JWT/SSO validation @@ -242,6 +247,7 @@ omnect-ui/ │ ├── smoke.spec.ts │ ├── update.spec.ts │ ├── version-mismatch.spec.ts +│ ├── wifi.spec.ts │ └── fixtures/ └── project-context.md # This file ``` diff --git a/src/app/src/events.rs b/src/app/src/events.rs index bb58072a..6a03586d 100644 --- a/src/app/src/events.rs +++ b/src/app/src/events.rs @@ -60,6 +60,7 @@ pub enum DeviceEvent { RunUpdate { validate_iothub_connection: bool, }, + FetchInitialHealthcheck, ReconnectionCheckTick, ReconnectionTimeout, NewIpCheckTick, diff --git a/src/app/src/http_helpers.rs b/src/app/src/http_helpers.rs index c25dcc34..d7aad44f 100644 --- a/src/app/src/http_helpers.rs +++ b/src/app/src/http_helpers.rs @@ -62,6 +62,23 @@ pub fn extract_error_message(action: &str, response: &mut Response>) -> } } +/// Parse JSON from response body regardless of HTTP status. +/// +/// Unlike `parse_json_response`, this does not check for 2xx status. +/// Used for endpoints like healthcheck where the body is valid JSON +/// even on error status codes (e.g., 503 for version mismatch). +pub fn parse_json_response_any_status( + action: &str, + response: &mut Response>, +) -> Result { + match response.take_body() { + Some(body) => { + serde_json::from_slice(&body).map_err(|e| format!("{action}: JSON parse error: {e}")) + } + None => Err(format!("{action}: Empty response body")), + } +} + /// Parse JSON from response body. /// /// Returns error if response is not successful or JSON parsing fails. @@ -178,9 +195,49 @@ where #[cfg(test)] mod tests { use super::*; + use crux_http::http::StatusCode; + use crux_http::testing::ResponseBuilder; + + fn make_response(status: StatusCode, body: &[u8]) -> Response> { + ResponseBuilder::with_status(status) + .body(body.to_vec()) + .build() + } #[test] fn test_build_url() { assert_eq!(build_url("/test"), "https://relative/test"); } + + #[test] + fn parse_json_response_any_status_parses_on_503() { + #[derive(serde::Deserialize, PartialEq, Debug)] + struct Info { + ok: bool, + } + + let mut response = make_response(StatusCode::ServiceUnavailable, b"{\"ok\":false}"); + let result: Result = parse_json_response_any_status("test", &mut response); + assert_eq!(result.unwrap(), Info { ok: false }); + } + + #[test] + fn parse_json_response_any_status_parses_on_200() { + #[derive(serde::Deserialize, PartialEq, Debug)] + struct Info { + value: u32, + } + + let mut response = make_response(StatusCode::Ok, b"{\"value\":42}"); + let result: Result = parse_json_response_any_status("test", &mut response); + assert_eq!(result.unwrap(), Info { value: 42 }); + } + + #[test] + fn parse_json_response_any_status_returns_error_on_invalid_json() { + let mut response = make_response(StatusCode::Ok, b"not json"); + let result: Result = parse_json_response_any_status("test", &mut response); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("JSON parse error")); + } } diff --git a/src/app/src/macros.rs b/src/app/src/macros.rs index bdb2eea0..435d9a03 100644 --- a/src/app/src/macros.rs +++ b/src/app/src/macros.rs @@ -44,7 +44,8 @@ macro_rules! update_field { pub use crate::http_helpers::{ build_url, check_response_status, extract_error_message, extract_string_response, handle_auth_error, handle_request_error, is_response_success, map_http_error, - parse_json_response, process_json_response, process_status_response, BASE_URL, + parse_json_response, parse_json_response_any_status, process_json_response, + process_status_response, BASE_URL, }; /// Macro for unauthenticated POST requests with standard error handling. diff --git a/src/app/src/model.rs b/src/app/src/model.rs index 55d613d4..eeabbd33 100644 --- a/src/app/src/model.rs +++ b/src/app/src/model.rs @@ -62,6 +62,10 @@ pub struct Model { pub should_show_rollback_modal: bool, pub default_rollback_enabled: bool, + // Version mismatch state (derived from healthcheck) + pub version_mismatch: bool, + pub version_mismatch_message: Option, + // Firmware upload state pub firmware_upload_state: UploadState, diff --git a/src/app/src/update/device/mod.rs b/src/app/src/update/device/mod.rs index 52b43e40..0eb65823 100644 --- a/src/app/src/update/device/mod.rs +++ b/src/app/src/update/device/mod.rs @@ -116,13 +116,7 @@ pub fn handle(event: DeviceEvent, model: &mut Model) -> Command { handle_set_network_config_response(result, model) } - DeviceEvent::AckRollbackResponse(result) => { - model.stop_loading(); - if let Err(e) = result { - model.set_error(e); - } - crux_core::render::render() - } + DeviceEvent::AckRollbackResponse(result) => handle_ack_response(result, model), DeviceEvent::LoadUpdate { file_path } => { let request = LoadUpdateRequest { file_path }; @@ -161,6 +155,8 @@ pub fn handle(event: DeviceEvent, model: &mut Model) -> Command { Some("The device is restarting with the updated firmware.".to_string()), ), + DeviceEvent::FetchInitialHealthcheck => build_initial_healthcheck_cmd(), + DeviceEvent::HealthcheckResponse(result) => handle_healthcheck_response(result, model), // Device reconnection events (reboot/factory reset/update) @@ -178,21 +174,9 @@ pub fn handle(event: DeviceEvent, model: &mut Model) -> Command { DeviceEvent::AckFactoryResetResult => handle_ack_factory_reset_result(model), DeviceEvent::AckUpdateValidation => handle_ack_update_validation(model), - DeviceEvent::AckFactoryResetResultResponse(result) => { - model.stop_loading(); - if let Err(e) = result { - model.set_error(e); - } - crux_core::render::render() - } + DeviceEvent::AckFactoryResetResultResponse(result) => handle_ack_response(result, model), - DeviceEvent::AckUpdateValidationResponse(result) => { - model.stop_loading(); - if let Err(e) = result { - model.set_error(e); - } - crux_core::render::render() - } + DeviceEvent::AckUpdateValidationResponse(result) => handle_ack_response(result, model), // Network form events DeviceEvent::NetworkFormStartEdit { adapter_name } => { @@ -207,6 +191,36 @@ pub fn handle(event: DeviceEvent, model: &mut Model) -> Command { } } +fn handle_ack_response(result: Result<(), String>, model: &mut Model) -> Command { + model.stop_loading(); + if let Err(e) = result { + model.set_error(e); + } + crux_core::render::render() +} + +fn build_initial_healthcheck_cmd() -> Command { + // crux_http converts 4xx/5xx to HttpError::Http but preserves the body. + // The backend returns 503 on version mismatch with a valid JSON body, + // so we must parse the body from both success and error paths. + crate::HttpCmd::get(crate::http_helpers::build_url("/healthcheck")) + .build() + .then_send(|result| { + let event_result = match result { + Ok(mut response) => crate::http_helpers::parse_json_response_any_status( + "Healthcheck", + &mut response, + ), + Err(crux_http::HttpError::Http { + body: Some(body), .. + }) => serde_json::from_slice(&body) + .map_err(|e| format!("Healthcheck: JSON parse error: {e}")), + Err(e) => Err(e.to_string()), + }; + Event::Device(DeviceEvent::HealthcheckResponse(event_result)) + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/app/src/update/device/reconnection.rs b/src/app/src/update/device/reconnection.rs index 622125d2..ff70ca24 100644 --- a/src/app/src/update/device/reconnection.rs +++ b/src/app/src/update/device/reconnection.rs @@ -71,21 +71,39 @@ pub fn handle_healthcheck_response( result: Result, model: &mut Model, ) -> Command { - // Update healthcheck info if success if let Ok(info) = &result { model.healthcheck = Some(info.clone()); + update_version_info(info, model); } + advance_device_operation_state(&result, model); + advance_network_change_state(&result, model); + crux_core::render::render() +} - // Handle reconnection state machine +fn update_version_info(info: &crate::types::HealthcheckInfo, model: &mut Model) { + model.version_mismatch = info.version_info.mismatch; + model.version_mismatch_message = if info.version_info.mismatch { + Some(format!( + "Current version: {}. Required version {}. Please consider to update omnect Secure OS.", + info.version_info.current, info.version_info.required, + )) + } else { + None + }; +} + +fn advance_device_operation_state( + result: &Result, + model: &mut Model, +) { match &model.device_operation_state { DeviceOperationState::Rebooting | DeviceOperationState::FactoryResetting | DeviceOperationState::Updating => { - // First check - if it fails, mark as "waiting" let is_updating = matches!(model.device_operation_state, DeviceOperationState::Updating); - // For updates, we also check the status field + // For updates, completion requires a terminal status field (not just reachability) let update_done = if is_updating { result.as_ref().ok().is_some_and(is_update_complete) } else { @@ -93,24 +111,18 @@ pub fn handle_healthcheck_response( }; if result.is_err() { - // Device went offline - mark it model.device_went_offline = true; - // Transition to waiting let operation = model.device_operation_state.operation_name(); model.device_operation_state = DeviceOperationState::WaitingReconnection { operation, attempt: model.reconnection_attempt, }; } else if (update_done || !is_updating) && model.device_went_offline { - // Device came back online after going offline - reconnection successful let operation = model.device_operation_state.operation_name(); model.device_operation_state = DeviceOperationState::ReconnectionSuccessful { operation }; - // Invalidate session as backend restart clears tokens model.invalidate_session(); - - // Clear overlay spinner model.overlay_spinner.clear(); // Clear stale firmware page state so the update page is fresh on re-login. @@ -124,21 +136,19 @@ pub fn handle_healthcheck_response( } } } - // else: healthcheck succeeded but device never went offline - keep checking + // else: device never went offline - keep checking } DeviceOperationState::WaitingReconnection { operation, .. } => { let is_update = operation == "Update"; if result.is_err() { - // Still offline - mark it model.device_went_offline = true; - // Update attempt count model.device_operation_state = DeviceOperationState::WaitingReconnection { operation: operation.clone(), attempt: model.reconnection_attempt, }; } else { - // Consider update done when status is Succeeded, Recovered, or NoUpdate + // For updates, completion requires a terminal status field let update_done = if is_update { result.as_ref().ok().is_some_and(is_update_complete) } else { @@ -146,15 +156,11 @@ pub fn handle_healthcheck_response( }; if update_done && model.device_went_offline { - // Success! Device is back online (or update finished) AND it went offline model.device_operation_state = DeviceOperationState::ReconnectionSuccessful { operation: operation.clone(), }; - // Invalidate session as backend restart clears tokens model.invalidate_session(); - - // Clear overlay spinner model.overlay_spinner.clear(); // Clear stale firmware page state so the update page is fresh on re-login. @@ -168,13 +174,17 @@ pub fn handle_healthcheck_response( } } } - // else: healthcheck succeeded but device never went offline - keep checking + // else: device never went offline - keep checking } } - _ => {} // Do nothing for other states + _ => {} } +} - // Handle network change state machine for IP change polling +fn advance_network_change_state( + result: &Result, + model: &mut Model, +) { match &model.network_change_state { NetworkChangeState::WaitingForNewIp { new_ip, ui_port, .. @@ -183,36 +193,30 @@ pub fn handle_healthcheck_response( // Clone values before reassigning state to avoid borrow conflict let new_ip = new_ip.clone(); let port = *ui_port; - // New IP is reachable model.network_change_state = NetworkChangeState::NewIpReachable { new_ip: new_ip.clone(), ui_port: port, }; - // Clear any leftover messages model.success_message = None; model.error_message = None; - // Update overlay for redirect model.overlay_spinner = OverlaySpinnerState::new("Network settings applied") .with_text(format!("Redirecting to new IP: {new_ip}:{port}")); } } NetworkChangeState::WaitingForOldIp { .. } => { if result.is_ok() { - // Old IP is reachable - Rollback successful + // Old IP is reachable — rollback successful. + // No success message here: the "Network Settings Rolled Back" modal + // is triggered by the `network_rollback_occurred` flag in the healthcheck response. model.network_change_state = NetworkChangeState::Idle; model.overlay_spinner.clear(); model.invalidate_session(); - // Clear any leftover messages model.success_message = None; model.error_message = None; - // Do not show success message here. The "Network Settings Rolled Back" modal - // will be triggered by the `network_rollback_occurred` flag in the healthcheck response. } } _ => {} } - - crux_core::render::render() } #[cfg(test)] @@ -646,7 +650,10 @@ mod tests { ); assert_eq!(model.update_manifest, Some(manifest)); - assert_eq!(model.firmware_upload_state, crate::types::UploadState::Completed); + assert_eq!( + model.firmware_upload_state, + crate::types::UploadState::Completed + ); } } @@ -810,7 +817,10 @@ mod tests { ); assert_eq!(model.update_manifest, Some(manifest)); - assert_eq!(model.firmware_upload_state, crate::types::UploadState::Completed); + assert_eq!( + model.firmware_upload_state, + crate::types::UploadState::Completed + ); } } @@ -870,5 +880,58 @@ mod tests { )); } } + + mod derived_state { + use super::*; + + #[test] + fn version_mismatch_sets_model_fields() { + let mut model = Model::default(); + let info = HealthcheckInfo { + version_info: VersionInfo { + required: ">=1.0.0".to_string(), + current: "0.9.0".to_string(), + mismatch: true, + }, + ..Default::default() + }; + + let _ = handle_healthcheck_response(Ok(info), &mut model); + + assert!(model.version_mismatch); + let msg = model.version_mismatch_message.unwrap(); + assert!(msg.contains("0.9.0")); + assert!(msg.contains(">=1.0.0")); + } + + #[test] + fn no_version_mismatch_clears_fields() { + let mut model = Model { + version_mismatch: true, + version_mismatch_message: Some("old".to_string()), + ..Default::default() + }; + + let _ = + handle_healthcheck_response(Ok(create_healthcheck("valid", false)), &mut model); + + assert!(!model.version_mismatch); + assert!(model.version_mismatch_message.is_none()); + } + + #[test] + fn error_response_does_not_change_derived_state() { + let mut model = Model { + version_mismatch: true, + ..Default::default() + }; + + let _ = + handle_healthcheck_response(Err("Connection failed".to_string()), &mut model); + + // Derived state unchanged on error + assert!(model.version_mismatch); + } + } } } diff --git a/src/app/src/update/websocket.rs b/src/app/src/update/websocket.rs index 2756c405..649bd960 100644 --- a/src/app/src/update/websocket.rs +++ b/src/app/src/update/websocket.rs @@ -45,37 +45,7 @@ pub fn handle(event: WebSocketEvent, model: &mut Model) -> Command Command Result<()> { let Some(tenant_list) = &claims.tenant_list else { bail!("failed to authorize user: no tenant list in token"); }; ensure!( - tenant_list.contains(tenant), + tenant_list.contains(&tenant.to_string()), "failed to authorize user: insufficient permissions for tenant" ); + Ok(()) + } - // Validate role-based authorization + async fn validate_role( + claims: &TokenClaims, + service_client: &ServiceClient, + ) -> Result<()> { let Some(roles) = &claims.roles else { bail!("failed to authorize user: no roles in token"); }; - // FleetAdministrator has full access if roles.iter().any(|r| r == "FleetAdministrator") { return Ok(()); } - // FleetOperator requires fleet validation if roles.iter().any(|r| r == "FleetOperator") { let Some(fleet_list) = &claims.fleet_list else { bail!("failed to authorize user: no fleet list in token"); @@ -104,57 +111,56 @@ mod tests { } } + async fn run_auth(claims: TokenClaims) -> anyhow::Result<()> { + let mut sso_mock = SingleSignOnProvider::default(); + sso_mock.expect_verify_token().returning(move |_| { + let c = claims.clone(); + Box::pin(async move { Ok(c) }) + }); + let device_mock = DeviceServiceClient::default(); + AuthorizationService::validate_token_and_claims(&sso_mock, &device_mock, "valid_token") + .await + } + + async fn run_auth_with_fleet( + claims: TokenClaims, + fleet_id: &'static str, + ) -> anyhow::Result<()> { + let mut sso_mock = SingleSignOnProvider::default(); + sso_mock.expect_verify_token().returning(move |_| { + let c = claims.clone(); + Box::pin(async move { Ok(c) }) + }); + let mut device_mock = DeviceServiceClient::default(); + device_mock + .expect_fleet_id() + .returning(move || Box::pin(async move { Ok(fleet_id.to_string()) })); + AuthorizationService::validate_token_and_claims(&sso_mock, &device_mock, "valid_token") + .await + } + mod fleet_administrator { use super::*; #[tokio::test] async fn with_valid_tenant_succeeds() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock.expect_verify_token().returning(|_| { - Box::pin(async { - Ok(create_claims( - Some(vec!["FleetAdministrator"]), - Some(vec!["cp"]), - None, - )) - }) - }); - - let device_mock = DeviceServiceClient::default(); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", - ) + let result = run_auth(create_claims( + Some(vec!["FleetAdministrator"]), + Some(vec!["cp"]), + None, + )) .await; - assert!(result.is_ok()); } #[tokio::test] async fn with_invalid_tenant_fails() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock.expect_verify_token().returning(|_| { - Box::pin(async { - Ok(create_claims( - Some(vec!["FleetAdministrator"]), - Some(vec!["invalid_tenant"]), - None, - )) - }) - }); - - let device_mock = DeviceServiceClient::default(); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", - ) + let result = run_auth(create_claims( + Some(vec!["FleetAdministrator"]), + Some(vec!["invalid_tenant"]), + None, + )) .await; - - assert!(result.is_err()); assert!( result .unwrap_err() @@ -165,26 +171,12 @@ mod tests { #[tokio::test] async fn with_multiple_tenants_including_valid_succeeds() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock.expect_verify_token().returning(|_| { - Box::pin(async { - Ok(create_claims( - Some(vec!["FleetAdministrator"]), - Some(vec!["other_tenant", "cp", "another_tenant"]), - None, - )) - }) - }); - - let device_mock = DeviceServiceClient::default(); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", - ) + let result = run_auth(create_claims( + Some(vec!["FleetAdministrator"]), + Some(vec!["other_tenant", "cp", "another_tenant"]), + None, + )) .await; - assert!(result.is_ok()); } } @@ -194,58 +186,29 @@ mod tests { #[tokio::test] async fn with_matching_fleet_succeeds() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock.expect_verify_token().returning(|_| { - Box::pin(async { - Ok(create_claims( - Some(vec!["FleetOperator"]), - Some(vec!["cp"]), - Some(vec!["fleet-123"]), - )) - }) - }); - - let mut device_mock = DeviceServiceClient::default(); - device_mock - .expect_fleet_id() - .returning(|| Box::pin(async { Ok("fleet-123".to_string()) })); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", + let result = run_auth_with_fleet( + create_claims( + Some(vec!["FleetOperator"]), + Some(vec!["cp"]), + Some(vec!["fleet-123"]), + ), + "fleet-123", ) .await; - assert!(result.is_ok()); } #[tokio::test] async fn with_non_matching_fleet_fails() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock.expect_verify_token().returning(|_| { - Box::pin(async { - Ok(create_claims( - Some(vec!["FleetOperator"]), - Some(vec!["cp"]), - Some(vec!["fleet-456"]), - )) - }) - }); - - let mut device_mock = DeviceServiceClient::default(); - device_mock - .expect_fleet_id() - .returning(|| Box::pin(async { Ok("fleet-123".to_string()) })); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", + let result = run_auth_with_fleet( + create_claims( + Some(vec!["FleetOperator"]), + Some(vec!["cp"]), + Some(vec!["fleet-456"]), + ), + "fleet-123", ) .await; - - assert!(result.is_err()); assert!( result .unwrap_err() @@ -256,55 +219,26 @@ mod tests { #[tokio::test] async fn with_multiple_fleets_including_match_succeeds() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock.expect_verify_token().returning(|_| { - Box::pin(async { - Ok(create_claims( - Some(vec!["FleetOperator"]), - Some(vec!["cp"]), - Some(vec!["fleet-456", "fleet-123", "fleet-789"]), - )) - }) - }); - - let mut device_mock = DeviceServiceClient::default(); - device_mock - .expect_fleet_id() - .returning(|| Box::pin(async { Ok("fleet-123".to_string()) })); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", + let result = run_auth_with_fleet( + create_claims( + Some(vec!["FleetOperator"]), + Some(vec!["cp"]), + Some(vec!["fleet-456", "fleet-123", "fleet-789"]), + ), + "fleet-123", ) .await; - assert!(result.is_ok()); } #[tokio::test] async fn without_fleet_list_fails() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock.expect_verify_token().returning(|_| { - Box::pin(async { - Ok(create_claims( - Some(vec!["FleetOperator"]), - Some(vec!["cp"]), - None, - )) - }) - }); - - let device_mock = DeviceServiceClient::default(); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", - ) + let result = run_auth(create_claims( + Some(vec!["FleetOperator"]), + Some(vec!["cp"]), + None, + )) .await; - - assert!(result.is_err()); assert!( result .unwrap_err() @@ -315,27 +249,12 @@ mod tests { #[tokio::test] async fn with_invalid_tenant_fails() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock.expect_verify_token().returning(|_| { - Box::pin(async { - Ok(create_claims( - Some(vec!["FleetOperator"]), - Some(vec!["invalid_tenant"]), - Some(vec!["fleet-123"]), - )) - }) - }); - - let device_mock = DeviceServiceClient::default(); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", - ) + let result = run_auth(create_claims( + Some(vec!["FleetOperator"]), + Some(vec!["invalid_tenant"]), + Some(vec!["fleet-123"]), + )) .await; - - assert!(result.is_err()); assert!( result .unwrap_err() @@ -350,27 +269,12 @@ mod tests { #[tokio::test] async fn with_valid_tenant_fails() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock.expect_verify_token().returning(|_| { - Box::pin(async { - Ok(create_claims( - Some(vec!["FleetObserver"]), - Some(vec!["cp"]), - None, - )) - }) - }); - - let device_mock = DeviceServiceClient::default(); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", - ) + let result = run_auth(create_claims( + Some(vec!["FleetObserver"]), + Some(vec!["cp"]), + None, + )) .await; - - assert!(result.is_err()); assert!( result .unwrap_err() @@ -385,21 +289,8 @@ mod tests { #[tokio::test] async fn without_tenant_list_fails() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock.expect_verify_token().returning(|_| { - Box::pin(async { Ok(create_claims(Some(vec!["FleetAdministrator"]), None, None)) }) - }); - - let device_mock = DeviceServiceClient::default(); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", - ) - .await; - - assert!(result.is_err()); + let result = + run_auth(create_claims(Some(vec!["FleetAdministrator"]), None, None)).await; assert!( result .unwrap_err() @@ -410,21 +301,7 @@ mod tests { #[tokio::test] async fn without_roles_fails() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock - .expect_verify_token() - .returning(|_| Box::pin(async { Ok(create_claims(None, Some(vec!["cp"]), None)) })); - - let device_mock = DeviceServiceClient::default(); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", - ) - .await; - - assert!(result.is_err()); + let result = run_auth(create_claims(None, Some(vec!["cp"]), None)).await; assert!( result .unwrap_err() @@ -443,17 +320,13 @@ mod tests { sso_mock .expect_verify_token() .returning(|_| Box::pin(async { Err(anyhow::anyhow!("invalid token signature")) })); - let device_mock = DeviceServiceClient::default(); - let result = AuthorizationService::validate_token_and_claims( &sso_mock, &device_mock, "invalid_token", ) .await; - - assert!(result.is_err()); assert!( result .unwrap_err() diff --git a/src/backend/src/services/auth/password.rs b/src/backend/src/services/auth/password.rs index 3a7df41c..99a1d0f2 100644 --- a/src/backend/src/services/auth/password.rs +++ b/src/backend/src/services/auth/password.rs @@ -18,6 +18,9 @@ use std::sync::{LazyLock, Mutex, MutexGuard}; #[allow(dead_code)] static PASSWORD_FILE_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); +const MAX_RETRIES: u32 = 3; +const RETRY_DELAY_MS: u64 = 100; + /// Service for password management operations pub struct PasswordService; @@ -75,6 +78,24 @@ impl PasswordService { .context("failed to hash password") } + /// Atomically write a password hash to file and verify it can be read back. + /// + /// Writes to a `.tmp` file first, then renames to replace the target atomically. + fn write_password_hash_atomic( + password_file: &std::path::Path, + hash: &str, + password: &str, + ) -> Result<()> { + let temp_path = password_file.with_extension("tmp"); + let mut file = File::create(&temp_path).context("failed to create temp password file")?; + file.write_all(hash.as_bytes()) + .context("failed to write password file")?; + file.sync_all().context("failed to sync password file")?; + std::fs::rename(&temp_path, password_file).context("failed to replace password file")?; + // Verify that the password can be read back and validated + Self::validate_password(password).context("failed to verify stored password") + } + /// Store or update a password /// /// # Arguments @@ -87,35 +108,15 @@ impl PasswordService { let password_file = &AppConfig::get().paths.password_file; let hash = Self::hash_password(password)?; - - let max_retries = 3; let mut last_error = anyhow!("Unknown error"); - for i in 0..max_retries { - let temp_file_path = password_file.with_extension("tmp"); - - let result = (|| -> Result<()> { - let mut file = - File::create(&temp_file_path).context("failed to create temp password file")?; - - file.write_all(hash.as_bytes()) - .context("failed to write password file")?; - - file.sync_all().context("failed to sync password file")?; - - std::fs::rename(&temp_file_path, password_file) - .context("failed to replace password file")?; - - // Verify that the password can be read back and validated - Self::validate_password(password).context("failed to verify stored password") - })(); - - match result { - Ok(_) => return Ok(()), + for i in 0..MAX_RETRIES { + match Self::write_password_hash_atomic(password_file, &hash, password) { + Ok(()) => return Ok(()), Err(e) => { log::warn!("store_or_update_password attempt {} failed: {:#}", i + 1, e); last_error = e; - std::thread::sleep(std::time::Duration::from_millis(100)); + std::thread::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS)); } } } diff --git a/src/ui/src/App.vue b/src/ui/src/App.vue index 5e9bff82..e21d346d 100644 --- a/src/ui/src/App.vue +++ b/src/ui/src/App.vue @@ -1,6 +1,5 @@