diff --git a/.gitignore b/.gitignore index 4d9edc57..45dfd230 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ /target /temp .vscode/settings.json -tools/centrifugo *.tar.gz *.env *.http diff --git a/.vscode/launch.json b/.vscode/launch.json index 52d1174f..0cf9c874 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -19,16 +19,12 @@ }, "args": [], "cwd": "${workspaceFolder}", - "preLaunchTask": "check_ods_and_centrifugo", + "preLaunchTask": "check_ods", "env": { "RUST_LOG": "omnect_ui=debug", "DEVICE_SERVICE_SOCKET_PATH": "/tmp/api.sock", "CERT_PATH": "temp/device_id_cert.pem", "KEY_PATH": "temp/device_id_cert_key.pem", - "CENTRIFUGO_ADMIN_ENABLED": "true", - "CENTRIFUGO_ADMIN_PASSWORD": "123", - "CENTRIFUGO_ADMIN_SECRET": "123", - "CENTRIFUGO_LOG_LEVEL": "debug", "KEYCLOAK_URL": "https://keycloak.omnect.conplement.cloud/realms/cp-dev", "UI_PORT": "1977" } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 56e86c11..833a40cf 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,7 +2,7 @@ "version": "2.0.0", "tasks": [ { - "label": "check_ods_and_centrifugo", + "label": "check_ods", "type": "shell", "command": "${workspaceFolder}/.vscode/tasks.sh", "problemMatcher": [], diff --git a/.vscode/tasks.sh b/.vscode/tasks.sh index cb8ddfdb..b45c3584 100755 --- a/.vscode/tasks.sh +++ b/.vscode/tasks.sh @@ -26,8 +26,3 @@ if ! pgrep -f "omnect-device-service" > /dev/null; then fi echo -e "${GREEN}✓${NC}" - -# Stop existing centrifugo processes -echo -n "Stopping existing centrifugo processes... " -killall centrifugo 2>/dev/null || true -echo -e "${GREEN}✓${NC}" diff --git a/Cargo.lock b/Cargo.lock index 2e184596..f41cd64d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,6 +53,7 @@ dependencies = [ "actix-service", "actix-tls", "actix-utils", + "base64 0.22.1", "bitflags", "bytes", "bytestring", @@ -66,9 +67,12 @@ dependencies = [ "httpdate", "itoa", "language-tags", + "local-channel", "mime", "percent-encoding", "pin-project-lite", + "rand 0.9.2", + "sha1", "smallvec", "tokio", "tokio-util", @@ -303,6 +307,22 @@ dependencies = [ "static-files 0.3.1", ] +[[package]] +name = "actix-ws" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf53c3cdd63dd6f289980b430238f9a2f6d19f8bce8e418272e08d3da43f0f" +dependencies = [ + "actix-codec", + "actix-http", + "actix-web", + "bytestring", + "futures-core", + "futures-sink", + "tokio", + "tokio-util", +] + [[package]] name = "addr2line" version = "0.25.1" @@ -422,9 +442,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.0" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" +checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" dependencies = [ "aws-lc-sys", "zeroize", @@ -432,9 +452,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.37.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" +checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" dependencies = [ "cc", "cmake", @@ -747,9 +767,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crux_core" -version = "0.17.0-rc2" +version = "0.17.0-rc3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dcabcd8aad70780470a8fbc05ebe29ff3a2a836e613f050d64bb97650942c" +checksum = "a0f8bab3a48dbc4c2ce72f87cc13efec90d59f0763d5b23d594819b52c860e85" dependencies = [ "anyhow", "bincode", @@ -767,9 +787,9 @@ dependencies = [ [[package]] name = "crux_http" -version = "0.16.0-rc2" +version = "0.16.0-rc3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56117df66c95e6f1f2b89f44e5871bb15d5acba435343108fb7bd100b295ce59" +checksum = "65c8e00712abe338fb7040fc9e4ca52ee77528dfdc2563f106269b8c95db970f" dependencies = [ "anyhow", "async-trait", @@ -783,7 +803,7 @@ dependencies = [ "serde", "serde_bytes", "serde_json", - "serde_qs", + "serde_qs 1.0.0", "thiserror 2.0.18", "url", "web-sys", @@ -791,9 +811,9 @@ dependencies = [ [[package]] name = "crux_macros" -version = "0.8.0-rc2" +version = "0.8.0-rc3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c18ba1052a1f28c97e3595e5a64496921953b3945cf7537e0544f93b36735e74" +checksum = "7ae641e716b11bb7980b5c123461f00077648fbacb89e622d31598aedbb55614" dependencies = [ "darling 0.23.0", "heck 0.5.0", @@ -803,6 +823,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "crux_time" +version = "0.15.0-rc3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4fc7f6d025a0d723073c07ebb21737fb1ad7baf269a8246ce6587c7f363e70" +dependencies = [ + "crux_core", + "facet", + "futures", + "serde", + "thiserror 2.0.18", +] + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -817,9 +850,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", "rand_core 0.6.4", @@ -1393,9 +1426,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -1424,20 +1457,20 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -1530,6 +1563,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" @@ -1614,7 +1653,7 @@ dependencies = [ "rand 0.9.2", "serde", "serde_json", - "serde_qs", + "serde_qs 0.15.0", "serde_urlencoded", "url", ] @@ -1873,9 +1912,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" @@ -2012,6 +2051,17 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + [[package]] name = "local-waker" version = "0.1.4" @@ -2209,7 +2259,7 @@ dependencies = [ [[package]] name = "omnect-ui" -version = "1.1.3" +version = "1.2.0" dependencies = [ "actix-files", "actix-http", @@ -2220,6 +2270,7 @@ dependencies = [ "actix-web", "actix-web-httpauth", "actix-web-static-files", + "actix-ws", "anyhow", "argon2", "base64 0.22.1", @@ -2245,25 +2296,30 @@ dependencies = [ "tempfile", "tokio", "trait-variant", + "url", "uuid", ] [[package]] name = "omnect-ui-core" -version = "1.1.3" +version = "1.2.0" dependencies = [ "base64 0.22.1", "console_log", "crux_core", "crux_http", "crux_macros", + "crux_time", "getrandom 0.3.4", - "lazy_static", + "hex", + "hmac", "log", + "pbkdf2", "serde", "serde_json", "serde_repr", "serde_valid", + "sha1", "wasm-bindgen", ] @@ -2392,6 +2448,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" @@ -2693,9 +2759,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -2706,6 +2772,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -3202,6 +3274,18 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "serde_qs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac22439301a0b6f45a037681518e3169e8db1db76080e2e9600a08d1027df037" +dependencies = [ + "itoa", + "percent-encoding", + "ryu", + "serde", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -3270,6 +3354,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" @@ -3283,7 +3378,7 @@ dependencies = [ [[package]] name = "shared_types" -version = "1.1.3" +version = "1.2.0" dependencies = [ "anyhow", "crux_core", @@ -3484,7 +3579,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3613,9 +3708,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", @@ -3630,9 +3725,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", @@ -3658,6 +3753,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] @@ -3844,7 +3940,7 @@ version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ - "getrandom 0.4.1", + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index 13934799..c25cb6bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,4 +26,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.3" +version = "1.2.0" diff --git a/Dockerfile b/Dockerfile index 2cec766c..cb188dd4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -68,10 +68,6 @@ ARG TARGETARCH ARG OMNECT_UI_BUILD_ARG="" WORKDIR "/work" -ARG CENTRIFUGO_VERSION=v6.6.2 - -RUN curl -sSLf https://centrifugal.dev/install.sh | sh - COPY --from=distroless /var/lib/dpkg/status.d /distroless_pkgs COPY Cargo.lock Cargo.toml ./ @@ -139,7 +135,6 @@ RUN mkdir /cert FROM ${DISTROLESS_IMAGE} AS base COPY --from=builder --chown=10000:10000 /cert /cert COPY --from=builder /work/omnect-ui-bin /omnect-ui -COPY --from=builder /work/centrifugo / COPY --from=builder /copy/lib/ /lib/ COPY --from=builder /copy/status.d /var/lib/dpkg/status.d # npm runtime metadata for SBOM generation (stripped of devDependencies) @@ -147,7 +142,6 @@ COPY --from=vue-build /tmp/sbom-package.json /sbom/npm/package.json COPY --from=vue-build /usr/src/app/bun.lock /sbom/npm/bun.lock WORKDIR "/" -COPY src/backend/config/centrifugo_config.json / ARG RUST_LOG="warn,omnect_ui=debug" ENV RUST_LOG=${RUST_LOG} diff --git a/README.md b/README.md index 05665389..8a1d53ce 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,128 @@ omnect UI provides the following main features: omnect UI follows a full-stack Single Page Application (SPA) architecture: -- **Backend**: Rust-based web service (Actix-web) providing API endpoints and WebSocket support via Centrifugo +- **Backend**: Rust-based web service (Actix-web) providing API endpoints and WebSocket support - **Crux Core**: Platform-agnostic business logic compiled to WebAssembly - **Frontend**: Vue 3 TypeScript SPA serving as the shell for the Crux Core - **Shared Types**: TypeScript bindings auto-generated from Rust types +## Security + +### Connections + +All external communication uses encrypted transports. Internal service communication uses Unix domain sockets (not network-exposed). + +| Connection | Direction | Protocol | Port | Security | Purpose | +|---|---|---|---|---|---| +| Browser ↔ Backend | bidirectional | HTTPS / WSS | 1977 (configurable) | TLS via rustls, self-signed certificate | UI serving and real-time WebSocket updates | +| Backend → omnect-device-service | outbound | Unix domain socket | — | OS filesystem permissions | Device control: reboot, factory reset, network config, firmware update | +| Backend → WiFi commissioning service | outbound | Unix domain socket | — | OS filesystem permissions | WiFi scan, connect, disconnect, forget | +| Backend → Keycloak (optional) | outbound | HTTPS | configured via `KEYCLOAK_URL` | TLS + RS256 JWT signature verification | Portal SSO token validation | +| Internal publish endpoint | localhost only | HTTP | 8000 (configurable) | `X-API-Key` header (UUID, generated per instance) | Receives event publications from omnect-device-service | + +### Transport Security + +- The backend serves HTTPS using **rustls** (memory-safe TLS library). TLS 1.2+ is enforced; no client certificate is required. +- The WebSocket endpoint (`/ws`) runs over the same TLS connection as the UI (WSS). +- The TLS certificate is issued by `aziot-edged` and written to `/cert/cert.pem` / `/cert/key.pem`. The certificate CN is set to the device's first detected IPv4 address and is regenerated when the address changes. See [HTTPS & Certificate Validation](#https--certificate-validation) for browser trust requirements. +- The internal publish endpoint (port 8000) is **plain HTTP** and must not be exposed outside the device network namespace. It is protected by a per-instance UUID API key passed as `X-API-Key`. + +### Authentication + +#### Local Password Mode (default) + +1. On first access the UI shows a **Set Password** page — no prior credential exists. +2. The user sets a password (no username). The password is hashed with **Argon2id** and stored atomically at `/data/config/password`. +3. On subsequent logins the password is validated against the stored hash and a session cookie is issued. +4. API clients may alternatively pass `Authorization: Bearer ` or HTTP Basic Auth (`:`). + +#### Portal SSO Mode (optional) + +When configured, Keycloak-based OIDC authentication gates the initial **Set Password** step: + +1. The frontend initiates an OIDC flow and obtains a token from Keycloak (RS256-signed JWT). +2. The token is submitted to `POST /token/validate`. The backend fetches Keycloak's realm public key and verifies the RS256 signature. +3. If valid, the backend records a `portal_validated` flag in the session, allowing the user to call `POST /set-password`. +4. From that point on the local password flow applies. + +### Session & Token Properties + +**Session cookie:** + +| Attribute | Value | Purpose | +|---|---|---| +| Name | `omnect-ui-session` | | +| `HttpOnly` | true | Prevents JavaScript access (XSS mitigation) | +| `Secure` | true | Transmitted over HTTPS only | +| `SameSite` | Strict | CSRF mitigation | +| Encryption | AES-GCM (private cookie) | Cookie value is authenticated and encrypted | +| Lifetime | Browser session | Destroyed when the browser is closed | + +**Session JWT (stored inside the cookie):** + +| Property | Value | +|---|---| +| Algorithm | HS256 | +| Expiry | 2 hours | +| Clock-skew leeway | 15 minutes | +| Signing key | 64-byte key from `OsRng`, stored at `/data/session.key` | + +### Authorization + +Authorization applies in Portal SSO mode when validating Keycloak tokens: + +| Role | Access | +|---|---| +| `FleetAdministrator` | Full access — no fleet restriction | +| `FleetOperator` | Access only if the device's `fleet_id` is in the user's `fleet_list` claim | +| Other / missing | Access denied | + +Additionally, the user's `tenant_list` claim must contain the configured tenant (default: `cp`). + +### Credential and Secret Storage + +| Secret | Path | Protection | +|---|---|---| +| Password hash | `/data/config/password` | Argon2id; written via atomic rename + post-write verification | +| Session signing key | `/data/session.key` | 64 bytes from `OsRng`; generated once and persisted for restart continuity | + +Both paths are under `/data`, which is the persistent, writable partition on omnect Secure OS. + +### Public and Protected API Endpoints + +**No authentication required:** + +- `GET /` — serves the SPA +- `GET /config.js` — Keycloak/SSO configuration for the frontend +- `GET /version` — backend version string +- `GET /healthcheck` — health probe (CORS enabled for external monitors) +- `GET /require-set-password` — indicates whether a password has been set +- `POST /set-password` — set initial password (requires portal session if SSO is configured) +- `POST /token/validate` — validate a Keycloak OIDC token +- `GET /api/settings` — read timeout settings + +**Authentication required (session cookie, Bearer token, or Basic Auth):** + +- `GET /ws` — WebSocket +- `POST /token/login`, `GET /token/refresh` — issue / refresh session token +- `POST /update/file`, `/update/load`, `/update/run` — firmware update +- `POST /factory-reset`, `POST /reboot` — device control +- `POST /network` — network configuration +- `POST /ack-rollback`, `/ack-factory-reset-result`, `/ack-update-validation` — acknowledge operations +- `POST /republish` — republish device state +- `POST /api/settings` — write timeout settings +- `POST /update-password` — change the current password +- `/wifi/*` — all WiFi management endpoints + +### Security Considerations + +- **Self-signed certificate**: The device certificate is not issued by a public CA. Browsers will show a security warning unless the device root certificate is imported into the browser's trust store. See [HTTPS & Certificate Validation](#https--certificate-validation). +- **Internal HTTP endpoint**: Port 8000 is plain HTTP and relies solely on the API key for protection. It must not be reachable from outside the device's loopback or container network. +- **Session key persistence**: `/data/session.key` is intentionally kept across restarts so that existing session cookies remain valid after a service restart. Delete this file to invalidate all active sessions (the service will generate a new key on the next start). +- **Password recovery**: There is no password-reset mechanism without authentication. If the password is lost, a factory reset is required. + +--- + ## Install omnect UI Since omnect secure OS is designed as generic OS, all specific or optional applications must be provided as docker images via azure iotedge deployment: @@ -56,6 +173,116 @@ To access the UI without security warnings, you must **import the device's root ## Feature Details +### Device Status Monitoring + +The device overview page displays a real-time snapshot of the device's health and identity. All data is pushed via WebSocket (healthcheck channel) so the UI updates automatically without polling. + +**Information displayed:** + +- **Cloud Connectivity**: Whether the device is connected to the omnect Azure IoT Hub +- **Hostname**: Device hostname +- **OS Variant & Version**: omnect Secure OS build name and version string +- **Boot Time**: Timestamp of the last system boot +- **Wait-Online Timeout**: Configured network wait timeout (seconds) +- **Service Versions**: omnect-device-service, Azure SDK, and WiFi commissioning service versions (the WiFi entry also shows the minimum required version when the installed version is too old) + +--- + +### Firmware Updates + +omnect UI supports uploading and applying firmware update packages directly from the browser. + +#### Upload & Inspect + +1. Navigate to the **Update** section +2. Drag-and-drop or click to select a `.tar` update archive +3. Upload progress is shown as a percentage +4. Once uploaded, the manifest is parsed and displayed in three columns: + - **Version Info**: Current device version, update version, OS variant + - **Provider Info**: Update provider name, creation date and time + - **Compatibility**: Manufacturer, model, compatibility ID + +#### Applying an Update + +1. Optionally enable **"Enforce cloud connection"** — requires the device to successfully reconnect to Azure IoT Hub after the update before declaring success +2. Click **Install Update** +3. The UI polls for update completion (default timeout: 900 s, configurable in Settings) +4. On success the device reports the new version; on failure it reports a rollback + +--- + +### Device Control + +The device overview page provides two administrative operations, each guarded by a confirmation dialog. + +#### Reboot + +1. Click **Restart** and confirm the dialog +2. The reboot command is sent to omnect-device-service +3. An overlay with a countdown timer appears while the UI polls for the device to come back online (5 s interval, default timeout: 300 s) +4. Once the device responds the overlay clears and the page resumes normally + +#### Factory Reset + +1. Click **Factory Reset** and confirm the dialog +2. Optionally select items to **preserve** (e.g., network configuration, certificates) — available options are reported by the device +3. The reset command is sent; an overlay with a countdown timer appears +4. The UI polls for reconnection (5 s interval, default timeout: 600 s) +5. Once the device responds the overlay clears + +--- + +### Secure Access + +omnect UI protects device settings with authentication. + +**Password login (default):** + +- Enter the password on the login page — no username is required +- On first use, a guided **Set Password** page is shown before the login form is accessible +- Passwords are hashed and stored on the device; the session is managed via JWT + +**Portal SSO (optional):** + +- If the device is configured for OIDC-based portal authentication, the UI detects this automatically and redirects to the identity provider +- When the portal session expires the UI clears the stale token and re-initiates the login flow + +--- + +### Settings + +The **Settings** page exposes configurable timeout durations (in seconds) for long-running operations. All values are persisted on the device and applied on the next operation. + +| Setting | Default | Description | +|---|---|---| +| Network rollback | 90 s | How long to wait at the new address before reverting a failed network change | +| Reboot reconnect | 300 s | Maximum time to wait for the device to come back online after a reboot | +| Factory reset reconnect | 600 s | Maximum time to wait for the device to come back online after a factory reset | +| Firmware update | 900 s | Maximum time to wait for an update to complete | + +All timeouts accept values between 30 s and 3600 s. A **Reset to Defaults** button restores all four values at once. + +--- + +### WiFi Management + +omnect UI can discover and manage WiFi networks when the WiFi commissioning service is installed on the device and meets the minimum required version. + +#### Scanning & Connecting + +1. Navigate to the **Network** section and open the **WiFi** panel +2. Click **Scan** to discover nearby networks; each result shows the SSID and signal strength (1–4 bars based on RSSI) +3. Click a network to open the **Connect** dialog, enter the password, and press **Connect** +4. Connection status (Idle / Connecting / Connected / Failed) is shown in real time + +#### Managing Saved Networks + +- Previously connected networks appear in the **Saved Networks** list with a **CURRENT** badge on the active connection +- Click the delete icon next to a saved network to forget its credentials +- Use the **Disconnect** button to drop the current WiFi connection without forgetting it + +--- + ### Network Configuration omnect UI allows you to configure network settings for your device's network adapters. This feature is particularly useful when you need to change IP addresses or switch between DHCP and static IP configuration. @@ -179,7 +406,7 @@ Run `./scripts/build-and-deploy-image.sh --help` for all available options. # Run all unit tests (backend + core) cargo test --features mock -# Run E2E tests (automated setup - starts Centrifugo + frontend dev server) +# Run E2E tests (automated setup - starts Mock WebSocket server + frontend dev server) ./scripts/run-e2e-tests.sh # Run E2E tests in Docker container (isolated environment) @@ -204,7 +431,7 @@ The project includes VSCode launch configurations optimized for development: #### Pre-Launch Task (runs before each debug session) -- `check_ods_and_centrifugo` task: Verifies omnect-device-service is running and kills existing Centrifugo processes +- `check_ods` task: Verifies omnect-device-service is running **Prerequisites before launching the debugger:** diff --git a/project-context.md b/project-context.md index 4a468f0a..7ccb3a0c 100644 --- a/project-context.md +++ b/project-context.md @@ -144,6 +144,7 @@ omnect-ui/ ├── scripts/ # Build and test scripts │ ├── build-frontend.sh # Build WASM + Types + UI │ ├── build-and-deploy-image.sh # Docker build and deploy +│ ├── build-and-run-image.sh # Local docker development │ └── run-e2e-tests.sh # Playwright test runner ├── src/ │ ├── app/ # Crux Core (business logic) @@ -155,22 +156,27 @@ 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 +│ │ │ └── websocket.rs # WebSocket commands │ │ ├── types/ # Domain types │ │ │ ├── mod.rs │ │ │ ├── auth.rs # Authentication types │ │ │ ├── common.rs # Common shared types │ │ │ ├── device.rs # Device information types │ │ │ ├── network.rs # Network configuration types +│ │ │ ├── wifi.rs # WiFi types │ │ │ ├── ods.rs # ODS-specific DTOs +│ │ │ ├── settings.rs # Timeout settings types +│ │ │ ├── websocket.rs # WebSocket channel enum │ │ │ ├── factory_reset.rs │ │ │ └── update.rs # Update validation types │ │ └── update/ # Domain-based event handlers │ │ ├── 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 @@ -181,21 +187,28 @@ omnect-ui/ │ │ ├── Cargo.toml │ │ ├── src/ │ │ │ ├── main.rs # Application entry point +│ │ │ ├── lib.rs # Library entry point │ │ │ ├── api.rs # API route handlers +│ │ │ ├── build.rs # Static resource generation │ │ │ ├── middleware.rs # Auth middleware │ │ │ ├── config.rs # Configuration loading │ │ │ ├── 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 +│ │ │ ├── settings.rs # Timeout settings service +│ │ │ ├── websocket.rs # WebSocket handlers │ │ │ └── auth/ # Auth logic │ │ │ ├── mod.rs │ │ │ ├── authorization.rs # JWT/SSO validation │ │ │ ├── password.rs # Password hashing/storage +│ │ │ ├── session_key.rs # Session key management │ │ │ └── token.rs # JWT generation │ │ └── tests/ # Integration tests │ ├── shared_types/ # TypeGen for TypeScript bindings @@ -205,44 +218,52 @@ omnect-ui/ │ └── ui/ # Vue 3 Shell │ ├── package.json │ ├── playwright.config.ts # E2E test configuration +│ ├── vite.config.ts # Build configuration │ ├── src/ │ │ ├── App.vue # Root component │ │ ├── main.ts # UI entry point │ │ ├── components/ # UI components │ │ ├── composables/ # Logic & WASM bridge │ │ │ ├── useCore.ts # Main bridge + effect handlers -│ │ │ ├── useCentrifugo.ts -│ │ │ └── core/ # Modular Core integration -│ │ │ ├── index.ts # Main entry point -│ │ │ ├── state.ts # Singleton reactive state -│ │ │ ├── types.ts # TypeScript type conversions -│ │ │ ├── effects.ts # Effect processing -│ │ │ ├── http.ts # HTTP capability -│ │ │ ├── centrifugo.ts # WebSocket capability -│ │ │ ├── timers.ts # Timer/Polling logic -│ │ │ └── sync.ts # ViewModel synchronization +│ │ │ ├── useWebSocket.ts # Native WebSocket client +│ │ │ └── core/ # Modular Core integration +│ │ │ ├── index.ts # Main entry point +│ │ │ ├── state.ts # Singleton reactive state +│ │ │ ├── types.ts # TypeScript type conversions +│ │ │ ├── effects.ts # Effect processing +│ │ │ ├── http.ts # HTTP capability +│ │ │ ├── websocket.ts # WebSocket capability +│ │ │ ├── time.ts # Time/timer capability (crux_time) +│ │ │ └── sync.ts # ViewModel synchronization and navigation side-effects │ │ ├── pages/ # Route components -│ │ │ ├── DeviceOverview.vue -│ │ │ ├── DeviceUpdate.vue -│ │ │ ├── Network.vue -│ │ │ ├── Login.vue -│ │ │ ├── SetPassword.vue -│ │ │ ├── UpdatePassword.vue -│ │ │ └── Callback.vue -│ │ ├── plugins/ # Router, Vuetify -│ │ └── types/ # UI-specific types -│ └── tests/ # Playwright E2E tests -│ ├── auth.spec.ts -│ ├── device.spec.ts -│ ├── error-handling.spec.ts -│ ├── factory-reset.spec.ts -│ ├── network-configuration.spec.ts -│ ├── network-multi-adapter.spec.ts -│ ├── reboot.spec.ts -│ ├── smoke.spec.ts -│ ├── update.spec.ts -│ ├── version-mismatch.spec.ts -│ └── fixtures/ +│ │ │ ├── DeviceOverview.vue +│ │ │ ├── DeviceUpdate.vue +│ │ │ ├── Network.vue +│ │ │ ├── Settings.vue +│ │ │ ├── Login.vue +│ │ │ ├── SetPassword.vue +│ │ │ ├── UpdatePassword.vue +│ │ │ └── Callback.vue +│ │ ├── plugins/ # Router, Vuetify +│ │ └── types/ # UI-specific types +│ └── tests/ # Playwright E2E tests +│ ├── auth.spec.ts +│ ├── device.spec.ts +│ ├── error-handling.spec.ts +│ ├── factory-reset.spec.ts +│ ├── network-configuration.spec.ts +│ ├── network-multi-adapter.spec.ts +│ ├── reboot.spec.ts +│ ├── settings.spec.ts +│ ├── smoke.spec.ts +│ ├── update.spec.ts +│ ├── version-mismatch.spec.ts +│ ├── wifi.spec.ts +│ └── fixtures/ +│ ├── mock-api.ts +│ ├── network-test-harness.ts +│ ├── test-setup.ts +│ └── websocket.ts # WebSocket test mocks └── project-context.md # This file ``` @@ -250,7 +271,7 @@ omnect-ui/ **Frontend (Shell):** - `src/ui/src/composables/useCore.ts` - Core WASM bridge + effect handlers -- `src/ui/src/composables/useCentrifugo.ts` - WebSocket client integration +- `src/ui/src/composables/useWebSocket.ts` - WebSocket client integration - `src/ui/src/pages/DeviceOverview.vue` - Main device dashboard page **Core:** @@ -259,12 +280,12 @@ omnect-ui/ - `src/app/src/events.rs` - Event enum definitions - `src/app/src/types/` - Domain types organized by domain - `src/app/src/update/` - Domain-based event handlers -- `src/app/src/commands/centrifugo.rs` - Custom Centrifugo commands +- `src/app/src/commands/websocket.rs` - Custom WebSocket commands **Backend:** - `src/backend/src/main.rs` - Application entry point - `src/backend/src/api.rs` - API route handlers -- `src/backend/src/services/` - Business logic services +- `src/backend/src/services/websocket.rs` - Native WebSocket implementation **Scripts:** - `scripts/build-frontend.sh` - Build complete frontend (WASM + TypeScript types + UI) 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/scripts/build-and-run-image.sh b/scripts/build-and-run-image.sh index 8ad681b8..ea142415 100755 --- a/scripts/build-and-run-image.sh +++ b/scripts/build-and-run-image.sh @@ -17,7 +17,7 @@ esac # Configuration IMAGE_TAG="${IMAGE_TAG:-local}" UI_PORT="${UI_PORT:-1977}" -CENTRIFUGO_PORT="${CENTRIFUGO_PORT:-8000}" +PUBLISH_PORT="${PUBLISH_PORT:-8000}" # Build image using the main build script echo "Building image for $ARCH architecture..." @@ -41,13 +41,11 @@ docker run --rm \ -u "$(id -u):$(id -g)" \ -e RUST_LOG=debug \ -e UI_PORT="$UI_PORT" \ + -e PUBLISH_PORT="$PUBLISH_PORT" \ -e SOCKET_PATH=/socket/api.sock \ - -e CENTRIFUGO_ADMIN_ENABLED=true \ - -e CENTRIFUGO_ADMIN_PASSWORD=123 \ - -e CENTRIFUGO_ADMIN_SECRET=123 \ -e DATA_DIR_PATH=/data \ -e KEYCLOAK_URL=https://keycloak.omnect.conplement.cloud/realms/cp-dev \ -e TENANT=cp \ -p "${UI_PORT}:${UI_PORT}" \ - -p "${CENTRIFUGO_PORT}:${CENTRIFUGO_PORT}" \ + -p "${PUBLISH_PORT}:${PUBLISH_PORT}" \ "$IMAGE_NAME" diff --git a/scripts/run-e2e-tests.sh b/scripts/run-e2e-tests.sh index 09c639c9..5b4cdffb 100755 --- a/scripts/run-e2e-tests.sh +++ b/scripts/run-e2e-tests.sh @@ -11,7 +11,7 @@ cd "$REPO_ROOT" cleanup() { echo "🧹 Cleaning up processes..." [ -n "$FRONTEND_PID" ] && kill $FRONTEND_PID 2>/dev/null || true - [ -n "$CENTRIFUGO_PID" ] && kill $CENTRIFUGO_PID 2>/dev/null || true + [ -n "$MOCK_WS_PID" ] && kill $MOCK_WS_PID 2>/dev/null || true } trap cleanup EXIT @@ -30,50 +30,79 @@ if ! command -v bun &> /dev/null; then exit 1 fi -# 2. Ensure Centrifugo is available -./scripts/setup-centrifugo.sh - -# 3. Start Centrifugo directly (Backend is mocked, but we need real WS) -echo "🚀 Starting Centrifugo..." -# Using the config from backend/config/centrifugo_config.json -CENTRIFUGO_CONFIG="src/backend/config/centrifugo_config.json" +# 2. Start mock WebSocket server +echo "🚀 Starting Mock WebSocket server..." +cat << 'EOF' > /tmp/mock-ws-server.mjs +import fs from 'fs'; + +const server = Bun.serve({ + port: 8000, + tls: { + key: Bun.file('temp/certs/server.key.pem'), + cert: Bun.file('temp/certs/server.cert.pem'), + }, + async fetch(req, server) { + const url = new URL(req.url); + if (url.pathname === '/health') { + return new Response('OK'); + } + if (req.method === 'POST' && url.pathname === '/api/internal/publish') { + const body = await req.text(); + try { + const parsed = JSON.parse(body); + if (parsed.channel) { + global.lastMessages = global.lastMessages || new Map(); + global.lastMessages.set(parsed.channel, body); + } + } catch (e) {} + server.publish('broadcast', body); + return new Response('OK'); + } + if (url.pathname === '/ws') { + const upgraded = server.upgrade(req); + if (upgraded) return; + return new Response('Upgrade failed', { status: 400 }); + } + return new Response('Not found', { status: 404 }); + }, + websocket: { + open(ws) { + ws.subscribe('broadcast'); + if (global.lastMessages) { + for (const msg of global.lastMessages.values()) { + ws.send(msg); + } + } + }, + message(ws, message) {}, + close(ws) {}, + } +}); +console.log('Mock WS server running on 8000'); +EOF # Generate self-signed certs for testing if missing mkdir -p temp/certs if [ ! -f "temp/certs/server.cert.pem" ] || [ ! -r "temp/certs/server.key.pem" ]; then echo "🔐 Generating self-signed certificates..." - # Check if old certs exist with wrong permissions - if [ -f "temp/certs/server.cert.pem" ] && [ ! -w "temp/certs/server.cert.pem" ]; then - echo "❌ Error: Old certificates exist with wrong permissions (likely created by root)" - echo " Please run: sudo rm -rf temp/certs" - exit 1 - fi rm -f temp/certs/server.cert.pem temp/certs/server.key.pem openssl req -newkey rsa:2048 -nodes -keyout temp/certs/server.key.pem -x509 -days 365 -out temp/certs/server.cert.pem -subj "/CN=localhost" 2>/dev/null chmod 644 temp/certs/server.key.pem temp/certs/server.cert.pem fi -# Env vars for Centrifugo -export CENTRIFUGO_HTTP_SERVER_TLS_CERT_PEM="temp/certs/server.cert.pem" -export CENTRIFUGO_HTTP_SERVER_TLS_KEY_PEM="temp/certs/server.key.pem" -export CENTRIFUGO_HTTP_SERVER_PORT="8000" -export CENTRIFUGO_CLIENT_TOKEN_HMAC_SECRET_KEY="secret" -export CENTRIFUGO_HTTP_API_KEY="api_key" -export CENTRIFUGO_LOG_LEVEL="info" - -./tools/centrifugo -c "$CENTRIFUGO_CONFIG" > /tmp/centrifugo.log 2>&1 & -CENTRIFUGO_PID=$! +bun run /tmp/mock-ws-server.mjs > /tmp/mock-ws.log 2>&1 & +MOCK_WS_PID=$! -echo "⏳ Waiting for Centrifugo..." +echo "⏳ Waiting for Mock WS Server..." for i in {1..30}; do if curl -k -s https://localhost:8000/health > /dev/null; then - echo "✅ Centrifugo is ready!" + echo "✅ Mock WS Server is ready!" break fi if [ $i -eq 30 ]; then - echo "❌ Centrifugo failed to start." - cat /tmp/centrifugo.log - kill $CENTRIFUGO_PID || true + echo "❌ Mock WS Server failed to start." + cat /tmp/mock-ws.log + kill $MOCK_WS_PID || true exit 1 fi sleep 1 @@ -94,7 +123,7 @@ if [ -d "dist" ]; then if [ ! -w "dist" ] || find dist -type d ! -writable 2>/dev/null | grep -q .; then echo "❌ Error: dist directory has wrong permissions (likely created by root)" echo " Please run: sudo rm -rf src/ui/dist" - kill $CENTRIFUGO_PID || true + kill $MOCK_WS_PID || true exit 1 fi fi @@ -102,19 +131,18 @@ fi # Build the frontend for preview mode (eliminates Vite dev optimization issues) # Note: Using default base path (/) for preview server, not /static for production backend echo "🏗️ Building frontend..." -# Faster polling for E2E tests +# Cap Core crux_time poll ticks (≤5s) to 500ms and one-shot timeout timers (>5s) +# to 2000ms so polls fire quickly while timeouts still allow multiple poll cycles first. export VITE_RECONNECTION_POLL_INTERVAL_MS=500 export VITE_NEW_IP_POLL_INTERVAL_MS=500 -export VITE_REBOOT_TIMEOUT_MS=2000 -export VITE_FACTORY_RESET_TIMEOUT_MS=2000 -export VITE_FIRMWARE_UPDATE_TIMEOUT_MS=2000 +export VITE_RECONNECTION_TIMEOUT_MS=2000 if bun run build-preview > /tmp/vite-build.log 2>&1; then echo "✅ Frontend build complete!" else echo "❌ Frontend build failed!" cat /tmp/vite-build.log - kill $CENTRIFUGO_PID || true + kill $MOCK_WS_PID || true exit 1 fi @@ -135,7 +163,7 @@ for i in {1..30}; do echo "❌ Preview server failed to start." cat /tmp/vite.log kill $FRONTEND_PID || true - kill $CENTRIFUGO_PID || true + kill $MOCK_WS_PID || true exit 1 fi sleep 1 @@ -149,7 +177,7 @@ if [ -d "test-results" ] && [ ! -w "test-results" ]; then echo "❌ Error: Playwright test-results directory has wrong permissions (likely created by root)" echo " Please run: sudo rm -rf src/ui/test-results src/ui/playwright-report" kill $FRONTEND_PID || true - kill $CENTRIFUGO_PID || true + kill $MOCK_WS_PID || true exit 1 fi diff --git a/scripts/setup-centrifugo.sh b/scripts/setup-centrifugo.sh deleted file mode 100755 index 7173a379..00000000 --- a/scripts/setup-centrifugo.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/bin/bash -# Download Centrifugo binary for local development - -set -e - -# Navigate to repository root (parent of scripts directory) -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$REPO_ROOT" - -CENTRIFUGO_VERSION="${CENTRIFUGO_VERSION:-v6.6.2}" -SCRIPT_DIR="tools" -CENTRIFUGO_BIN="$SCRIPT_DIR/centrifugo" - -# Create directory if it doesn't exist -mkdir -p "$SCRIPT_DIR" - -if [[ -f "$CENTRIFUGO_BIN" ]]; then - echo "Centrifugo already exists at $CENTRIFUGO_BIN" - "$CENTRIFUGO_BIN" version 2>/dev/null || true - exit 0 -fi - -echo "Downloading Centrifugo $CENTRIFUGO_VERSION..." - -# Detect OS and architecture -OS=$(uname -s | tr '[:upper:]' '[:lower:]') -ARCH=$(uname -m) - -case $ARCH in - x86_64) - ARCH="amd64" - ;; - aarch64|arm64) - ARCH="arm64" - ;; - *) - echo "Unsupported architecture: $ARCH" - exit 1 - ;; -esac - -# Download and extract -DOWNLOAD_URL="https://github.com/centrifugal/centrifugo/releases/download/${CENTRIFUGO_VERSION}/centrifugo_${CENTRIFUGO_VERSION#v}_${OS}_${ARCH}.tar.gz" - -echo "Downloading from: $DOWNLOAD_URL" -curl -sSL "$DOWNLOAD_URL" | tar -xz -C "$SCRIPT_DIR" centrifugo - -chmod +x "$CENTRIFUGO_BIN" -echo "Centrifugo installed successfully at $CENTRIFUGO_BIN" -"$CENTRIFUGO_BIN" version diff --git a/src/app/Cargo.toml b/src/app/Cargo.toml index 06dfc48d..e99fc6ab 100644 --- a/src/app/Cargo.toml +++ b/src/app/Cargo.toml @@ -2,9 +2,9 @@ name = "omnect-ui-core" description = "Crux Core for omnect-ui application" readme = "README.md" -edition = "2021" authors.workspace = true +edition.workspace = true homepage.workspace = true license.workspace = true repository.workspace = true @@ -15,7 +15,7 @@ crate-type = ["lib", "cdylib"] name = "omnect_ui_core" [features] -typegen = ["crux_core/typegen", "crux_http/typegen"] +typegen = ["crux_core/typegen", "crux_http/typegen", "crux_time/typegen"] [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("facet_typegen"))'] } @@ -23,15 +23,19 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("facet_typ [dependencies] base64 = { version = "0.22", default-features = false, features = ["alloc"] } console_log = { version = "1.0", default-features = false } -crux_core = { version = "0.17.0-rc2", default-features = false } -crux_http = { version = "0.16.0-rc2", default-features = false } -crux_macros = { version = "0.8.0-rc2", default-features = false } -lazy_static = { version = "1.4", default-features = false } +crux_core = { version = "0.17.0-rc3", default-features = false } +crux_http = { version = "0.16.0-rc3", default-features = false } +crux_macros = { version = "0.8.0-rc3", default-features = false } +crux_time = { version = "0.15.0-rc3", default-features = false } +hex = { version = "0.4", default-features = false, features = ["alloc"] } +hmac = { version = "0.12", default-features = false } log = { version = "0.4", default-features = false } +pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } serde = { version = "1.0", default-features = false, features = ["derive"] } serde_json = { version = "1.0", default-features = false } serde_repr = { version = "0.1", default-features = false } serde_valid = { version = "2.0", default-features = false } +sha1 = { version = "0.10", default-features = false } wasm-bindgen = { version = "0.2", default-features = false } [target.'cfg(target_arch = "wasm32")'.dependencies] @@ -41,4 +45,4 @@ getrandom = { version = "0.3", features = ["wasm_js"] } getrandom = { version = "0.3" } [build-dependencies] -crux_core = { version = "0.17.0-rc2", default-features = false, features = ["typegen"] } +crux_core = { version = "0.17.0-rc3", default-features = false, features = ["typegen"] } diff --git a/src/app/README.md b/src/app/README.md index 7303a484..7e42a054 100644 --- a/src/app/README.md +++ b/src/app/README.md @@ -9,37 +9,44 @@ The Crux Core follows the Model-View-Update pattern: - **Model** - The complete application state (auth, device info, network status, etc.) - **ViewModel** - Data needed by the UI to render - **Events** - Actions that can occur in the application -- **Effects** - Side effects (HTTP requests, WebSocket, rendering) +- **Effects** - Side effects (HTTP requests, WebSocket, Time/timers, rendering) ## Key Files - `src/lib.rs` - App struct, Effect enum, and re-exports - `src/model.rs` - Model and ViewModel structs - `src/events.rs` - Event enum definitions +- `src/wasm.rs` - WASM FFI bindings +- `src/macros.rs` - HTTP request macros (`auth_post!`, `unauth_post!`, `auth_post_basic!`, `http_get!`, `http_get_silent!`, `handle_response!`) +- `src/http_helpers.rs` - HTTP response handling helper functions +- `src/wifi_psk.rs` - WiFi PSK utilities - `src/types/` - Domain-based type definitions - `auth.rs` - Authentication types (AuthToken, password requests) + - `common.rs` - Common shared types - `device.rs` - Device information types (SystemInfo, HealthcheckInfo) - - `network.rs` - Network configuration types and state - `factory_reset.rs` - Factory reset types + - `network.rs` - Network configuration types and state + - `ods.rs` - ODS-specific DTOs + - `settings.rs` - Timeout settings types - `update.rs` - Update validation types - - `common.rs` - Common shared types -- `src/http_helpers.rs` - HTTP response handling helper functions -- `src/macros.rs` - HTTP request macros (`auth_post!`, `unauth_post!`, `auth_post_basic!`, `http_get!`, `http_get_silent!`, `handle_response!`) + - `websocket.rs` - WebSocket channel enum + - `wifi.rs` - WiFi types - `src/update/` - Domain-based event handlers - `mod.rs` - Main dispatcher - `auth.rs` - Authentication handlers (login, logout, password) + - `ui.rs` - UI action handlers (clear error/success) + - `websocket.rs` - WebSocket handlers + - `wifi.rs` - WiFi event handlers - `device/` - Device action handlers - `mod.rs` - Device event dispatcher - `operations.rs` - Device operations (reboot, factory reset, updates) - - `reconnection.rs` - Device reconnection handlers + - `reconnection.rs` - Reconnection polling and countdown, scheduled via `crux_time` - `network/` - Network configuration handlers - `mod.rs` - Module re-exports - `config.rs` - Network config request/response - `form.rs` - Form state management - - `verification.rs` - IP check and rollback logic - - `websocket.rs` - WebSocket/Centrifugo handlers - - `ui.rs` - UI action handlers (clear error/success) -- `src/commands/centrifugo.rs` - Custom WebSocket commands + - `verification.rs` - IP reachability check and rollback logic, scheduled via `crux_time` +- `src/commands/websocket.rs` - Custom WebSocket commands ## Building @@ -68,8 +75,6 @@ This will generate the WASM module in `src/ui/src/core/pkg/`. ### Generate TypeScript Types -Make sure pnpm is in your PATH, then: - ```bash cargo build -p shared_types ``` diff --git a/src/app/src/commands/centrifugo.rs b/src/app/src/commands/centrifugo.rs deleted file mode 100644 index c6479c4c..00000000 --- a/src/app/src/commands/centrifugo.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! Centrifugo command definitions. -//! -//! These types define the interface between the Core and the Shell for Centrifugo operations. - -use crux_core::{capability::Operation, command, Command}; -use serde::{Deserialize, Serialize}; -use std::marker::PhantomData; - -// Operations that the Shell needs to perform for Centrifugo -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum CentrifugoOperation { - Connect, - Disconnect, - Subscribe { channel: String }, - Unsubscribe { channel: String }, - SubscribeAll, - UnsubscribeAll, - History { channel: String }, -} - -// The output from Centrifugo operations (shell tells us what happened) -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum CentrifugoOutput { - Connected, - Disconnected, - Subscribed { - channel: String, - }, - Unsubscribed { - channel: String, - }, - Message { - channel: String, - data: String, - }, - HistoryResult { - channel: String, - data: Option, - }, - Error { - message: String, - }, -} - -impl Operation for CentrifugoOperation { - type Output = CentrifugoOutput; -} - -/// Command-based Centrifugo API -pub struct Centrifugo { - _effect: PhantomData, - _event: PhantomData, -} - -impl Centrifugo -where - Effect: Send + From> + 'static, - Event: Send + 'static, -{ - /// Connect to Centrifugo server - pub fn connect() -> RequestBuilder { - RequestBuilder::new(CentrifugoOperation::Connect) - } - - /// Disconnect from Centrifugo server - pub fn disconnect() -> RequestBuilder { - RequestBuilder::new(CentrifugoOperation::Disconnect) - } - - /// Subscribe to a specific channel - pub fn subscribe(channel: impl Into) -> RequestBuilder { - RequestBuilder::new(CentrifugoOperation::Subscribe { - channel: channel.into(), - }) - } - - /// Unsubscribe from a specific channel - pub fn unsubscribe(channel: impl Into) -> RequestBuilder { - RequestBuilder::new(CentrifugoOperation::Unsubscribe { - channel: channel.into(), - }) - } - - /// Subscribe to all known channels - pub fn subscribe_all() -> RequestBuilder { - RequestBuilder::new(CentrifugoOperation::SubscribeAll) - } - - /// Unsubscribe from all channels - pub fn unsubscribe_all() -> RequestBuilder { - RequestBuilder::new(CentrifugoOperation::UnsubscribeAll) - } - - /// Get history (last message) from a channel - pub fn history(channel: impl Into) -> RequestBuilder { - RequestBuilder::new(CentrifugoOperation::History { - channel: channel.into(), - }) - } -} - -/// Request builder for Centrifugo operations -#[must_use] -pub struct RequestBuilder { - operation: CentrifugoOperation, - _effect: PhantomData, - _event: PhantomData Event>, -} - -impl RequestBuilder -where - Effect: Send + From> + 'static, - Event: Send + 'static, -{ - fn new(operation: CentrifugoOperation) -> Self { - Self { - operation, - _effect: PhantomData, - _event: PhantomData, - } - } - - /// Build the request into a Command RequestBuilder - pub fn build( - self, - ) -> command::RequestBuilder> - { - command::RequestBuilder::new(move |ctx| async move { - Command::request_from_shell(self.operation) - .into_future(ctx) - .await - }) - } -} diff --git a/src/app/src/commands/mod.rs b/src/app/src/commands/mod.rs index af4d012e..6eba44d9 100644 --- a/src/app/src/commands/mod.rs +++ b/src/app/src/commands/mod.rs @@ -1 +1 @@ -pub mod centrifugo; +pub mod websocket; diff --git a/src/app/src/commands/websocket.rs b/src/app/src/commands/websocket.rs new file mode 100644 index 00000000..2d29183e --- /dev/null +++ b/src/app/src/commands/websocket.rs @@ -0,0 +1,128 @@ +//! WebSocket command definitions. +//! +//! These types define the interface between the Core and the Shell for WebSocket operations. + +use crate::types::websocket::WebSocketChannel; +use crux_core::{Command, capability::Operation, command}; +use serde::{Deserialize, Serialize}; +use std::marker::PhantomData; + +// Operations that the Shell needs to perform for WebSocket +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum WebSocketOperation { + Connect, + Disconnect, + Subscribe { channel: WebSocketChannel }, + Unsubscribe { channel: WebSocketChannel }, + SubscribeAll, + UnsubscribeAll, + History { channel: WebSocketChannel }, +} + +// The output from WebSocket operations (shell tells us what happened) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum WebSocketOutput { + Connected, + Disconnected, + Subscribed { + channel: WebSocketChannel, + }, + Unsubscribed { + channel: WebSocketChannel, + }, + Message { + channel: WebSocketChannel, + data: String, + }, + HistoryResult { + channel: WebSocketChannel, + data: Option, + }, + Error { + message: String, + }, +} + +impl Operation for WebSocketOperation { + type Output = WebSocketOutput; +} + +/// Command-based WebSocket API +pub struct WebSocket { + _effect: PhantomData, + _event: PhantomData, +} + +impl WebSocket +where + Effect: Send + From> + 'static, + Event: Send + 'static, +{ + /// Connect to WebSocket server + pub fn connect() -> RequestBuilder { + RequestBuilder::new(WebSocketOperation::Connect) + } + + /// Disconnect from WebSocket server + pub fn disconnect() -> RequestBuilder { + RequestBuilder::new(WebSocketOperation::Disconnect) + } + + /// Subscribe to a specific channel + pub fn subscribe(channel: WebSocketChannel) -> RequestBuilder { + RequestBuilder::new(WebSocketOperation::Subscribe { channel }) + } + + /// Unsubscribe from a specific channel + pub fn unsubscribe(channel: WebSocketChannel) -> RequestBuilder { + RequestBuilder::new(WebSocketOperation::Unsubscribe { channel }) + } + + /// Subscribe to all known channels + pub fn subscribe_all() -> RequestBuilder { + RequestBuilder::new(WebSocketOperation::SubscribeAll) + } + + /// Unsubscribe from all channels + pub fn unsubscribe_all() -> RequestBuilder { + RequestBuilder::new(WebSocketOperation::UnsubscribeAll) + } + + /// Get history (last message) from a channel + pub fn history(channel: WebSocketChannel) -> RequestBuilder { + RequestBuilder::new(WebSocketOperation::History { channel }) + } +} + +/// Request builder for WebSocket operations +#[must_use] +pub struct RequestBuilder { + operation: WebSocketOperation, + _effect: PhantomData, + _event: PhantomData Event>, +} + +impl RequestBuilder +where + Effect: Send + From> + 'static, + Event: Send + 'static, +{ + fn new(operation: WebSocketOperation) -> Self { + Self { + operation, + _effect: PhantomData, + _event: PhantomData, + } + } + + /// Build the request into a Command RequestBuilder + pub fn build( + self, + ) -> command::RequestBuilder> { + command::RequestBuilder::new(move |ctx| async move { + Command::request_from_shell(self.operation) + .into_future(ctx) + .await + }) + } +} diff --git a/src/app/src/events.rs b/src/app/src/events.rs index b803f41e..3a876b6d 100644 --- a/src/app/src/events.rs +++ b/src/app/src/events.rs @@ -18,6 +18,7 @@ pub enum AuthEvent { password: String, }, CheckRequiresPasswordSet, + RestoreSession(String), #[serde(skip)] LoginResponse(Result), #[serde(skip)] @@ -60,9 +61,12 @@ pub enum DeviceEvent { RunUpdate { validate_iothub_connection: bool, }, + FetchInitialHealthcheck, ReconnectionCheckTick, + ReconnectionCountdownTick, ReconnectionTimeout, NewIpCheckTick, + NewIpCountdownTick, NewIpCheckTimeout, AckRollback, AckFactoryResetResult, @@ -87,7 +91,7 @@ pub enum DeviceEvent { AckUpdateValidationResponse(Result<(), String>), } -/// WebSocket/Centrifugo events +/// WebSocket/WebSocket events #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub enum WebSocketEvent { SubscribeToChannels, @@ -102,12 +106,97 @@ 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>), +} + +/// 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 { ClearError, ClearSuccess, SetBrowserHostname(String), + LoadSettings, + SaveSettings(crate::types::TimeoutSettings), + #[serde(skip)] + LoadSettingsResponse(Result), + #[serde(skip)] + SaveSettingsResponse(Result<(), String>), } /// Main event enum - wraps domain events @@ -118,6 +207,7 @@ pub enum Event { Device(DeviceEvent), WebSocket(WebSocketEvent), Ui(UiEvent), + Wifi(WifiEvent), } /// Custom Debug implementation for AuthEvent to redact sensitive data @@ -149,6 +239,7 @@ impl fmt::Debug for AuthEvent { }, AuthEvent::Logout => write!(f, "Logout"), AuthEvent::CheckRequiresPasswordSet => write!(f, "CheckRequiresPasswordSet"), + AuthEvent::RestoreSession(_) => write!(f, "RestoreSession()"), AuthEvent::LogoutResponse(r) => f.debug_tuple("LogoutResponse").field(r).finish(), AuthEvent::SetPasswordResponse(result) => match result { Ok(_) => f @@ -180,6 +271,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/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/lib.rs b/src/app/src/lib.rs index 8ac8fb1a..aa58e81d 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; @@ -13,12 +14,12 @@ use crux_core::Command; // Re-export core types pub use crate::{ - commands::centrifugo::{CentrifugoOperation, CentrifugoOutput}, + commands::websocket::{WebSocketOperation, WebSocketOutput}, events::Event, http_helpers::{ - build_url, check_response_status, extract_error_message, extract_string_response, + BASE_URL, 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, process_json_response, process_status_response, }, model::Model, types::*, @@ -29,11 +30,13 @@ pub use crux_http::Result as HttpResult; pub enum Effect { Render(crux_core::render::RenderOperation), Http(crux_http::protocol::HttpRequest), - Centrifugo(CentrifugoOperation), + WebSocket(WebSocketOperation), + Time(crux_time::protocol::TimeRequest), } -pub type CentrifugoCmd = crate::commands::centrifugo::Centrifugo; +pub type WebSocketCmd = crate::commands::websocket::WebSocket; pub type HttpCmd = crux_http::command::Http; +pub type TimeCmd = crux_time::command::Time; /// The Core application #[derive(Default)] diff --git a/src/app/src/macros.rs b/src/app/src/macros.rs index a1b32010..5c96ba51 100644 --- a/src/app/src/macros.rs +++ b/src/app/src/macros.rs @@ -42,9 +42,10 @@ macro_rules! update_field { // Re-export http_helpers functions for macro use pub use crate::http_helpers::{ - build_url, check_response_status, extract_error_message, extract_string_response, + BASE_URL, 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, }; /// Macro for unauthenticated POST requests with standard error handling. @@ -420,6 +421,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..6a6c160f 100644 --- a/src/app/src/model.rs +++ b/src/app/src/model.rs @@ -24,6 +24,9 @@ pub struct Model { pub timeouts: Option, pub healthcheck: Option, + // User-configurable timeout settings + pub timeout_settings: TimeoutSettings, + // Authentication state /// Auth token for API requests pub auth_token: Option, @@ -62,11 +65,18 @@ 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, // Overlay spinner state pub overlay_spinner: OverlaySpinnerState, + + // WiFi state + pub wifi_state: WifiState, } impl Model { diff --git a/src/app/src/types/common.rs b/src/app/src/types/common.rs index 56799422..b0f4d603 100644 --- a/src/app/src/types/common.rs +++ b/src/app/src/types/common.rs @@ -26,10 +26,10 @@ pub struct OnlineStatus { pub iothub: bool, } -/// Duration type for timeouts +/// TimeoutDuration type for timeouts #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct Duration { +pub struct TimeoutDuration { pub nanos: u32, pub secs: u64, } @@ -38,7 +38,7 @@ pub struct Duration { #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Timeouts { - pub wait_online_timeout: Duration, + pub wait_online_timeout: TimeoutDuration, } /// Overlay spinner state (UI state) @@ -159,4 +159,11 @@ impl OverlaySpinnerState { pub fn countdown_seconds(&self) -> Option { self.countdown_seconds } + + /// Decrement countdown by one second (saturating at zero) + pub fn decrement_countdown(&mut self) { + if let Some(s) = self.countdown_seconds { + self.countdown_seconds = Some(s.saturating_sub(1)); + } + } } diff --git a/src/app/src/types/mod.rs b/src/app/src/types/mod.rs index 00a2a0ce..fd5d0500 100644 --- a/src/app/src/types/mod.rs +++ b/src/app/src/types/mod.rs @@ -17,7 +17,10 @@ pub mod device; pub mod factory_reset; pub mod network; pub mod ods; +pub mod settings; pub mod update; +pub mod websocket; +pub mod wifi; // Re-export all types for backward compatibility pub use auth::*; @@ -26,4 +29,7 @@ pub use device::*; pub use factory_reset::*; pub use network::*; pub use ods::*; +pub use settings::*; pub use update::*; +pub use websocket::*; +pub use wifi::*; diff --git a/src/app/src/types/network.rs b/src/app/src/types/network.rs index 7d7ffb23..db08b2c4 100644 --- a/src/app/src/types/network.rs +++ b/src/app/src/types/network.rs @@ -74,10 +74,10 @@ pub fn subnet_to_cidr(subnet: &str) -> Option { /// Accepts "/24" or "24" format, returns prefix length if valid pub fn parse_netmask(mask: &str) -> Option { let cleaned = mask.trim_start_matches('/'); - if let Ok(prefix_len) = cleaned.parse::() { - if prefix_len <= 32 { - return Some(prefix_len); - } + if let Ok(prefix_len) = cleaned.parse::() + && prefix_len <= 32 + { + return Some(prefix_len); } None } diff --git a/src/app/src/types/ods.rs b/src/app/src/types/ods.rs index feb238e0..23ceaf13 100644 --- a/src/app/src/types/ods.rs +++ b/src/app/src/types/ods.rs @@ -1,7 +1,7 @@ //! External Data Transfer Objects (DTOs) for omnect-device-service (ODS) //! //! These types represent the "wire format" of JSON payloads received from ODS -//! over WebSocket/Centrifugo. +//! over WebSocket/WebSocket. //! //! ### Why separate types? //! 1. **Wire Format Isolation**: ODS uses `snake_case` variants, while our internal @@ -15,8 +15,8 @@ use serde::{Deserialize, Serialize}; use serde_repr::Deserialize_repr; use crate::types::{ - DeviceNetwork, Duration, FactoryReset, FactoryResetResult, FactoryResetStatus, - InternetProtocol, IpAddress, NetworkStatus, OnlineStatus, OsInfo, SystemInfo, Timeouts, + DeviceNetwork, FactoryReset, FactoryResetResult, FactoryResetStatus, InternetProtocol, + IpAddress, NetworkStatus, OnlineStatus, OsInfo, SystemInfo, TimeoutDuration, Timeouts, UpdateValidationStatus, }; @@ -85,7 +85,7 @@ pub struct OdsTimeouts { impl From for Timeouts { fn from(ods: OdsTimeouts) -> Self { Self { - wait_online_timeout: Duration { + wait_online_timeout: TimeoutDuration { nanos: ods.wait_online_timeout.nanos as u32, secs: ods.wait_online_timeout.secs, }, diff --git a/src/app/src/types/settings.rs b/src/app/src/types/settings.rs new file mode 100644 index 00000000..eacfc3ed --- /dev/null +++ b/src/app/src/types/settings.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; + +pub const DEFAULT_REBOOT_TIMEOUT_SECS: u32 = 300; +pub const DEFAULT_FACTORY_RESET_TIMEOUT_SECS: u32 = 600; +pub const DEFAULT_FIRMWARE_UPDATE_TIMEOUT_SECS: u32 = 900; +pub const DEFAULT_NETWORK_ROLLBACK_TIMEOUT_SECS: u32 = 90; + +/// User-configurable timeout settings for device operations +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TimeoutSettings { + pub reboot_timeout_secs: u32, + pub factory_reset_timeout_secs: u32, + pub firmware_update_timeout_secs: u32, + pub network_rollback_timeout_secs: u32, +} + +impl Default for TimeoutSettings { + fn default() -> Self { + Self { + reboot_timeout_secs: DEFAULT_REBOOT_TIMEOUT_SECS, + factory_reset_timeout_secs: DEFAULT_FACTORY_RESET_TIMEOUT_SECS, + firmware_update_timeout_secs: DEFAULT_FIRMWARE_UPDATE_TIMEOUT_SECS, + network_rollback_timeout_secs: DEFAULT_NETWORK_ROLLBACK_TIMEOUT_SECS, + } + } +} diff --git a/src/app/src/types/websocket.rs b/src/app/src/types/websocket.rs new file mode 100644 index 00000000..a8f87967 --- /dev/null +++ b/src/app/src/types/websocket.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum WebSocketChannel { + #[serde(rename = "OnlineStatusV1")] + OnlineStatus, + #[serde(rename = "SystemInfoV1")] + SystemInfo, + #[serde(rename = "TimeoutsV1")] + Timeouts, + #[serde(rename = "NetworkStatusV1")] + NetworkStatus, + #[serde(rename = "FactoryResetV1")] + FactoryReset, + #[serde(rename = "UpdateValidationStatusV1")] + UpdateStatus, +} diff --git a/src/app/src/types/wifi.rs b/src/app/src/types/wifi.rs new file mode 100644 index 00000000..626e9f7c --- /dev/null +++ b/src/app/src/types/wifi.rs @@ -0,0 +1,152 @@ +use serde::{Deserialize, Serialize}; + +/// WiFi service availability info returned by the backend +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "state", rename_all = "camelCase")] +pub enum WifiAvailability { + Available { + version: String, + interface_name: String, + }, + Unavailable { + socket_present: bool, + version: Option, + min_required_version: String, + }, +} + +/// 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 ch: 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] + Unknown, + Unavailable { + socket_present: bool, + version: Option, + min_required_version: String, + }, + Ready { + interface_name: String, + version: Option, + status: WifiConnectionStatus, + scan_state: WifiScanState, + scan_results: Vec, + saved_networks: Vec, + scan_poll_attempt: u32, + connect_poll_attempt: u32, + }, +} + +// --- API Response Types --- + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct WifiConnectRequest { + pub ssid: String, + pub psk: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct WifiForgetRequest { + pub ssid: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct WifiScanStartedResponse { + pub status: String, + pub state: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct WifiScanResultsResponse { + pub status: String, + pub state: String, + pub networks: Vec, +} + +// WifiNetwork is defined above in the file + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct WifiConnectResponse { + pub status: String, + pub state: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct WifiDisconnectResponse { + pub status: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct WifiStatusResponse { + pub status: String, + pub state: String, + pub ssid: Option, + pub ip_address: Option, + pub interface_name: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct WifiSavedNetworksResponse { + pub status: String, + pub networks: Vec, +} + +// WifiSavedNetwork is defined above + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct WifiForgetResponse { + pub status: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct WifiVersionResponse { + pub version: String, +} diff --git a/src/app/src/update/auth.rs b/src/app/src/update/auth.rs index b83cb1c1..502386d1 100644 --- a/src/app/src/update/auth.rs +++ b/src/app/src/update/auth.rs @@ -1,13 +1,13 @@ use base64::prelude::*; -use crux_core::Command; +use crux_core::{Command, render::render}; use crate::{ - auth_post, auth_post_basic, - events::{AuthEvent, Event}, + Effect, auth_post, auth_post_basic, + events::{AuthEvent, Event, WifiEvent}, handle_response, model::Model, - types::{AuthToken, SetPasswordRequest, UpdatePasswordRequest}, - unauth_post, Effect, + types::{AuthToken, SetPasswordRequest, UpdatePasswordRequest, WifiState}, + unauth_post, }; /// Handle authentication-related events @@ -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, @@ -82,6 +98,25 @@ pub fn handle(event: AuthEvent, model: &mut Model) -> Command { model.requires_password_set = requires; }, }), + + AuthEvent::RestoreSession(token) => { + model.auth_token = Some(token); + model.is_authenticated = true; + post_auth_commands(model) + } + } +} + +/// 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() } } @@ -92,6 +127,38 @@ mod tests { mod login { use super::*; + #[test] + fn posts_to_login_endpoint() { + let mut model = Model::default(); + let mut cmd = handle( + AuthEvent::Login { + password: "test_password".into(), + }, + &mut model, + ); + + // Login produces render + http effects + let effects = [cmd.expect_effect(), cmd.expect_effect()]; + let http_req = effects + .into_iter() + .find_map(|e| match e { + Effect::Http(req) => Some(req), + _ => None, + }) + .expect("Expected Http effect"); + let (http_request, _) = http_req.split(); + + assert_eq!(http_request.url, "https://relative/token/login"); + assert_eq!(http_request.method, "POST"); + assert!( + http_request + .headers + .iter() + .any(|h| h.name.eq_ignore_ascii_case("authorization") + && h.value.starts_with("Basic ")) + ); + } + #[test] fn sets_loading_state() { let mut model = Model::default(); @@ -166,6 +233,36 @@ mod tests { mod logout { use super::*; + #[test] + fn posts_to_logout_endpoint() { + let mut model = Model { + is_authenticated: true, + auth_token: Some("bearer-token".into()), + ..Default::default() + }; + let mut cmd = handle(AuthEvent::Logout, &mut model); + + let effects = [cmd.expect_effect(), cmd.expect_effect()]; + let http_req = effects + .into_iter() + .find_map(|e| match e { + Effect::Http(req) => Some(req), + _ => None, + }) + .expect("Expected Http effect"); + let (http_request, _) = http_req.split(); + + assert_eq!(http_request.url, "https://relative/logout"); + assert_eq!(http_request.method, "POST"); + assert!( + http_request + .headers + .iter() + .any(|h| h.name.eq_ignore_ascii_case("authorization") + && h.value.starts_with("Bearer ")) + ); + } + #[test] fn sets_loading_state() { let mut model = Model { @@ -346,9 +443,47 @@ mod tests { } } + mod restore_session { + use super::*; + + #[test] + fn sets_authenticated_and_stores_token() { + let mut model = Model::default(); + + let _ = handle( + AuthEvent::RestoreSession("restored-token-123".into()), + &mut model, + ); + + assert!(model.is_authenticated); + assert_eq!(model.auth_token, Some("restored-token-123".into())); + assert!(!model.is_loading); + assert!(model.error_message.is_none()); + } + } + mod check_requires_password_set { use super::*; + #[test] + fn sends_get_to_check_endpoint() { + let mut model = Model::default(); + let mut cmd = handle(AuthEvent::CheckRequiresPasswordSet, &mut model); + + let effects = [cmd.expect_effect(), cmd.expect_effect()]; + let http_req = effects + .into_iter() + .find_map(|e| match e { + Effect::Http(req) => Some(req), + _ => None, + }) + .expect("Expected Http effect"); + let (http_request, _) = http_req.split(); + + assert_eq!(http_request.url, "https://relative/require-set-password"); + assert_eq!(http_request.method, "GET"); + } + #[test] fn sets_loading_state() { let mut model = Model::default(); diff --git a/src/app/src/update/device/mod.rs b/src/app/src/update/device/mod.rs index 9d13c2cc..7be97eb8 100644 --- a/src/app/src/update/device/mod.rs +++ b/src/app/src/update/device/mod.rs @@ -5,17 +5,19 @@ mod reconnection; pub use network::{ handle_ack_factory_reset_result, handle_ack_rollback, handle_ack_update_validation, handle_network_form_start_edit, handle_network_form_update, handle_new_ip_check_tick, - handle_new_ip_check_timeout, handle_set_network_config, handle_set_network_config_response, + handle_new_ip_check_timeout, handle_new_ip_countdown_tick, handle_set_network_config, + handle_set_network_config_response, }; pub use operations::handle_device_operation_response; pub use reconnection::{ - handle_healthcheck_response, handle_reconnection_check_tick, handle_reconnection_timeout, + handle_healthcheck_response, handle_reconnection_check_tick, + handle_reconnection_countdown_tick, handle_reconnection_timeout, }; use crux_core::Command; use crate::{ - auth_post, + Effect, auth_post, events::{DeviceEvent, Event}, handle_response, model::Model, @@ -23,7 +25,6 @@ use crate::{ DeviceOperationState, FactoryResetRequest, LoadUpdateRequest, OverlaySpinnerState, RunUpdateRequest, UpdateManifest, UploadState, }, - Effect, }; /// Handle device action events (reboot, factory reset, network, updates) @@ -116,13 +117,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,16 +156,18 @@ 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) - // Shell sends these tick events based on watching device_operation_state + // Device reconnection events - tick events scheduled by Core via crux_time DeviceEvent::ReconnectionCheckTick => handle_reconnection_check_tick(model), + DeviceEvent::ReconnectionCountdownTick => handle_reconnection_countdown_tick(model), DeviceEvent::ReconnectionTimeout => handle_reconnection_timeout(model), - // Network IP change events - // Shell sends these tick events based on watching network_change_state + // Network IP change events - tick events scheduled by Core via crux_time DeviceEvent::NewIpCheckTick => handle_new_ip_check_tick(model), + DeviceEvent::NewIpCountdownTick => handle_new_ip_countdown_tick(model), DeviceEvent::NewIpCheckTimeout => handle_new_ip_check_timeout(model), // Acknowledge events @@ -178,21 +175,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,16 +192,46 @@ 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::*; + use crate::UpdateManifest; use crate::events::DeviceEvent; use crate::types::{DeviceOperationState, UploadState}; - use crate::UpdateManifest; mod reboot { use super::*; - use crate::update::device::operations::REBOOT_TIMEOUT_SECS; + use crate::types::DEFAULT_REBOOT_TIMEOUT_SECS; #[test] fn success_sets_rebooting_state() { @@ -236,7 +251,7 @@ mod tests { assert!(model.overlay_spinner.is_visible()); assert_eq!( model.overlay_spinner.countdown_seconds(), - Some(REBOOT_TIMEOUT_SECS) + Some(DEFAULT_REBOOT_TIMEOUT_SECS) ); } @@ -299,11 +314,13 @@ mod tests { assert!(!model.is_loading); assert!(model.error_message.is_some()); - assert!(model - .error_message - .as_ref() - .unwrap() - .contains("Invalid factory reset mode")); + assert!( + model + .error_message + .as_ref() + .unwrap() + .contains("Invalid factory reset mode") + ); } #[test] @@ -413,11 +430,13 @@ mod tests { UploadState::Failed(_) )); assert!(model.error_message.is_some()); - assert!(model - .error_message - .as_ref() - .unwrap() - .contains("Upload failed")); + assert!( + model + .error_message + .as_ref() + .unwrap() + .contains("Upload failed") + ); assert!(!model.overlay_spinner.is_visible()); } } diff --git a/src/app/src/update/device/network/config.rs b/src/app/src/update/device/network/config.rs index 59a38a67..dc783ce2 100644 --- a/src/app/src/update/device/network/config.rs +++ b/src/app/src/update/device/network/config.rs @@ -2,14 +2,16 @@ use crux_core::Command; use std::collections::HashMap; use crate::{ - auth_post, + Effect, auth_post, events::Event, model::Model, - types::{subnet_to_cidr, NetworkChangeState, NetworkConfigRequest, NetworkFormState}, - Effect, + types::{NetworkChangeState, NetworkConfigRequest, NetworkFormState, subnet_to_cidr}, }; -use super::verification::update_network_state_and_spinner; +use super::verification::{ + schedule_new_ip_check_timeout, schedule_new_ip_countdown_tick, schedule_new_ip_poll, + update_network_state_and_spinner, +}; /// Success message for network configuration update const NETWORK_CONFIG_SUCCESS: &str = "Network configuration updated"; @@ -24,10 +26,10 @@ pub fn handle_set_network_config(config: String, model: &mut Model) -> Command { + // Static IP change: poll for new IP + timeout/countdown if rollback enabled + let rollback = *rollback_timeout_seconds; + if rollback > 0 { + Command::all([ + crux_core::render::render(), + schedule_new_ip_poll(), + schedule_new_ip_check_timeout(rollback), + schedule_new_ip_countdown_tick(), + ]) + } else { + Command::all([crux_core::render::render(), schedule_new_ip_poll()]) + } + } + NetworkChangeState::WaitingForNewIp { + switching_to_dhcp: true, + rollback_timeout_seconds, + .. + } if *rollback_timeout_seconds > 0 => { + // DHCP change with rollback: no polling (IP unknown) but timeout + countdown + let rollback = *rollback_timeout_seconds; + Command::all([ + crux_core::render::render(), + schedule_new_ip_check_timeout(rollback), + schedule_new_ip_countdown_tick(), + ]) + } + _ => crux_core::render::render(), + } } Err(e) => { model.set_error(e); diff --git a/src/app/src/update/device/network/form.rs b/src/app/src/update/device/network/form.rs index 1e748392..23e27828 100644 --- a/src/app/src/update/device/network/form.rs +++ b/src/app/src/update/device/network/form.rs @@ -1,10 +1,10 @@ use crux_core::Command; use std::collections::HashMap; +use crate::Effect; use crate::events::Event; use crate::model::Model; -use crate::types::{is_valid_ipv4, subnet_to_cidr, NetworkFormData, NetworkFormState}; -use crate::Effect; +use crate::types::{NetworkFormData, NetworkFormState, is_valid_ipv4, subnet_to_cidr}; /// Handle network form start edit - initialize form with current network adapter data pub fn handle_network_form_start_edit( @@ -12,26 +12,25 @@ pub fn handle_network_form_start_edit( model: &mut Model, ) -> Command { // Find the network adapter and copy its data to form state - if let Some(network_status) = &model.network_status { - if let Some(adapter) = network_status + if let Some(network_status) = &model.network_status + && let Some(adapter) = network_status .network_status .iter() .find(|n| n.name == adapter_name) - { - let form_data = NetworkFormData::from(adapter); - - model.network_form_state = NetworkFormState::Editing { - adapter_name: adapter_name.clone(), - form_data: form_data.clone(), - original_data: form_data, - errors: HashMap::new(), - }; - // Clear dirty flag when starting a fresh edit - model.network_form_dirty = false; - // Clear rollback modal flags - model.should_show_rollback_modal = false; - model.default_rollback_enabled = false; - } + { + let form_data = NetworkFormData::from(adapter); + + model.network_form_state = NetworkFormState::Editing { + adapter_name: adapter_name.clone(), + form_data: form_data.clone(), + original_data: form_data, + errors: HashMap::new(), + }; + // Clear dirty flag when starting a fresh edit + model.network_form_dirty = false; + // Clear rollback modal flags + model.should_show_rollback_modal = false; + model.default_rollback_enabled = false; } crux_core::render::render() diff --git a/src/app/src/update/device/network/mod.rs b/src/app/src/update/device/network/mod.rs index dde482d7..39cdaac0 100644 --- a/src/app/src/update/device/network/mod.rs +++ b/src/app/src/update/device/network/mod.rs @@ -6,7 +6,7 @@ pub use config::{handle_set_network_config, handle_set_network_config_response}; pub use form::{handle_network_form_start_edit, handle_network_form_update}; pub use verification::{ handle_ack_factory_reset_result, handle_ack_rollback, handle_ack_update_validation, - handle_new_ip_check_tick, handle_new_ip_check_timeout, + handle_new_ip_check_tick, handle_new_ip_check_timeout, handle_new_ip_countdown_tick, }; /* diff --git a/src/app/src/update/device/network/verification.rs b/src/app/src/update/device/network/verification.rs index e3c90ae7..e5574252 100644 --- a/src/app/src/update/device/network/verification.rs +++ b/src/app/src/update/device/network/verification.rs @@ -1,13 +1,51 @@ use crux_core::Command; use crate::{ + Effect, TimeCmd, events::{DeviceEvent, Event, UiEvent}, http_get_silent, model::Model, types::{HealthcheckInfo, NetworkChangeState, OverlaySpinnerState}, - unauth_post, Effect, + unauth_post, }; +/// New IP poll interval +const NEW_IP_POLL_INTERVAL_MS: u64 = 5000; + +/// New IP countdown tick interval (1 second) +const NEW_IP_COUNTDOWN_INTERVAL_MS: u64 = 1000; + +pub(super) fn schedule_new_ip_check_timeout(secs: u64) -> Command { + let (timer, handle) = TimeCmd::notify_after(std::time::Duration::from_secs(secs)); + std::mem::forget(handle); + timer.then_send(|_| Event::Device(DeviceEvent::NewIpCheckTimeout)) +} + +pub(super) fn schedule_new_ip_countdown_tick() -> Command { + let (timer, handle) = TimeCmd::notify_after(std::time::Duration::from_millis( + NEW_IP_COUNTDOWN_INTERVAL_MS, + )); + std::mem::forget(handle); + timer.then_send(|_| Event::Device(DeviceEvent::NewIpCountdownTick)) +} + +/// Handle new IP countdown tick - decrements the displayed countdown each second +pub fn handle_new_ip_countdown_tick(model: &mut Model) -> Command { + let is_active = matches!( + model.network_change_state, + NetworkChangeState::WaitingForNewIp { .. } + ); + if is_active && model.overlay_spinner.countdown_seconds() > Some(0) { + model.overlay_spinner.decrement_countdown(); + Command::all([ + crux_core::render::render(), + schedule_new_ip_countdown_tick(), + ]) + } else { + Command::done() + } +} + /// Helper to update network state and spinner based on configuration response pub fn update_network_state_and_spinner( model: &mut Model, @@ -77,15 +115,18 @@ pub fn handle_new_ip_check_tick(model: &mut Model) -> Command { // If switching to DHCP, we don't know the new IP, so we can't poll it. // We just wait for the timeout (rollback) or for the user to manually navigate. if !*switching_to_dhcp { - // Try to reach the new IP + // Try to reach the new IP, then reschedule let url = format!("https://{new_ip}:{ui_port}/healthcheck"); - http_get_silent!( - url, - on_success: Event::Device(DeviceEvent::HealthcheckResponse(Ok( - HealthcheckInfo::default() - ))), - on_error: Event::Ui(UiEvent::ClearSuccess) - ) + Command::all([ + http_get_silent!( + url, + on_success: Event::Device(DeviceEvent::HealthcheckResponse(Ok( + HealthcheckInfo::default() + ))), + on_error: Event::Ui(UiEvent::ClearSuccess) + ), + schedule_new_ip_poll(), + ]) } else { crux_core::render::render() } @@ -96,17 +137,19 @@ pub fn handle_new_ip_check_tick(model: &mut Model) -> Command { attempt, } => { *attempt += 1; - // Poll the old IP to see if rollback completed + // Poll the old IP to see if rollback completed, then reschedule let url = format!("https://{old_ip}:{ui_port}/healthcheck"); - // Use http_get! to parse the response body (needed for network_rollback_occurred flag) use crate::http_get; - http_get!( - Device, - DeviceEvent, - &url, - HealthcheckResponse, - crate::types::HealthcheckInfo - ) + Command::all([ + http_get!( + Device, + DeviceEvent, + &url, + HealthcheckResponse, + crate::types::HealthcheckInfo + ), + schedule_new_ip_poll(), + ]) } _ => crux_core::render::render(), } @@ -114,6 +157,8 @@ pub fn handle_new_ip_check_tick(model: &mut Model) -> Command { /// Handle new IP check timeout - new IP didn't become reachable in time pub fn handle_new_ip_check_timeout(model: &mut Model) -> Command { + let mut start_old_ip_poll = false; + if let NetworkChangeState::WaitingForNewIp { new_ip, old_ip, @@ -134,6 +179,7 @@ pub fn handle_new_ip_check_timeout(model: &mut Model) -> Command .set_text("Rollback in progress. Verifying original address..."); // Ensure spinner is spinning (not timed out state) model.overlay_spinner.set_loading(); + start_old_ip_poll = true; } else { model.network_change_state = NetworkChangeState::NewIpTimeout { new_ip: new_ip.clone(), @@ -150,7 +196,18 @@ pub fn handle_new_ip_check_timeout(model: &mut Model) -> Command } } - crux_core::render::render() + if start_old_ip_poll { + Command::all([crux_core::render::render(), schedule_new_ip_poll()]) + } else { + crux_core::render::render() + } +} + +pub(super) fn schedule_new_ip_poll() -> Command { + let (timer, handle) = + TimeCmd::notify_after(std::time::Duration::from_millis(NEW_IP_POLL_INTERVAL_MS)); + std::mem::forget(handle); + timer.then_send(|_| Event::Device(DeviceEvent::NewIpCheckTick)) } /// Handle acknowledge factory reset result - clear the result @@ -531,4 +588,89 @@ mod tests { assert!(model.error_message.is_some()); } } + + mod schedule_timers { + use super::*; + use crux_time::protocol::{Duration as TimeDuration, TimeRequest}; + + fn find_time_effect(cmd: &mut Command) -> Option { + cmd.effects().find_map(|e| { + if let Effect::Time(_) = e { + let (time_request, _) = e.expect_time().split(); + Some(time_request) + } else { + None + } + }) + } + + #[test] + fn schedule_new_ip_check_timeout_emits_time_effect_with_correct_duration() { + let mut cmd = schedule_new_ip_check_timeout(120); + let time_request = find_time_effect(&mut cmd).expect("expected Time effect"); + let TimeRequest::NotifyAfter { duration, .. } = time_request else { + panic!("expected NotifyAfter"); + }; + assert_eq!(duration, TimeDuration::from_secs(120)); + } + + #[test] + fn schedule_new_ip_countdown_tick_emits_1000ms_time_effect() { + let mut cmd = schedule_new_ip_countdown_tick(); + let time_request = find_time_effect(&mut cmd).expect("expected Time effect"); + let TimeRequest::NotifyAfter { duration, .. } = time_request else { + panic!("expected NotifyAfter"); + }; + assert_eq!( + duration, + TimeDuration::from_millis(NEW_IP_COUNTDOWN_INTERVAL_MS) + ); + } + + #[test] + fn schedule_new_ip_poll_emits_5000ms_time_effect() { + let mut cmd = schedule_new_ip_poll(); + let time_request = find_time_effect(&mut cmd).expect("expected Time effect"); + let TimeRequest::NotifyAfter { duration, .. } = time_request else { + panic!("expected NotifyAfter"); + }; + assert_eq!(duration, TimeDuration::from_millis(NEW_IP_POLL_INTERVAL_MS)); + } + + #[test] + fn new_ip_countdown_tick_reschedules_when_waiting_and_nonzero() { + let mut model = Model { + network_change_state: NetworkChangeState::WaitingForNewIp { + new_ip: "192.168.1.101".to_string(), + old_ip: "192.168.1.100".to_string(), + attempt: 0, + rollback_timeout_seconds: 60, + ui_port: 443, + switching_to_dhcp: false, + }, + overlay_spinner: OverlaySpinnerState::new("Test").with_countdown(30), + ..Default::default() + }; + let mut cmd = handle_new_ip_countdown_tick(&mut model); + let time_request = find_time_effect(&mut cmd).expect("expected Time effect"); + let TimeRequest::NotifyAfter { duration, .. } = time_request else { + panic!("expected NotifyAfter"); + }; + assert_eq!( + duration, + TimeDuration::from_millis(NEW_IP_COUNTDOWN_INTERVAL_MS) + ); + } + + #[test] + fn new_ip_countdown_tick_stops_when_idle() { + let mut model = Model { + network_change_state: NetworkChangeState::Idle, + overlay_spinner: OverlaySpinnerState::new("Test").with_countdown(30), + ..Default::default() + }; + let mut cmd = handle_new_ip_countdown_tick(&mut model); + assert!(find_time_effect(&mut cmd).is_none()); + } + } } diff --git a/src/app/src/update/device/operations.rs b/src/app/src/update/device/operations.rs index b527b8ca..6c310917 100644 --- a/src/app/src/update/device/operations.rs +++ b/src/app/src/update/device/operations.rs @@ -1,12 +1,27 @@ use crux_core::Command; +use crate::events::{DeviceEvent, Event}; use crate::model::Model; use crate::types::{DeviceOperationState, OverlaySpinnerState}; -use crate::Effect; +use crate::{Effect, TimeCmd}; -pub const REBOOT_TIMEOUT_SECS: u32 = 300; // 5 minutes -pub const FACTORY_RESET_TIMEOUT_SECS: u32 = 600; // 10 minutes -pub const FIRMWARE_UPDATE_TIMEOUT_SECS: u32 = 600; // 10 minutes +use super::reconnection::schedule_reconnection_poll; + +const RECONNECTION_COUNTDOWN_INTERVAL_MS: u64 = 1000; + +pub(super) fn schedule_reconnection_timeout(secs: u32) -> Command { + let (timer, handle) = TimeCmd::notify_after(std::time::Duration::from_secs(secs as u64)); + std::mem::forget(handle); + timer.then_send(|_| Event::Device(DeviceEvent::ReconnectionTimeout)) +} + +pub(super) fn schedule_reconnection_countdown_tick() -> Command { + let (timer, handle) = TimeCmd::notify_after(std::time::Duration::from_millis( + RECONNECTION_COUNTDOWN_INTERVAL_MS, + )); + std::mem::forget(handle); + timer.then_send(|_| Event::Device(DeviceEvent::ReconnectionCountdownTick)) +} /// Check if an error message indicates a network error pub fn is_network_error(error: &str) -> bool { @@ -30,6 +45,7 @@ pub fn is_actual_update_result(info: &crate::types::HealthcheckInfo) -> bool { } /// Generic handler for device operation responses (reboot, factory reset, update) +/// Schedules reconnection poll, operation timeout, and countdown tick on success. pub fn handle_device_operation_response( result: Result<(), String>, model: &mut Model, @@ -50,9 +66,11 @@ pub fn handle_device_operation_response( success_msg.to_string() }); let timeout_secs = match &operation { - DeviceOperationState::FactoryResetting => FACTORY_RESET_TIMEOUT_SECS, - DeviceOperationState::Updating => FIRMWARE_UPDATE_TIMEOUT_SECS, - _ => REBOOT_TIMEOUT_SECS, + DeviceOperationState::FactoryResetting => { + model.timeout_settings.factory_reset_timeout_secs + } + DeviceOperationState::Updating => model.timeout_settings.firmware_update_timeout_secs, + _ => model.timeout_settings.reboot_timeout_secs, }; model.device_operation_state = operation; model.reconnection_attempt = 0; @@ -62,6 +80,12 @@ pub fn handle_device_operation_response( spinner = spinner.with_text(text); } model.overlay_spinner = spinner; + return Command::all([ + crux_core::render::render(), + schedule_reconnection_poll(), + schedule_reconnection_timeout(timeout_secs), + schedule_reconnection_countdown_tick(), + ]); } else if let Err(e) = result { model.set_error(e); model.overlay_spinner.clear(); @@ -69,3 +93,109 @@ pub fn handle_device_operation_response( crux_core::render::render() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::events::DeviceEvent; + use crate::types::{DEFAULT_FACTORY_RESET_TIMEOUT_SECS, DEFAULT_REBOOT_TIMEOUT_SECS}; + use crux_time::protocol::{Duration as TimeDuration, TimeRequest}; + + fn collect_time_effects(cmd: &mut Command) -> Vec { + cmd.effects() + .filter_map(|e| { + if let Effect::Time(_) = e { + let (time_request, _) = e.expect_time().split(); + Some(time_request) + } else { + None + } + }) + .collect() + } + + fn has_notify_after(effects: &[TimeRequest], expected: TimeDuration) -> bool { + effects.iter().any( + |r| matches!(r, TimeRequest::NotifyAfter { duration, .. } if *duration == expected), + ) + } + + #[test] + fn schedule_reconnection_timeout_emits_time_effect_with_correct_duration() { + let mut cmd = schedule_reconnection_timeout(90); + let effects = collect_time_effects(&mut cmd); + assert_eq!(effects.len(), 1); + let TimeRequest::NotifyAfter { duration, .. } = effects[0] else { + panic!("expected NotifyAfter"); + }; + assert_eq!(duration, TimeDuration::from_secs(90)); + } + + #[test] + fn schedule_reconnection_countdown_tick_emits_1000ms_time_effect() { + let mut cmd = schedule_reconnection_countdown_tick(); + let effects = collect_time_effects(&mut cmd); + assert_eq!(effects.len(), 1); + let TimeRequest::NotifyAfter { duration, .. } = effects[0] else { + panic!("expected NotifyAfter"); + }; + assert_eq!( + duration, + TimeDuration::from_millis(RECONNECTION_COUNTDOWN_INTERVAL_MS) + ); + } + + #[test] + fn reboot_success_schedules_poll_timeout_and_countdown_timers() { + let mut model = Model::default(); + let mut cmd = handle_device_operation_response( + Ok(()), + &mut model, + DeviceOperationState::Rebooting, + "Reboot initiated", + "Reboot initiated (connection lost)", + "Rebooting", + None, + ); + let time_effects = collect_time_effects(&mut cmd); + // poll (5000ms) + timeout (reboot_timeout_secs) + countdown tick (1000ms) + assert_eq!(time_effects.len(), 3); + assert!( + has_notify_after(&time_effects, TimeDuration::from_millis(5_000)), + "poll timer missing" + ); + assert!( + has_notify_after( + &time_effects, + TimeDuration::from_secs(u64::from(DEFAULT_REBOOT_TIMEOUT_SECS)) + ), + "timeout timer missing" + ); + assert!( + has_notify_after(&time_effects, TimeDuration::from_millis(1_000)), + "countdown timer missing" + ); + } + + #[test] + fn factory_reset_success_schedules_correct_timeout() { + let mut model = Model::default(); + let mut cmd = handle_device_operation_response( + Ok(()), + &mut model, + DeviceOperationState::FactoryResetting, + "Factory reset initiated", + "Factory reset initiated (connection lost)", + "Factory Resetting", + None, + ); + let time_effects = collect_time_effects(&mut cmd); + assert!( + has_notify_after( + &time_effects, + TimeDuration::from_secs(u64::from(DEFAULT_FACTORY_RESET_TIMEOUT_SECS)) + ), + "factory reset timeout timer missing" + ); + } +} diff --git a/src/app/src/update/device/reconnection.rs b/src/app/src/update/device/reconnection.rs index 0f7606a5..dfbdc5c3 100644 --- a/src/app/src/update/device/reconnection.rs +++ b/src/app/src/update/device/reconnection.rs @@ -1,15 +1,20 @@ use crux_core::Command; use crate::{ + Effect, TimeCmd, events::Event, http_get, http_helpers::build_url, model::Model, types::{DeviceOperationState, NetworkChangeState, OverlaySpinnerState, UploadState}, - Effect, }; -use super::operations::{is_actual_update_result, is_update_complete}; +/// Reconnection poll interval +const RECONNECTION_POLL_INTERVAL_MS: u64 = 5000; + +use super::operations::{ + is_actual_update_result, is_update_complete, schedule_reconnection_countdown_tick, +}; /// Handle reconnection check tick - polls healthcheck endpoint pub fn handle_reconnection_check_tick(model: &mut Model) -> Command { @@ -26,14 +31,45 @@ pub fn handle_reconnection_check_tick(model: &mut Model) -> Command Command { + use crate::events::DeviceEvent; + let (timer, handle) = TimeCmd::notify_after(std::time::Duration::from_millis( + RECONNECTION_POLL_INTERVAL_MS, + )); + std::mem::forget(handle); + timer.then_send(|_| Event::Device(DeviceEvent::ReconnectionCheckTick)) +} + +/// Handle reconnection countdown tick - decrements the displayed countdown each second +pub fn handle_reconnection_countdown_tick(model: &mut Model) -> Command { + let is_active = matches!( + model.device_operation_state, + DeviceOperationState::Rebooting + | DeviceOperationState::FactoryResetting + | DeviceOperationState::Updating + | DeviceOperationState::WaitingReconnection { .. } + ); + if is_active && model.overlay_spinner.countdown_seconds() > Some(0) { + model.overlay_spinner.decrement_countdown(); + Command::all([ + crux_core::render::render(), + schedule_reconnection_countdown_tick(), + ]) + } else { + Command::done() + } } /// Handle reconnection timeout - device didn't come back online @@ -71,21 +107,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,52 +147,43 @@ 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. // Only when an actual update ran — "NoUpdate" preserves the loaded manifest. - if is_updating { - if let Ok(info) = &result { - if is_actual_update_result(info) { - model.update_manifest = None; - model.firmware_upload_state = UploadState::Idle; - } - } + if is_updating + && let Ok(info) = &result + && is_actual_update_result(info) + { + model.update_manifest = None; + model.firmware_upload_state = UploadState::Idle; } } - // 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,35 +191,34 @@ 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. // Only when an actual update ran — "NoUpdate" preserves the loaded manifest. - if is_update { - if let Ok(info) = &result { - if is_actual_update_result(info) { - model.update_manifest = None; - model.firmware_upload_state = UploadState::Idle; - } - } + if is_update + && let Ok(info) = &result + && is_actual_update_result(info) + { + model.update_manifest = None; + model.firmware_upload_state = UploadState::Idle; } } - // 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 +227,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)] @@ -293,6 +331,32 @@ mod tests { assert_eq!(model.reconnection_attempt, 0); } + + #[test] + fn sends_get_to_healthcheck_endpoint() { + let mut model = Model { + device_operation_state: DeviceOperationState::Rebooting, + ..Default::default() + }; + let mut cmd = handle_reconnection_check_tick(&mut model); + + // Command::all([http_get!(...), schedule_poll()]) produces Http + Time effects; + // find the Http one. + let http_effect = cmd + .effects() + .find_map(|e| { + if let Effect::Http(_) = e { + Some(e.expect_http()) + } else { + None + } + }) + .expect("expected Http effect"); + let (http_request, _) = http_effect.split(); + + assert_eq!(http_request.url, "https://relative/healthcheck"); + assert_eq!(http_request.method, "GET"); + } } mod reconnection_timeout { @@ -876,5 +940,141 @@ 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); + } + } + } + + mod schedule_timers { + use super::*; + use crux_time::protocol::{Duration as TimeDuration, TimeRequest}; + + fn find_time_effect(cmd: &mut Command) -> Option { + cmd.effects().find_map(|e| { + if let Effect::Time(_) = e { + let (time_request, _) = e.expect_time().split(); + Some(time_request) + } else { + None + } + }) + } + + #[test] + fn schedule_reconnection_poll_emits_5000ms_time_effect() { + let mut cmd = schedule_reconnection_poll(); + let time_request = find_time_effect(&mut cmd).expect("expected Time effect"); + let TimeRequest::NotifyAfter { duration, .. } = time_request else { + panic!("expected NotifyAfter"); + }; + assert_eq!( + duration, + TimeDuration::from_millis(RECONNECTION_POLL_INTERVAL_MS) + ); + } + + #[test] + fn check_tick_reschedules_poll() { + let mut model = Model { + device_operation_state: DeviceOperationState::Rebooting, + ..Default::default() + }; + let mut cmd = handle_reconnection_check_tick(&mut model); + let time_request = find_time_effect(&mut cmd).expect("expected Time effect"); + let TimeRequest::NotifyAfter { duration, .. } = time_request else { + panic!("expected NotifyAfter"); + }; + assert_eq!( + duration, + TimeDuration::from_millis(RECONNECTION_POLL_INTERVAL_MS) + ); + } + + #[test] + fn countdown_tick_reschedules_when_active_and_nonzero() { + let mut model = Model { + device_operation_state: DeviceOperationState::Rebooting, + overlay_spinner: OverlaySpinnerState::new("Test").with_countdown(30), + ..Default::default() + }; + let mut cmd = handle_reconnection_countdown_tick(&mut model); + let time_request = find_time_effect(&mut cmd).expect("expected Time effect"); + let TimeRequest::NotifyAfter { duration, .. } = time_request else { + panic!("expected NotifyAfter"); + }; + assert_eq!(duration, TimeDuration::from_millis(1_000)); + } + + #[test] + fn countdown_tick_stops_when_countdown_is_zero() { + let mut model = Model { + device_operation_state: DeviceOperationState::Rebooting, + overlay_spinner: OverlaySpinnerState::new("Test").with_countdown(0), + ..Default::default() + }; + let mut cmd = handle_reconnection_countdown_tick(&mut model); + assert!(find_time_effect(&mut cmd).is_none()); + } + + #[test] + fn countdown_tick_stops_when_idle() { + let mut model = Model { + device_operation_state: DeviceOperationState::Idle, + overlay_spinner: OverlaySpinnerState::new("Test").with_countdown(30), + ..Default::default() + }; + let mut cmd = handle_reconnection_countdown_tick(&mut model); + assert!(find_time_effect(&mut cmd).is_none()); + } } } diff --git a/src/app/src/update/mod.rs b/src/app/src/update/mod.rs index 46df55f9..c60e5bc0 100644 --- a/src/app/src/update/mod.rs +++ b/src/app/src/update/mod.rs @@ -2,12 +2,13 @@ mod auth; mod device; mod ui; mod websocket; +mod wifi; -use crux_core::{render::render, Command}; +use crux_core::{Command, render::render}; +use crate::Effect; use crate::events::Event; use crate::model::Model; -use crate::Effect; /// Main update dispatcher - routes events to domain-specific handlers pub fn update(event: Event, model: &mut Model) -> Command { @@ -17,11 +18,16 @@ 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), + ui::handle(crate::events::UiEvent::LoadSettings, 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/ui.rs b/src/app/src/update/ui.rs index 915dca21..76b79b1f 100644 --- a/src/app/src/update/ui.rs +++ b/src/app/src/update/ui.rs @@ -1,12 +1,15 @@ use crux_core::Command; use crate::{ + Effect, auth_post, build_url, events::{Event, UiEvent}, + handle_response, http_get, model::Model, - update_field, Effect, + types::TimeoutSettings, + update_field, }; -/// Handle UI-related events (clear messages, etc.) +/// Handle UI-related events (clear messages, settings, etc.) pub fn handle(event: UiEvent, model: &mut Model) -> Command { match event { UiEvent::ClearError => update_field!(model.error_message, None), @@ -16,6 +19,32 @@ pub fn handle(event: UiEvent, model: &mut Model) -> Command { model.update_current_connection_adapter(); crux_core::render::render() } + UiEvent::LoadSettings => { + http_get!( + Ui, + UiEvent, + build_url("/api/settings"), + LoadSettingsResponse, + TimeoutSettings + ) + } + UiEvent::LoadSettingsResponse(result) => handle_response!(model, result, { + on_success: |m, settings| { + m.timeout_settings = settings; + }, + no_loading: true, + }), + UiEvent::SaveSettings(settings) => { + // Optimistic update so the form reflects the new values immediately, + // regardless of when the POST response arrives. + model.timeout_settings = settings.clone(); + auth_post!(Ui, UiEvent, model, "/api/settings", SaveSettingsResponse, "Save settings", + body_json: &settings + ) + } + UiEvent::SaveSettingsResponse(result) => handle_response!(model, result, { + success_message: "Settings saved", + }), } } @@ -91,4 +120,47 @@ mod tests { assert_eq!(model.current_connection_adapter, Some("eth0".to_string())); } + + #[test] + fn load_settings_response_updates_model() { + let mut model = Model::default(); + let custom = TimeoutSettings { + reboot_timeout_secs: 120, + factory_reset_timeout_secs: 300, + firmware_update_timeout_secs: 300, + network_rollback_timeout_secs: 60, + }; + + let _ = handle( + UiEvent::LoadSettingsResponse(Ok(custom.clone())), + &mut model, + ); + + assert_eq!(model.timeout_settings, custom); + } + + #[test] + fn load_settings_response_error_sets_error_message() { + let mut model = Model::default(); + + let _ = handle( + UiEvent::LoadSettingsResponse(Err("fetch failed".to_string())), + &mut model, + ); + + assert_eq!(model.error_message, Some("fetch failed".to_string())); + } + + #[test] + fn save_settings_response_sets_success_message() { + let mut model = Model { + is_loading: true, + ..Default::default() + }; + + let _ = handle(UiEvent::SaveSettingsResponse(Ok(())), &mut model); + + assert_eq!(model.success_message, Some("Settings saved".to_string())); + assert!(!model.is_loading); + } } diff --git a/src/app/src/update/websocket.rs b/src/app/src/update/websocket.rs index 1ff717ce..22f40eed 100644 --- a/src/app/src/update/websocket.rs +++ b/src/app/src/update/websocket.rs @@ -1,29 +1,35 @@ +use std::collections::HashMap; + use crux_core::Command; use crate::{ + Effect, WebSocketCmd, events::{Event, WebSocketEvent}, model::Model, parse_ods_update, - types::ods::{ - OdsFactoryReset, OdsNetworkStatus, OdsOnlineStatus, OdsSystemInfo, OdsTimeouts, - OdsUpdateValidationStatus, + types::{ + NetworkFormData, NetworkFormState, + ods::{ + OdsFactoryReset, OdsNetworkStatus, OdsOnlineStatus, OdsSystemInfo, OdsTimeouts, + OdsUpdateValidationStatus, + }, }, - update_field, CentrifugoCmd, Effect, + update_field, }; -/// Handle WebSocket and Centrifugo-related events +/// Handle WebSocket and WebSocket-related events pub fn handle(event: WebSocketEvent, model: &mut Model) -> Command { match event { WebSocketEvent::SubscribeToChannels => { - // Issue Centrifugo effect (shell sends WebSocket data as events directly) - CentrifugoCmd::subscribe_all() + // Issue WebSocket effect (shell sends WebSocket data as events directly) + WebSocketCmd::subscribe_all() .build() .then_send(|_| Event::WebSocket(WebSocketEvent::Connected)) } WebSocketEvent::UnsubscribeFromChannels => { - // Issue Centrifugo effect - CentrifugoCmd::unsubscribe_all() + // Issue WebSocket effect + WebSocketCmd::unsubscribe_all() .build() .then_send(|_| Event::WebSocket(WebSocketEvent::Disconnected)) } @@ -40,6 +46,7 @@ pub fn handle(event: WebSocketEvent, model: &mut Model) -> Command 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(WifiAvailability::Available { + interface_name, + version, + }) => { + model.wifi_state = WifiState::Ready { + interface_name, + version: Some(version), + 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(WifiAvailability::Unavailable { + socket_present, + version, + min_required_version, + }) => { + model.wifi_state = WifiState::Unavailable { + socket_present, + version, + min_required_version, + }; + render() + } + Err(e) => { + log::error!("WiFi availability check failed: {e}"); + model.wifi_state = WifiState::Unavailable { + socket_present: false, + version: None, + min_required_version: "0.1.0".to_string(), // Fallback + }; + 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 accepted; schedule first poll tick + Command::all([render(), schedule_scan_poll()]) + } + } + + 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; + Command::all([ + auth_get!( + Wifi, WifiEvent, model, + "/wifi/scan/results", + ScanResultsResponse, "WiFi scan results", + expect_json: WifiScanResultsResponse + ), + schedule_scan_poll(), + ]) + }) + } + + 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(), + ch: net.ch, + rssi: net.rssi, + }); + if net.rssi > entry.rssi { + *entry = WifiNetwork { + ssid: net.ssid, + mac: net.mac, + ch: 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 accepted; schedule first poll tick + model.stop_loading(); + Command::all([render(), schedule_connect_poll()]) + } + } + + 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; + Command::all([ + auth_get!( + Wifi, WifiEvent, model, + "/wifi/status", + StatusResponse, "WiFi connect status", + expect_json: WifiStatusResponse + ), + schedule_connect_poll(), + ]) + }) + } + + 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: WifiStatusResponse + ) + } + + WifiEvent::GetSavedNetworks => { + auth_get!( + Wifi, WifiEvent, model, + "/wifi/networks", + SavedNetworksResponse, "WiFi networks", + expect_json: WifiSavedNetworksResponse + ) + } + + 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), + ]) + } + } + } +} + +fn schedule_scan_poll() -> Command { + let (timer, handle) = + TimeCmd::notify_after(std::time::Duration::from_millis(WIFI_SCAN_POLL_INTERVAL_MS)); + std::mem::forget(handle); + timer.then_send(|_| Event::Wifi(WifiEvent::ScanPollTick)) +} + +fn schedule_connect_poll() -> Command { + let (timer, handle) = TimeCmd::notify_after(std::time::Duration::from_millis( + WIFI_CONNECT_POLL_INTERVAL_MS, + )); + std::mem::forget(handle); + timer.then_send(|_| Event::Wifi(WifiEvent::ConnectPollTick)) +} + +#[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(), + version: Some("0.1.0".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 sends_get_to_availability_endpoint() { + let mut model = Model::default(); + let mut cmd = handle(WifiEvent::CheckAvailability, &mut model); + + // unauth_post! GET pattern produces render + http effects + let effects = [cmd.expect_effect(), cmd.expect_effect()]; + let http_req = effects + .into_iter() + .find_map(|e| match e { + Effect::Http(req) => Some(req), + _ => None, + }) + .expect("Expected Http effect"); + let (http_request, _) = http_req.split(); + + assert_eq!(http_request.url, "https://relative/wifi/available"); + assert_eq!(http_request.method, "GET"); + } + + #[test] + fn check_availability_success_transitions_to_ready() { + let mut model = Model::default(); + let result = Ok(WifiAvailability::Available { + version: "0.1.0".to_string(), + interface_name: "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 check_availability_unavailable_response() { + let mut model = Model::default(); + let result = Ok(WifiAvailability::Unavailable { + socket_present: true, + version: Some("0.0.9".to_string()), + min_required_version: "0.1.0".to_string(), + }); + let _ = handle(WifiEvent::CheckAvailabilityResponse(result), &mut model); + assert_eq!( + model.wifi_state, + WifiState::Unavailable { + socket_present: true, + version: Some("0.0.9".to_string()), + min_required_version: "0.1.0".to_string() + } + ); + } + + #[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 { + socket_present: false, + version: None, + min_required_version: "0.1.0".to_string() + } + ); + } + } + + mod scan { + use super::*; + + #[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 = WifiScanResultsResponse { + status: "ok".to_string(), + state: "finished".to_string(), + networks: vec![ + WifiNetwork { + ssid: "MyNet".to_string(), + mac: "aa:bb:cc:dd:ee:ff".to_string(), + ch: 6, + rssi: -70, + }, + WifiNetwork { + ssid: "MyNet".to_string(), + mac: "11:22:33:44:55:66".to_string(), + ch: 11, + rssi: -50, + }, + WifiNetwork { + 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 = WifiScanResultsResponse { + 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; + } + // Fixed lines in test constructors + let response = WifiStatusResponse { + status: "ok".to_string(), + state: "connected".to_string(), + ssid: Some("MyNet".to_string()), + ip_address: Some("192.168.1.100".to_string()), + interface_name: Some("wlan0".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 = WifiStatusResponse { + status: "ok".to_string(), + state: "failed".to_string(), + ssid: None, + ip_address: None, + interface_name: 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::*; + + #[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 = WifiSavedNetworksResponse { + status: "ok".to_string(), + networks: vec![ + WifiSavedNetwork { + ssid: "Home".to_string(), + flags: "[CURRENT]".to_string(), + }, + WifiSavedNetwork { + 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::Unknown); + let _ = handle(WifiEvent::Scan, &mut model); + // Should not panic, just returns done + } + } +} diff --git a/src/app/src/wasm.rs b/src/app/src/wasm.rs index b972a7fb..fb84f625 100644 --- a/src/app/src/wasm.rs +++ b/src/app/src/wasm.rs @@ -3,16 +3,14 @@ //! This module provides the interface between JavaScript and the Crux Core. //! It exposes functions for processing events and retrieving the view model. -use lazy_static::lazy_static; +use std::sync::LazyLock; use wasm_bindgen::prelude::wasm_bindgen; -use crux_core::{bridge::Bridge, Core}; +use crux_core::{Core, bridge::Bridge}; use crate::App; -lazy_static! { - static ref CORE: Bridge = Bridge::new(Core::new()); -} +static CORE: LazyLock> = LazyLock::new(|| Bridge::new(Core::new())); /// Initialize the WASM module and set up logging /// 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/Cargo.toml b/src/backend/Cargo.toml index f174f6b1..d8f4800a 100644 --- a/src/backend/Cargo.toml +++ b/src/backend/Cargo.toml @@ -19,12 +19,13 @@ actix-multipart = { version = "0.7", default-features = false, features = [ ] } actix-server = { version = "2.6", default-features = false } actix-session = { version = "0.11", features = ["cookie-session"] } -actix-web-static-files = { version = "4.1", features = ["static-files-03"] } actix-web = { version = "4.11", default-features = false, features = [ "macros", "rustls-0_23", ] } actix-web-httpauth = { version = "0.8", default-features = false } +actix-web-static-files = { version = "4.1", features = ["static-files-03"] } +actix-ws = { version = "0.4", default-features = false } anyhow = { version = "1.0", default-features = false } argon2 = { version = "0.5", default-features = false, features = [ "password-hash", @@ -73,6 +74,7 @@ tokio = { version = "1.45", default-features = false, features = [ "process", ] } trait-variant = { version = "0.1", default-features = false } +url = { version = "2.5", default-features = false } uuid = { version = "1.17", default-features = false, features = ["v4"] } [features] diff --git a/src/backend/config/centrifugo_config.json b/src/backend/config/centrifugo_config.json deleted file mode 100644 index 3c005986..00000000 --- a/src/backend/config/centrifugo_config.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "http_server": { - "tls": { - "enabled": true - } - }, - "client": { - "allowed_origins": [ - "*" - ] - }, - "channel": { - "without_namespace": { - "history_size": 1, - "history_ttl": "720h", - "allow_history_for_client": true, - "allow_subscribe_for_client": true - } - } -} \ No newline at end of file diff --git a/src/backend/src/api.rs b/src/backend/src/api.rs index 505d1d48..d2215111 100644 --- a/src/backend/src/api.rs +++ b/src/backend/src/api.rs @@ -8,6 +8,10 @@ use crate::{ firmware::FirmwareService, marker, network::{NetworkConfigRequest, NetworkConfigService}, + settings::SettingsService, + }, + wifi_commissioning_client::{ + WifiAvailability, WifiCommissioningClient, WifiConnectRequest, WifiForgetRequest, }, }; use actix_files::NamedFile; @@ -45,16 +49,11 @@ where } pub async fn index( - api: web::Data, + _api: web::Data, static_resources: web::Data, ) -> actix_web::Result { debug!("index() called"); - api.service_client.republish().await.map_err(|e| { - error!("republish failed: {e:#}"); - actix_web::error::ErrorInternalServerError("republish failed") - })?; - let Some(index_html) = static_resources.get("index.html") else { return Err(actix_web::error::ErrorNotFound( "index.html not found in embedded resources", @@ -74,17 +73,26 @@ where debug!("healthcheck() called"); match api.service_client.healthcheck_info().await { - Ok(info) if info.version_info.mismatch => { - HttpResponse::ServiceUnavailable().json(&info) - } - Ok(info) => HttpResponse::Ok().json(&info), + Ok(info) if info.version_info.mismatch => HttpResponse::ServiceUnavailable() + .insert_header(("Access-Control-Allow-Origin", "*")) + .json(&info), + Ok(info) => HttpResponse::Ok() + .insert_header(("Access-Control-Allow-Origin", "*")) + .json(&info), Err(e) => { error!("healthcheck failed: {e:#}"); - HttpResponse::InternalServerError().body(e.to_string()) + HttpResponse::InternalServerError() + .insert_header(("Access-Control-Allow-Origin", "*")) + .body(e.to_string()) } } } + pub async fn republish(api: web::Data) -> impl Responder { + debug!("republish() called"); + handle_service_result(api.service_client.republish().await, "republish") + } + pub async fn factory_reset( body: web::Json, api: web::Data, @@ -252,6 +260,18 @@ where ) } + pub async fn get_settings() -> impl Responder { + debug!("get_settings() called"); + HttpResponse::Ok().json(SettingsService::get()) + } + + pub async fn update_settings( + body: web::Json, + ) -> impl Responder { + debug!("update_settings() called: {body:?}"); + handle_service_result(SettingsService::save(&body), "update_settings") + } + pub async fn ack_rollback() -> impl Responder { debug!("ack_rollback() called"); marker::NETWORK_ROLLBACK_OCCURRED.clear(); @@ -284,6 +304,101 @@ where return HttpResponse::InternalServerError().body("failed to insert token into session"); } - HttpResponse::Ok().body(token) + HttpResponse::Ok() + .content_type("text/plain; charset=utf-8") + .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..ad625e29 100644 --- a/src/backend/src/config.rs +++ b/src/backend/src/config.rs @@ -8,8 +8,8 @@ pub struct AppConfig { /// UI server configuration pub ui: UiConfig, - /// Centrifugo WebSocket server configuration - pub centrifugo: CentrifugoConfig, + /// Internal Publish endpoint configuration + pub publish: PublishConfig, /// Keycloak SSO configuration pub keycloak: KeycloakConfig, @@ -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, @@ -37,14 +40,9 @@ pub struct UiConfig { } #[derive(Clone, Debug)] -pub struct CentrifugoConfig { - pub port: String, - pub client_token: String, +pub struct PublishConfig { pub api_key: String, - pub publish_endpoint: crate::omnect_device_service_client::PublishEndpoint, - pub log_level: String, - pub binary_path: PathBuf, - pub config_path: PathBuf, + pub endpoint: crate::omnect_device_service_client::PublishEndpoint, } #[derive(Clone, Debug)] @@ -79,6 +77,12 @@ pub struct PathConfig { pub password_file: PathBuf, pub host_update_file: PathBuf, pub local_update_file: PathBuf, + pub session_key_path: PathBuf, +} + +#[derive(Clone, Debug)] +pub struct WifiConfig { + pub socket_path: PathBuf, } impl AppConfig { @@ -111,21 +115,23 @@ impl AppConfig { ); let ui = UiConfig::load()?; - let centrifugo = CentrifugoConfig::load()?; + let publish = PublishConfig::load()?; let keycloak = KeycloakConfig::load()?; 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()); Ok(Self { ui, - centrifugo, + publish, keycloak, device_service, certificate, iot_edge, + wifi, paths, tenant, }) @@ -143,17 +149,14 @@ impl UiConfig { } } -impl CentrifugoConfig { +impl PublishConfig { fn load() -> Result { - let port = env::var("CENTRIFUGO_HTTP_SERVER_PORT").unwrap_or_else(|_| "8000".to_string()); - let log_level = env::var("CENTRIFUGO_LOG_LEVEL").unwrap_or_else(|_| "none".to_string()); - - // Generate unique tokens for this instance - let client_token = Uuid::new_v4().to_string(); + let port = env::var("PUBLISH_PORT").unwrap_or_else(|_| "8000".to_string()); + // Generate a unique token for this instance to protect the internal publish endpoint let api_key = Uuid::new_v4().to_string(); - let publish_endpoint = crate::omnect_device_service_client::PublishEndpoint { - url: format!("https://localhost:{port}/api/publish"), + let endpoint = crate::omnect_device_service_client::PublishEndpoint { + url: format!("http://localhost:{port}/api/internal/publish"), headers: vec![ crate::omnect_device_service_client::HeaderKeyValue { name: String::from("Content-Type"), @@ -166,26 +169,7 @@ impl CentrifugoConfig { ], }; - #[cfg(any(test, feature = "mock"))] - let binary_path = PathBuf::from("tools/centrifugo"); - #[cfg(not(any(test, feature = "mock")))] - let binary_path = - std::fs::canonicalize("centrifugo").context("failed to find centrifugo binary")?; - - #[cfg(any(test, feature = "mock"))] - let config_path = PathBuf::from("src/backend/config/centrifugo_config.json"); - #[cfg(not(any(test, feature = "mock")))] - let config_path = PathBuf::from("/centrifugo_config.json"); - - Ok(Self { - port, - client_token, - api_key, - publish_endpoint, - log_level, - binary_path, - config_path, - }) + Ok(Self { api_key, endpoint }) } } @@ -205,7 +189,7 @@ impl KeycloakConfig { impl DeviceServiceConfig { fn load() -> Result { let socket_path = env::var("DEVICE_SERVICE_SOCKET_PATH") - .unwrap_or_else(|_| "/socket/device-service.sock".to_string()) + .unwrap_or_else(|_| "/omnect-device-service/api.sock".to_string()) .into(); Ok(Self { socket_path }) @@ -271,6 +255,16 @@ impl IoTEdgeConfig { } } +impl WifiConfig { + fn load() -> Self { + let socket_path = env::var("WIFI_COMMISSIONING_SOCKET_PATH") + .unwrap_or_else(|_| "/wifi-commissioning-service/api.sock".to_string()) + .into(); + + Self { socket_path } + } +} + impl PathConfig { fn load() -> Result { #[cfg(not(any(test, feature = "mock")))] @@ -298,6 +292,7 @@ impl PathConfig { let password_file = config_dir.join("password"); let host_update_file = host_data_dir.join("update.tar"); let local_update_file = data_dir.join("update.tar"); + let session_key_path = data_dir.join("session.key"); Ok(Self { app_config_path, @@ -305,6 +300,7 @@ impl PathConfig { password_file, host_update_file, local_update_file, + session_key_path, }) } } 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 5d1580e0..a93e073d 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, @@ -12,10 +13,11 @@ use crate::{ keycloak_client::KeycloakProvider, omnect_device_service_client::{DeviceServiceClient, OmnectDeviceServiceClient}, services::{ - auth::TokenManager, + auth::{SessionKeyService, TokenManager}, certificate::{CertificateService, CreateCertPayload}, network::NetworkConfigService, }, + wifi_commissioning_client::{WifiAvailability, WifiCommissioningServiceClient}, }; use actix_multipart::form::MultipartFormConfig; use actix_server::ServerHandle; @@ -26,7 +28,7 @@ use actix_session::{ }; use actix_web::{ App, HttpServer, - cookie::{Key, SameSite}, + cookie::SameSite, web::{self, Data}, }; use actix_web_static_files::ResourceFiles; @@ -36,10 +38,10 @@ use log::{debug, error, info, warn}; use rustls::crypto::{CryptoProvider, ring::default_provider}; use std::{io::Write, sync::Mutex}; use tokio::{ - process::{Child, Command}, signal::unix::{SignalKind, signal}, sync::broadcast, }; +use uuid::Uuid; const UPLOAD_LIMIT_BYTES: usize = 1024 * 1024 * 1024; const MULTIPART_CHUNK_SIZE_BYTES: usize = 512 * 1024; @@ -198,19 +200,23 @@ async fn run_until_shutdown( info!("certificate still valid, skipping recreation"); } - // 2. run centrifugo with valid cert - let mut centrifugo = run_centrifugo().context("failed to start centrifugo")?; + let (ws_tx, _) = broadcast::channel(100); - // 3. register publish endpoint with running centrifugo + // 2. start both servers (HTTPS for UI, HTTP for internal ODS publish) + let (ui_handle, internal_handle, ui_task, internal_task) = + run_server(service_client.clone(), ws_tx).await?; + + // 3. register publish endpoint with ods (after server is listening) if !service_client.has_publish_endpoint { + // Wait a tiny bit for the sockets to be fully ready + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + service_client - .register_publish_endpoint(AppConfig::get().centrifugo.publish_endpoint.clone()) + .register_publish_endpoint(AppConfig::get().publish.endpoint.clone()) .await .context("failed to register publish endpoint")?; } - let (server_handle, server_task) = run_server(service_client.clone()).await?; - let service_client_clone = service_client.clone(); let rollback_task = tokio::spawn(async move { if let Err(e) = NetworkConfigService::process_pending_rollback(service_client_clone).await { @@ -231,16 +237,20 @@ async fn run_until_shutdown( debug!("server restart requested"); ShutdownReason::Restart }, - result = server_task => { + result = ui_task => { match result { - Ok(Ok(())) => debug!("server stopped normally"), - Ok(Err(e)) => error!("server stopped with error: {e}"), - Err(e) => error!("server task panicked: {e}"), + Ok(Ok(())) => debug!("UI server stopped normally"), + Ok(Err(e)) => error!("UI server stopped with error: {e}"), + Err(e) => error!("UI server task panicked: {e}"), } ShutdownReason::Shutdown }, - _ = centrifugo.wait() => { - error!("centrifugo stopped unexpectedly"); + result = internal_task => { + match result { + Ok(Ok(())) => debug!("internal server stopped normally"), + Ok(Err(e)) => error!("internal server stopped with error: {e}"), + Err(e) => error!("internal server task panicked: {e}"), + } ShutdownReason::Shutdown } }; @@ -248,10 +258,8 @@ async fn run_until_shutdown( rollback_task.abort(); info!("{reason}"); - server_handle.stop(true).await; - if let Err(e) = centrifugo.kill().await { - error!("failed to kill centrifugo: {e:#}"); - } + ui_handle.stop(true).await; + internal_handle.stop(true).await; if matches!(reason, ShutdownReason::Shutdown) { if let Err(e) = service_client.shutdown().await { @@ -281,147 +289,246 @@ fn optimal_worker_count() -> usize { workers } +async fn initialize_wifi_client() -> (Option, WifiAvailability) { + let unavailable = WifiAvailability::Unavailable { + socket_present: false, + version: None, + min_required_version: WifiCommissioningServiceClient::MIN_REQUIRED_VERSION.to_string(), + }; + + let config = &AppConfig::get().wifi; + + let Some(client) = WifiCommissioningServiceClient::try_new(&config.socket_path) else { + return (None, unavailable); + }; + + let availability = client.check_availability().await; + + if matches!(availability, WifiAvailability::Available { .. }) { + (Some(client), availability) + } else { + (None, availability) + } +} + async fn run_server( service_client: OmnectDeviceServiceClient, + ws_tx: broadcast::Sender, ) -> Result<( ServerHandle, + ServerHandle, + tokio::task::JoinHandle>, tokio::task::JoinHandle>, )> { let api = UiApi::new(service_client.clone(), Default::default()) .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; - let session_key = Key::generate(); - let token_manager = TokenManager::new(&config.centrifugo.client_token); + let publish_url = + url::Url::parse(&config.publish.endpoint.url).context("failed to parse publish url")?; + let publish_port = publish_url + .port() + .context("failed to get port from publish url")?; + let session_key = SessionKeyService::load_or_generate(&config.paths.session_key_path); + // TokenManager generates the JWT tokens for the UI to use in API requests (via Bearer token or session). + let token_manager = TokenManager::new(&Uuid::new_v4().to_string()); + + let ws_tx_data = Data::new(ws_tx); + + // Internal HTTP server — serves only the API-key-protected ODS publish endpoint. + // Isolated from the HTTPS server to prevent exposing UI routes on unencrypted HTTP. + let internal_ws_tx = ws_tx_data.clone(); + let internal_server = HttpServer::new(move || { + App::new().app_data(internal_ws_tx.clone()).route( + "/api/internal/publish", + web::post().to(services::websocket::internal_publish), + ) + }) + .workers(1) + .bind(format!("[::]:{publish_port}")) + .context("failed to bind internal HTTP server")? + .disable_signals() + .run(); - let server = HttpServer::new(move || { + let internal_handle = internal_server.handle(); + let internal_task = tokio::spawn(internal_server); + + // Main HTTPS server — serves UI, API, and WebSocket routes + let ui_server = HttpServer::new(move || { App::new() - .wrap( - SessionMiddleware::builder(CookieSessionStore::default(), session_key.clone()) - .cookie_name(String::from("omnect-ui-session")) - .cookie_secure(true) - .session_lifecycle(BrowserSession::default()) - .cookie_same_site(SameSite::Strict) - .cookie_content_security(CookieContentSecurity::Private) - .cookie_http_only(true) - .build(), - ) .app_data( MultipartFormConfig::default() .total_limit(UPLOAD_LIMIT_BYTES) .memory_limit(MULTIPART_CHUNK_SIZE_BYTES), ) .app_data(web::PayloadConfig::new(UPLOAD_LIMIT_BYTES)) + .app_data(web::JsonConfig::default().limit(UPLOAD_LIMIT_BYTES)) .app_data(Data::new(token_manager.clone())) + .app_data(ws_tx_data.clone()) .app_data(Data::new(api.clone())) .app_data(Data::new(static_files())) - .route("/", web::get().to(UiApi::index)) - .route("/config.js", web::get().to(UiApi::config)) - .route( - "/factory-reset", - web::post() - .to(UiApi::factory_reset) - .wrap(middleware::AuthMw), - ) - .route( - "/reboot", - web::post().to(UiApi::reboot).wrap(middleware::AuthMw), - ) - .route( - "/update/file", - web::post() - .to(UiApi::upload_firmware_file) - .wrap(middleware::AuthMw), - ) - .route( - "/update/load", - web::post().to(UiApi::load_update).wrap(middleware::AuthMw), - ) - .route( - "/update/run", - web::post().to(UiApi::run_update).wrap(middleware::AuthMw), - ) - .route( - "/token/login", - web::post().to(UiApi::token).wrap(middleware::AuthMw), - ) - .route( - "/token/refresh", - web::get().to(UiApi::token).wrap(middleware::AuthMw), - ) - .route( - "/token/validate", - web::post().to(UiApi::validate_portal_token), - ) - .route( - "/require-set-password", - web::get().to(UiApi::require_set_password), - ) - .route("/set-password", web::post().to(UiApi::set_password)) - .route("/update-password", web::post().to(UiApi::update_password)) - .route("/version", web::get().to(UiApi::version)) - .route("/logout", web::post().to(UiApi::logout)) - .route("/healthcheck", web::get().to(UiApi::healthcheck)) - .route("/network", web::post().to(UiApi::set_network_config)) - .route("/ack-rollback", web::post().to(UiApi::ack_rollback)) - .route( - "/ack-factory-reset-result", - web::post().to(UiApi::ack_factory_reset_result), - ) - .route( - "/ack-update-validation", - web::post().to(UiApi::ack_update_validation), - ) + .app_data(wifi_data.clone()) + .app_data(wifi_availability_data.clone()) .service(ResourceFiles::new("/static", static_files())) + .service( + web::scope("") + .wrap( + SessionMiddleware::builder( + CookieSessionStore::default(), + session_key.clone(), + ) + .cookie_name(String::from("omnect-ui-session")) + .cookie_secure(true) + .session_lifecycle(BrowserSession::default()) + .cookie_same_site(SameSite::Strict) + .cookie_content_security(CookieContentSecurity::Private) + .cookie_http_only(true) + .build(), + ) + .route( + "/ws", + web::get() + .to(services::websocket::ws_route) + .wrap(middleware::AuthMw), + ) + .route("/", web::get().to(UiApi::index)) + .route("/config.js", web::get().to(UiApi::config)) + .route( + "/factory-reset", + web::post() + .to(UiApi::factory_reset) + .wrap(middleware::AuthMw), + ) + .route( + "/reboot", + web::post().to(UiApi::reboot).wrap(middleware::AuthMw), + ) + .route( + "/update/file", + web::post() + .to(UiApi::upload_firmware_file) + .wrap(middleware::AuthMw), + ) + .route( + "/update/load", + web::post().to(UiApi::load_update).wrap(middleware::AuthMw), + ) + .route( + "/update/run", + web::post().to(UiApi::run_update).wrap(middleware::AuthMw), + ) + .route( + "/token/login", + web::post().to(UiApi::token).wrap(middleware::AuthMw), + ) + .route( + "/token/refresh", + web::get().to(UiApi::token).wrap(middleware::AuthMw), + ) + .route( + "/token/validate", + web::post().to(UiApi::validate_portal_token), + ) + .route( + "/require-set-password", + web::get().to(UiApi::require_set_password), + ) + .route("/set-password", web::post().to(UiApi::set_password)) + .route("/update-password", web::post().to(UiApi::update_password)) + .route("/version", web::get().to(UiApi::version)) + .route("/logout", web::post().to(UiApi::logout)) + .route("/healthcheck", web::get().to(UiApi::healthcheck)) + .route( + "/republish", + web::post().to(UiApi::republish).wrap(middleware::AuthMw), + ) + .route("/api/settings", web::get().to(UiApi::get_settings)) + .route( + "/api/settings", + web::post() + .to(UiApi::update_settings) + .wrap(middleware::AuthMw), + ) + .route( + "/network", + web::post() + .to(UiApi::set_network_config) + .wrap(middleware::AuthMw), + ) + .route( + "/ack-rollback", + web::post().to(UiApi::ack_rollback).wrap(middleware::AuthMw), + ) + .route( + "/ack-factory-reset-result", + web::post() + .to(UiApi::ack_factory_reset_result) + .wrap(middleware::AuthMw), + ) + .route( + "/ack-update-validation", + web::post() + .to(UiApi::ack_update_validation) + .wrap(middleware::AuthMw), + ) + // 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), + ), + ) .default_service(web::route().to(UiApi::index)) }) .workers(optimal_worker_count()) .bind_rustls_0_23(format!("0.0.0.0:{ui_port}"), tls_config) - .context("failed to bind server")? + .context("failed to bind HTTPS server")? .disable_signals() .run(); - Ok((server.handle(), tokio::spawn(server))) -} - -fn run_centrifugo() -> Result { - let config = &AppConfig::get().centrifugo; - let certificate = &AppConfig::get().certificate; - - let centrifugo = Command::new(&config.binary_path) - .arg("-c") - .arg(&config.config_path) - .envs(vec![ - ( - "CENTRIFUGO_HTTP_SERVER_TLS_CERT_PEM", - certificate.cert_path.to_string_lossy().to_string(), - ), - ( - "CENTRIFUGO_HTTP_SERVER_TLS_KEY_PEM", - certificate.key_path.to_string_lossy().to_string(), - ), - ("CENTRIFUGO_HTTP_SERVER_PORT", config.port.clone()), - ( - "CENTRIFUGO_CLIENT_TOKEN_HMAC_SECRET_KEY", - config.client_token.clone(), - ), - ("CENTRIFUGO_HTTP_API_KEY", config.api_key.clone()), - ("CENTRIFUGO_LOG_LEVEL", config.log_level.clone()), - ]) - .spawn() - .context("failed to spawn centrifugo process")?; - - info!( - "centrifugo pid: {}", - centrifugo - .id() - .context("failed to get centrifugo process id")? - ); + let ui_handle = ui_server.handle(); + let ui_task = tokio::spawn(ui_server); - Ok(centrifugo) + Ok((ui_handle, internal_handle, ui_task, internal_task)) } fn load_tls_config() -> Result { @@ -457,3 +564,161 @@ fn load_tls_config() -> Result { Ok(config) } + +#[cfg(test)] +mod tests { + use crate::{middleware::AuthMw, services::auth::TokenManager}; + use actix_http::StatusCode; + use actix_session::{ + SessionMiddleware, + config::{BrowserSession, CookieContentSecurity}, + storage::CookieSessionStore, + }; + use actix_web::{App, HttpResponse, cookie::SameSite, dev::ServiceResponse, test, web}; + + const SESSION_SECRET: [u8; 64] = [ + 0xb2, 0x64, 0x83, 0x0, 0xf5, 0xcb, 0xf6, 0x1d, 0x5c, 0x83, 0xc0, 0x90, 0x6b, 0xb2, 0xe4, + 0x26, 0x14, 0x9, 0x2b, 0xa1, 0xc4, 0xc5, 0x37, 0xe7, 0xc9, 0x20, 0x8e, 0xbc, 0xee, 0x2, + 0x3c, 0xa2, 0x32, 0x57, 0x96, 0xc9, 0x99, 0x62, 0x90, 0x4f, 0x24, 0xe5, 0x25, 0x6b, 0xe1, + 0x2b, 0x8a, 0x3, 0xa3, 0xc7, 0x1e, 0xb2, 0xb2, 0xbe, 0x29, 0x51, 0xc1, 0xe2, 0x1e, 0xb7, + 0x8, 0x15, 0xc9, 0xe0, + ]; + + const TOKEN_SECRET: &str = "test-secret-key!"; + + async fn ok_handler() -> HttpResponse { + HttpResponse::Ok().finish() + } + + /// Build a test app mirroring production route + middleware layout. + /// Uses stub handlers — we're testing auth enforcement, not handler logic. + async fn create_route_auth_service() -> impl actix_service::Service< + actix_http::Request, + Response = ServiceResponse, + Error = actix_web::Error, + > { + let key = actix_web::cookie::Key::from(&SESSION_SECRET); + let session_mw = SessionMiddleware::builder(CookieSessionStore::default(), key) + .cookie_name(String::from("omnect-ui-session")) + .cookie_secure(true) + .session_lifecycle(BrowserSession::default()) + .cookie_same_site(SameSite::Strict) + .cookie_content_security(CookieContentSecurity::Private) + .cookie_http_only(true) + .build(); + + let token_manager = TokenManager::new(TOKEN_SECRET); + + test::init_service( + App::new().app_data(web::Data::new(token_manager)).service( + web::scope("") + .wrap(session_mw) + // Protected routes + .route("/ws", web::get().to(ok_handler).wrap(AuthMw)) + .route("/network", web::post().to(ok_handler).wrap(AuthMw)) + .route("/republish", web::post().to(ok_handler).wrap(AuthMw)) + .route("/ack-rollback", web::post().to(ok_handler).wrap(AuthMw)) + .route( + "/ack-factory-reset-result", + web::post().to(ok_handler).wrap(AuthMw), + ) + .route( + "/ack-update-validation", + web::post().to(ok_handler).wrap(AuthMw), + ) + .route("/api/settings", web::get().to(ok_handler)) + .route("/api/settings", web::post().to(ok_handler).wrap(AuthMw)) + .route("/factory-reset", web::post().to(ok_handler).wrap(AuthMw)) + .route("/reboot", web::post().to(ok_handler).wrap(AuthMw)) + // Public routes + .route("/version", web::get().to(ok_handler)) + .route("/healthcheck", web::get().to(ok_handler)) + .route("/require-set-password", web::get().to(ok_handler)), + ), + ) + .await + } + + #[tokio::test] + async fn protected_routes_require_auth() { + let app = create_route_auth_service().await; + + let protected = vec![ + ("GET", "/ws"), + ("POST", "/network"), + ("POST", "/republish"), + ("POST", "/ack-rollback"), + ("POST", "/ack-factory-reset-result"), + ("POST", "/ack-update-validation"), + ("POST", "/api/settings"), + ("POST", "/factory-reset"), + ("POST", "/reboot"), + ]; + + for (method, path) in protected { + let req = match method { + "GET" => test::TestRequest::get().uri(path).to_request(), + "POST" => test::TestRequest::post().uri(path).to_request(), + _ => unreachable!(), + }; + let resp = test::call_service(&app, req).await; + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "{method} {path} should require authentication" + ); + } + } + + #[tokio::test] + async fn public_routes_dont_require_auth() { + let app = create_route_auth_service().await; + + let public = vec![ + ("GET", "/version"), + ("GET", "/healthcheck"), + ("GET", "/require-set-password"), + ("GET", "/api/settings"), + ]; + + for (method, path) in public { + let req = match method { + "GET" => test::TestRequest::get().uri(path).to_request(), + "POST" => test::TestRequest::post().uri(path).to_request(), + _ => unreachable!(), + }; + let resp = test::call_service(&app, req).await; + assert!( + resp.status().is_success(), + "{method} {path} should be publicly accessible, got {}", + resp.status() + ); + } + } + + #[tokio::test] + async fn protected_routes_succeed_with_valid_bearer() { + let app = create_route_auth_service().await; + let token_manager = TokenManager::new(TOKEN_SECRET); + let token = token_manager.create_token().unwrap(); + + let protected = vec![("GET", "/ws"), ("POST", "/network"), ("POST", "/republish")]; + + for (method, path) in protected { + let req_builder = match method { + "GET" => test::TestRequest::get().uri(path), + "POST" => test::TestRequest::post().uri(path), + _ => unreachable!(), + }; + let req = req_builder + .insert_header(("Authorization", format!("Bearer {token}"))) + .to_request(); + let resp = test::call_service(&app, req).await; + assert!( + resp.status().is_success(), + "{method} {path} should succeed with valid bearer, got {}", + resp.status() + ); + } + } +} diff --git a/src/backend/src/middleware.rs b/src/backend/src/middleware.rs index 04610d2e..0ee8e419 100644 --- a/src/backend/src/middleware.rs +++ b/src/backend/src/middleware.rs @@ -133,7 +133,6 @@ fn unauthorized_error(req: ServiceRequest) -> ServiceResponse { #[cfg(test)] pub mod tests { use super::*; - use crate::config::AppConfig; const TOKEN_SUBJECT: &str = "omnect-ui"; const TOKEN_EXPIRE_HOURS: u64 = 2; @@ -210,7 +209,7 @@ pub mod tests { } fn generate_token(claim: TestClaims) -> String { - let key = EncodingKey::from_secret(AppConfig::get().centrifugo.client_token.as_bytes()); + let key = EncodingKey::from_secret("test-secret-key!".as_bytes()); encode(&Header::default(), &claim, &key).unwrap() } @@ -245,7 +244,7 @@ pub mod tests { .cookie_http_only(true) .build(); - let token_manager = TokenManager::new(AppConfig::get().centrifugo.client_token.as_str()); + let token_manager = TokenManager::new("test-secret-key!"); test::init_service( App::new() @@ -484,7 +483,7 @@ pub mod tests { async fn verify_correct_token_should_succeed() { let claim = generate_valid_claim(); let token = generate_token(claim); - let token_manager = TokenManager::new(AppConfig::get().centrifugo.client_token.as_str()); + let token_manager = TokenManager::new("test-secret-key!"); assert!(token_manager.verify_token(token.as_str())); } @@ -493,7 +492,7 @@ pub mod tests { async fn verify_expired_token_should_fail() { let claim = generate_expired_claim(); let token = generate_token(claim); - let token_manager = TokenManager::new(AppConfig::get().centrifugo.client_token.as_str()); + let token_manager = TokenManager::new("test-secret-key!"); assert!(!token_manager.verify_token(token.as_str())); } @@ -502,7 +501,7 @@ pub mod tests { async fn verify_token_with_invalid_subject_should_fail() { let claim = generate_unset_subject_claim(); let token = generate_token(claim); - let token_manager = TokenManager::new(AppConfig::get().centrifugo.client_token.as_str()); + let token_manager = TokenManager::new("test-secret-key!"); assert!(!token_manager.verify_token(token.as_str())); @@ -517,7 +516,7 @@ pub mod tests { let claim = generate_invalid_subject_claim(); let _ = generate_token(claim); let token = "someinvalidtestbytes".to_string(); - let token_manager = TokenManager::new(AppConfig::get().centrifugo.client_token.as_str()); + let token_manager = TokenManager::new("test-secret-key!"); assert!(!token_manager.verify_token(token.as_str())); } diff --git a/src/backend/src/services/auth/authorization.rs b/src/backend/src/services/auth/authorization.rs index 1ed66887..55fcaa69 100644 --- a/src/backend/src/services/auth/authorization.rs +++ b/src/backend/src/services/auth/authorization.rs @@ -3,7 +3,8 @@ //! Handles token validation and role-based access control independent of HTTP concerns. use crate::{ - config::AppConfig, keycloak_client::SingleSignOnProvider, + config::AppConfig, + keycloak_client::{SingleSignOnProvider, TokenClaims}, omnect_device_service_client::DeviceServiceClient, }; use anyhow::{Result, bail, ensure}; @@ -39,27 +40,33 @@ impl AuthorizationService { { let claims = single_sign_on.verify_token(token).await?; let tenant = &AppConfig::get().tenant; + Self::validate_tenant(&claims, tenant)?; + Self::validate_role(&claims, service_client).await + } - // Validate tenant authorization + fn validate_tenant(claims: &TokenClaims, tenant: &str) -> 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/mod.rs b/src/backend/src/services/auth/mod.rs index 61c52a67..27c101d9 100644 --- a/src/backend/src/services/auth/mod.rs +++ b/src/backend/src/services/auth/mod.rs @@ -1,7 +1,9 @@ pub mod authorization; pub mod password; +pub mod session_key; pub mod token; pub use authorization::AuthorizationService; pub use password::PasswordService; +pub use session_key::SessionKeyService; pub use token::TokenManager; 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/backend/src/services/auth/session_key.rs b/src/backend/src/services/auth/session_key.rs new file mode 100644 index 00000000..fe569eb8 --- /dev/null +++ b/src/backend/src/services/auth/session_key.rs @@ -0,0 +1,129 @@ +//! Session key management service +//! +//! Loads or generates the actix-session signing/encryption key. The key is +//! persisted to disk so that server restarts do not invalidate existing browser +//! session cookies. + +use actix_web::cookie::Key; +use log::{info, warn}; + +/// actix-web's cookie::Key requires at least 64 bytes (512 bits). +const SESSION_KEY_LEN: usize = 64; + +/// Service for session key management +pub struct SessionKeyService; + +impl SessionKeyService { + /// Return a session key loaded from `path`, or generate and save a new one. + /// + /// Falls back to an ephemeral (in-memory only) key when the path is + /// unreadable for reasons other than the file not existing yet. + pub fn load_or_generate(path: &std::path::Path) -> Key { + match std::fs::read(path) { + Ok(bytes) if bytes.len() >= SESSION_KEY_LEN => { + info!("loaded session key from {}", path.display()); + Key::from(&bytes) + } + Ok(_) => { + warn!( + "session key at {} is too short, regenerating", + path.display() + ); + Self::generate_and_save(path) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + info!( + "no session key found at {}, generating new key", + path.display() + ); + Self::generate_and_save(path) + } + Err(e) => { + warn!( + "failed to read session key from {}: {e:#}, using ephemeral key", + path.display() + ); + Key::generate() + } + } + } + + fn generate_and_save(path: &std::path::Path) -> Key { + let key = Key::generate(); + if let Err(e) = std::fs::write(path, key.master()) { + warn!("failed to persist session key to {}: {e:#}", path.display()); + } + key + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generates_and_saves_key_when_file_missing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("session.key"); + + assert!(!path.exists()); + let _ = SessionKeyService::load_or_generate(&path); + assert!(path.exists()); + + let saved = std::fs::read(&path).unwrap(); + assert_eq!(saved.len(), SESSION_KEY_LEN); + } + + #[test] + fn loads_existing_valid_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("session.key"); + + // Write a known 64-byte key. + let original_bytes: Vec = (0..SESSION_KEY_LEN as u8).collect(); + std::fs::write(&path, &original_bytes).unwrap(); + + let key = SessionKeyService::load_or_generate(&path); + + // The master slice must match the bytes we wrote. + assert_eq!(key.master(), original_bytes.as_slice()); + } + + #[test] + fn regenerates_when_file_too_short() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("session.key"); + + // Write fewer than SESSION_KEY_LEN bytes. + std::fs::write(&path, b"tooshort").unwrap(); + + let _ = SessionKeyService::load_or_generate(&path); + + // The file should now contain a full-length key. + let saved = std::fs::read(&path).unwrap(); + assert_eq!(saved.len(), SESSION_KEY_LEN); + } + + #[test] + fn returns_ephemeral_key_on_read_error() { + // Point at a path that is unreadable for a reason other than NotFound: + // use the directory itself as the key path. + let dir = tempfile::tempdir().unwrap(); + let not_a_file = dir.path(); // reading a directory returns an error + + // Should not panic and should return a usable key. + let key = SessionKeyService::load_or_generate(not_a_file); + assert_eq!(key.master().len(), SESSION_KEY_LEN); + } + + #[test] + fn round_trip_key_is_stable_across_loads() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("session.key"); + + let first = SessionKeyService::load_or_generate(&path); + let second = SessionKeyService::load_or_generate(&path); + + assert_eq!(first.master(), second.master()); + } +} diff --git a/src/backend/src/services/auth/token.rs b/src/backend/src/services/auth/token.rs index d1371ec1..4c5462fb 100644 --- a/src/backend/src/services/auth/token.rs +++ b/src/backend/src/services/auth/token.rs @@ -20,7 +20,6 @@ struct Claims { /// /// Handles creation and verification of JWT tokens used for: /// - Session authentication -/// - Centrifugo WebSocket authentication /// /// This struct is cheap to clone (uses Arc internally) and can be safely /// shared across threads and added to application data. diff --git a/src/backend/src/services/firmware.rs b/src/backend/src/services/firmware.rs index a10f5799..31a152c3 100644 --- a/src/backend/src/services/firmware.rs +++ b/src/backend/src/services/firmware.rs @@ -48,7 +48,7 @@ impl FirmwareService { const FLUSH_INTERVAL_BYTES: usize = 5 * 1024 * 1024; const FLUSH_INTERVAL_SECS: u64 = 10; const CHUNK_TIMEOUT_SECS: u64 = 30; - const TOTAL_TIMEOUT_SECS: u64 = 600; + const TOTAL_TIMEOUT_SECS: u64 = 900; info!("firmware upload started"); let start = Instant::now(); diff --git a/src/backend/src/services/mod.rs b/src/backend/src/services/mod.rs index 9ddc6f38..e7afdcd0 100644 --- a/src/backend/src/services/mod.rs +++ b/src/backend/src/services/mod.rs @@ -9,3 +9,5 @@ pub mod certificate; pub mod firmware; pub mod marker; pub mod network; +pub mod settings; +pub mod websocket; diff --git a/src/backend/src/services/network.rs b/src/backend/src/services/network.rs index 550a0f43..1a45fef9 100644 --- a/src/backend/src/services/network.rs +++ b/src/backend/src/services/network.rs @@ -1,4 +1,7 @@ -use crate::{omnect_device_service_client::DeviceServiceClient, services::marker}; +use crate::{ + omnect_device_service_client::DeviceServiceClient, + services::{marker, settings::SettingsService}, +}; use anyhow::{Context, Result}; use ini::Ini; use log::{debug, error, info}; @@ -54,10 +57,12 @@ macro_rules! clear_rollback { static SERVER_RESTART_TX: std::sync::OnceLock> = std::sync::OnceLock::new(); // ============================================================================ -// Constants +// Helpers // ============================================================================ -const ROLLBACK_TIMEOUT_SECS: u64 = 90; +fn rollback_timeout_secs() -> u64 { + SettingsService::get().network_rollback_timeout_secs.into() +} // ============================================================================ // Structs @@ -122,7 +127,7 @@ impl NetworkConfigService { } Ok(SetNetworkConfigResponse { - rollback_timeout_seconds: ROLLBACK_TIMEOUT_SECS, + rollback_timeout_seconds: rollback_timeout_secs(), ui_port: crate::config::AppConfig::get().ui.port, rollback_enabled: enable_rollback && request.is_server_addr @@ -255,9 +260,16 @@ impl NetworkConfigService { fn trigger_server_restart() -> Result<()> { let tx = SERVER_RESTART_TX .get() - .context("failed to trigger restart: channel not initialized")?; - - tx.send(()).context("failed to send restart signal")?; + .context("failed to trigger restart: channel not initialized")? + .clone(); + + // Spawn a task to delay the restart signal so the HTTP response has time to be sent to the client + tokio::spawn(async move { + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + if let Err(e) = tx.send(()) { + error!("failed to send restart signal after delay: {}", e); + } + }); Ok(()) } @@ -406,7 +418,7 @@ impl NetworkConfigService { fn create_rollback(network: &NetworkConfigRequest) -> Result<()> { let rollback = Rollback { network_config: network.clone(), - deadline: SystemTime::now() + Duration::from_secs(ROLLBACK_TIMEOUT_SECS), + deadline: SystemTime::now() + Duration::from_secs(rollback_timeout_secs()), }; info!("create rollback: {rollback:?}"); @@ -608,18 +620,24 @@ mod tests { #[test] fn response_includes_rollback_timeout() { let response = SetNetworkConfigResponse { - rollback_timeout_seconds: ROLLBACK_TIMEOUT_SECS, + rollback_timeout_seconds: u64::from( + omnect_ui_core::types::DEFAULT_NETWORK_ROLLBACK_TIMEOUT_SECS, + ), ui_port: 1977, rollback_enabled: true, }; - assert_eq!(response.rollback_timeout_seconds, 90); + assert_eq!( + response.rollback_timeout_seconds, + u64::from(omnect_ui_core::types::DEFAULT_NETWORK_ROLLBACK_TIMEOUT_SECS) + ); } #[test] fn rollback_enabled_when_ip_changed_and_is_server() { let response = SetNetworkConfigResponse { - rollback_timeout_seconds: ROLLBACK_TIMEOUT_SECS, + rollback_timeout_seconds: + omnect_ui_core::types::DEFAULT_NETWORK_ROLLBACK_TIMEOUT_SECS.into(), ui_port: 1977, rollback_enabled: true, }; @@ -630,7 +648,8 @@ mod tests { #[test] fn rollback_disabled_when_not_requested() { let response = SetNetworkConfigResponse { - rollback_timeout_seconds: ROLLBACK_TIMEOUT_SECS, + rollback_timeout_seconds: + omnect_ui_core::types::DEFAULT_NETWORK_ROLLBACK_TIMEOUT_SECS.into(), ui_port: 1977, rollback_enabled: false, }; diff --git a/src/backend/src/services/settings.rs b/src/backend/src/services/settings.rs new file mode 100644 index 00000000..434ab82f --- /dev/null +++ b/src/backend/src/services/settings.rs @@ -0,0 +1,44 @@ +use anyhow::{Context, Result}; +use log::warn; +use omnect_ui_core::types::TimeoutSettings; +use std::{fs, path::PathBuf}; + +pub struct SettingsService; + +impl SettingsService { + /// Return current timeout settings, falling back to defaults if the file is missing or corrupt. + pub fn get() -> TimeoutSettings { + match Self::load() { + Ok(settings) => settings, + Err(e) => { + warn!("failed to load settings, using defaults: {e:#}"); + TimeoutSettings::default() + } + } + } + + /// Persist timeout settings to disk. + pub fn save(settings: &TimeoutSettings) -> Result<()> { + let path = Self::settings_path(); + let json = + serde_json::to_string_pretty(settings).context("failed to serialize settings")?; + fs::write(&path, json).context(format!("failed to write settings file: {path:?}")) + } + + fn load() -> Result { + let path = Self::settings_path(); + let content = + fs::read_to_string(&path).context(format!("failed to read settings file: {path:?}"))?; + serde_json::from_str(&content).context("failed to deserialize settings") + } + + fn settings_path() -> PathBuf { + // Derive path from the config dir already created by PathConfig::load() + crate::config::AppConfig::get() + .paths + .app_config_path + .parent() + .expect("app_config_path has no parent") + .join("timeouts.json") + } +} diff --git a/src/backend/src/services/websocket.rs b/src/backend/src/services/websocket.rs new file mode 100644 index 00000000..66592900 --- /dev/null +++ b/src/backend/src/services/websocket.rs @@ -0,0 +1,129 @@ +use crate::config::AppConfig; +use actix_web::{HttpResponse, Responder, web}; +use actix_ws::Message; +use anyhow::Result; +use futures_util::StreamExt; +use log::{debug, error, warn}; +use omnect_ui_core::types::WebSocketChannel; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::sync::broadcast; + +#[derive(Deserialize, Serialize, Debug)] +pub struct PublishPayload { + pub channel: WebSocketChannel, + pub data: Value, +} + +pub async fn ws_route( + req: actix_web::HttpRequest, + stream: web::Payload, + tx: web::Data>, +) -> Result { + let peer = req.peer_addr(); + debug!("WebSocket connection attempt from {:?}", peer); + + let (response, mut session, mut msg_stream) = actix_ws::handle(&req, stream)?; + + let mut rx = tx.subscribe(); + + actix_web::rt::spawn(async move { + debug!("WebSocket session started for {:?}", peer); + loop { + tokio::select! { + res = msg_stream.next() => { + match res { + Some(Ok(Message::Ping(bytes))) => { + if session.pong(&bytes).await.is_err() { + break; + } + } + Some(Ok(Message::Close(reason))) => { + debug!("WebSocket closed by client {:?}: {:?}", peer, reason); + let _ = session.close(reason).await; + break; + } + Some(Ok(_)) => {} // ignore text/binary + Some(Err(e)) => { + error!("WebSocket protocol error for {:?}: {}", peer, e); + break; + } + None => { + debug!("WebSocket stream ended for {:?}", peer); + break; + } + } + } + + res = rx.recv() => { + match res { + Ok(msg) => { + debug!("Forwarding broadcast message to {:?}: {}", peer, msg); + if session.text(msg).await.is_err() { + debug!("Failed to send message to {:?}, closing", peer); + break; + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + warn!("WebSocket receiver for {:?} lagged by {} messages", peer, n); + } + Err(broadcast::error::RecvError::Closed) => { + debug!("Broadcast channel closed, ending WebSocket for {:?}", peer); + break; + } + } + } + } + } + debug!("WebSocket session ended for {:?}", peer); + }); + + Ok(response) +} + +pub async fn internal_publish( + req: actix_web::HttpRequest, + body: web::Bytes, + tx: web::Data>, +) -> impl Responder { + let config = AppConfig::get(); + let api_key = req.headers().get("X-API-Key").and_then(|h| h.to_str().ok()); + + debug!("internal_publish called from {:?}", req.peer_addr()); + + if api_key != Some(&config.publish.api_key) { + warn!("Unauthorized publish attempt from {:?}", req.peer_addr()); + return HttpResponse::Unauthorized().finish(); + } + + let body_str = match std::str::from_utf8(&body) { + Ok(s) => s, + Err(e) => { + error!("Invalid UTF-8 in publish body: {e}"); + return HttpResponse::BadRequest().finish(); + } + }; + + let json_value: Value = match serde_json::from_str(body_str) { + Ok(v) => v, + Err(e) => { + error!("Invalid JSON in publish body: {e}. Body: {body_str}"); + return HttpResponse::BadRequest().finish(); + } + }; + + let payload = match serde_json::from_value::(json_value) { + Ok(p) => p, + Err(e) => { + warn!("Invalid publish payload structure: {e}. Body: {body_str}"); + return HttpResponse::BadRequest().finish(); + } + }; + + if let Ok(json_str) = serde_json::to_string(&payload) { + debug!("Broadcasting payload for channel: {:?}", payload.channel); + let _ = tx.send(json_str); + } + + HttpResponse::Ok().finish() +} diff --git a/src/backend/src/wifi_commissioning_client.rs b/src/backend/src/wifi_commissioning_client.rs new file mode 100644 index 00000000..2d8b7216 --- /dev/null +++ b/src/backend/src/wifi_commissioning_client.rs @@ -0,0 +1,372 @@ +#![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; +pub use omnect_ui_core::types::{ + WifiAvailability, WifiConnectRequest, WifiConnectResponse, WifiDisconnectResponse, + WifiForgetRequest, WifiForgetResponse, WifiSavedNetworksResponse, WifiScanResultsResponse, + WifiScanStartedResponse, WifiStatusResponse, WifiVersionResponse, +}; +use reqwest::Client; +use serde::Serialize; +use std::fmt::Debug; +use std::path::Path; +use trait_variant::make; + +// --- 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; + async fn version(&self) -> 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"; + const VERSION_ENDPOINT: &str = "/api/v1/version"; + + pub const MIN_REQUIRED_VERSION: semver::Version = semver::Version::new(0, 1, 0); + + pub async fn check_availability(&self) -> WifiAvailability { + let status_result = self.status().await; + let version_result = self.version().await; + + match (status_result, version_result) { + (Ok(status), Ok(version_response)) => { + let is_version_compatible = match semver::Version::parse(&version_response.version) + { + Ok(v) => v >= Self::MIN_REQUIRED_VERSION, + Err(e) => { + log::warn!( + "Failed to parse WiFi service version '{}': {}", + version_response.version, + e + ); + false + } + }; + + if is_version_compatible { + log::info!( + "WiFi service available (version {})", + version_response.version + ); + + if let Some(interface_name) = status.interface_name { + return WifiAvailability::Available { + version: version_response.version, + interface_name, + }; + } else { + log::error!("WiFi service reported OK status but no interface name"); + } + } else { + log::warn!( + "WiFi service version '{}' is lower than required minimum {}", + version_response.version, + Self::MIN_REQUIRED_VERSION + ); + } + + WifiAvailability::Unavailable { + socket_present: true, + version: Some(version_response.version), + min_required_version: Self::MIN_REQUIRED_VERSION.to_string(), + } + } + (Err(e), _) => { + log::error!("WiFi service status probe failed: {e:#}"); + WifiAvailability::Unavailable { + socket_present: true, + version: None, + min_required_version: Self::MIN_REQUIRED_VERSION.to_string(), + } + } + (_, Err(e)) => { + log::error!("WiFi service version probe failed: {e:#}"); + WifiAvailability::Unavailable { + socket_present: true, + version: None, + min_required_version: Self::MIN_REQUIRED_VERSION.to_string(), + } + } + } + } + + /// 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") + } + + async fn version(&self) -> Result { + let body = self.get(Self::VERSION_ENDPOINT).await?; + serde_json::from_str(&body).context("failed to parse version 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" + ); + assert_eq!( + WifiCommissioningServiceClient::VERSION_ENDPOINT, + "/api/v1/version" + ); + } + } +} diff --git a/src/shared_types/Cargo.toml b/src/shared_types/Cargo.toml index 017e5285..ec3419cc 100644 --- a/src/shared_types/Cargo.toml +++ b/src/shared_types/Cargo.toml @@ -12,6 +12,6 @@ version.workspace = true [build-dependencies] anyhow = "1.0" -crux_core = { version = "0.17.0-rc2", features = ["typegen"] } -crux_http = { version = "0.16.0-rc2", features = ["typegen"] } +crux_core = { version = "0.17.0-rc3", features = ["typegen"] } +crux_http = { version = "0.16.0-rc3", features = ["typegen"] } omnect-ui-core = { path = "../app", features = ["typegen"] } diff --git a/src/shared_types/build.rs b/src/shared_types/build.rs index 1b80feb7..9d10fc17 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, TimeoutSettings, 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::()?; @@ -31,6 +33,16 @@ fn main() -> Result<()> { gen.register_type::()?; gen.register_type::()?; gen.register_type::()?; + 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::()?; diff --git a/src/ui/bun.lock b/src/ui/bun.lock index 052c85b3..50d30b26 100644 --- a/src/ui/bun.lock +++ b/src/ui/bun.lock @@ -7,23 +7,22 @@ "@mdi/font": "^7.4.47", "@vueuse/core": "^14.2.1", "axios": "^1.13.6", - "centrifuge": "^5.5.3", "oidc-client-ts": "^3.4.1", "vue": "^3.5.29", "vue-imask": "^7.6.1", "vue-router": "^5.0.3", - "vuetify": "^3.12.1", + "vuetify": "^4.0.1", }, "devDependencies": { "@biomejs/biome": "2.3.14", "@playwright/test": "^1.58.2", - "@types/bun": "^1.3.9", + "@types/bun": "^1.3.10", "@types/jsonwebtoken": "^9.0.10", "@vitejs/plugin-vue": "^6.0.4", - "@vue/tsconfig": "^0.8.1", + "@vue/tsconfig": "^0.9.0", "jsonwebtoken": "^9.0.3", "typescript": "~5.9.3", - "unocss": "^66.6.2", + "unocss": "^66.6.5", "vite": "^7.3.1", "vite-plugin-vuetify": "^2.1.3", "vue-tsc": "^3.2.5", @@ -33,22 +32,16 @@ "packages": { "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], - "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@babel/parser": ["@babel/parser@7.27.7", "", { "dependencies": { "@babel/types": "^7.27.7" }, "bin": "./bin/babel-parser.js" }, "sha512-qnzXzDXdr/po3bOTbTIQZ7+TxNKxpkN5IifVLXS+r7qwynkZfPyjZfE7hCXbo7IoO9TNcSyibgONsf2HauUd3Q=="], + "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], "@babel/runtime-corejs3": ["@babel/runtime-corejs3@7.28.0", "", { "dependencies": { "core-js-pure": "^3.43.0" } }, "sha512-nlIXnSqLcBij8K8TtkxbBJgfzfvi75V1pAKSM7dUXejGw12vJAqez74jZrHTsJ3Z+Aczc5Q/6JgNjKRMsVU44g=="], - "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], - - "@babel/traverse": ["@babel/traverse@7.27.7", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.7", "@babel/template": "^7.27.2", "@babel/types": "^7.27.7", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-X6ZlfR/O/s5EQ/SnUSLzr+6kGnkg8HXGMzpgsMsrJVcfDtH1vIp6ctCN4eZ1LS5c0+te5Cb6Y514fASjMRJ1nw=="], - "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], "@biomejs/biome": ["@biomejs/biome@2.3.14", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.14", "@biomejs/cli-darwin-x64": "2.3.14", "@biomejs/cli-linux-arm64": "2.3.14", "@biomejs/cli-linux-arm64-musl": "2.3.14", "@biomejs/cli-linux-x64": "2.3.14", "@biomejs/cli-linux-x64-musl": "2.3.14", "@biomejs/cli-win32-arm64": "2.3.14", "@biomejs/cli-win32-x64": "2.3.14" }, "bin": { "biome": "bin/biome" } }, "sha512-QMT6QviX0WqXJCaiqVMiBUCr5WRQ1iFSjvOLoTk6auKukJMvnMzWucXpwZB0e8F00/1/BsS9DzcKgWH+CLqVuA=="], @@ -69,6 +62,12 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-oizCjdyQ3WJEswpb3Chdngeat56rIdSYK12JI3iI11Mt5T5EXcZ7WLuowzEaFPNJ3zmOQFliMN8QY1Pi+qsfdQ=="], + "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], @@ -137,29 +136,53 @@ "@mdi/font": ["@mdi/font@7.4.47", "", {}, "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw=="], - "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], - "@polka/url": ["@polka/url@1.0.0-next.28", "", {}, "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.115.0", "", { "os": "android", "cpu": "arm" }, "sha512-VoB2rhgoqgYf64d6Qs5emONQW8ASiTc0xp+aUE4JUhxjX+0pE3gblTYDO0upcN5vt9UlBNmUhAwfSifkfre7nw=="], + + "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.115.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lWRX75u+gqfB4TF3pWCHuvhaeneAmRl2b2qNBcl4S6yJ0HtnT4VXOMEZrq747i4Zby1ZTxj6mtOe678Bg8gRLw=="], + + "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.115.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ii/oOZjfGY1aszXTy29Z5DRyCEnBOrAXDVCvfdfXFQsOZlbbOa7NMHD7D+06YFe5qdxfmbWAYv4yn6QJi/0d2g=="], + + "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.115.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-R/sW/p8l77wglbjpMcF+h/3rWbp9zk1mRP3U14mxTYIC2k3m+aLBpXXgk2zksqf9qKk5mcc4GIYsuCn9l8TgDg=="], + + "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.115.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-CSJ5ldNm9wIGGkhaIJeGmxRMZbgxThRN+X1ufYQQUNi5jZDV/U3C2QDMywpP93fczNBj961hXtcUPO/oVGq4Pw=="], + + "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.115.0", "", { "os": "linux", "cpu": "arm" }, "sha512-uWFwssE5dHfQ8lH+ktrsD9JA49+Qa0gtxZHUs62z1e91NgGz6O7jefHGI6aygNyKNS45pnnBSDSP/zV977MsOQ=="], - "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.115.0", "", { "os": "linux", "cpu": "arm" }, "sha512-fZbqt8y/sKQ+v6bBCuv/mYYFoC0+fZI3mGDDEemmDOhT78+aUs2+4ZMdbd2btlXmnLaScl37r8IRbhnok5Ka9w=="], - "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.115.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1ej/MjuTY9tJEunU/hUPIFmgH5PqgMQoRjNOvOkibtJ3Zqlw/+Lc+HGHDNET8sjbgIkWzdhX+p4J96A5CPdbag=="], - "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], + "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.115.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-HjsZbJPH9mMd4swJRywVMsDZsJX0hyKb1iNHo5ijRl5yhtbO3lj7ImSrrL1oZ1VEg0te4iKmDGGz/6YPLd1G8w=="], - "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.115.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-zhhePoBrd7kQx3oClX/W6NldsuCbuMqaN9rRsY+6/WoorAb4j490PG/FjqgAXscWp2uSW2WV9L+ksn0wHrvsrg=="], - "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], + "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.115.0", "", { "os": "linux", "cpu": "none" }, "sha512-t/IRojvUE9XrKu+/H1b8YINug+7Q6FLls5rsm2lxB5mnS8GN/eYAYrPgHkcg9/1SueRDSzGpDYu3lGWTObk1zw=="], - "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.115.0", "", { "os": "linux", "cpu": "none" }, "sha512-79jBHSSh/YpQRAmvYoaCfpyToRbJ/HBrdB7hxK2ku2JMehjopTVo+xMJss/RV7/ZYqeezgjvKDQzapJbgcjVZA=="], - "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], + "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.115.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-nA1TpxkhNTIOMMyiSSsa7XIVJVoOU/SsVrHIz3gHvWweB5PHCQfO7w+Lb2EP0lBWokv7HtA/KbF7aLDoXzmuMw=="], - "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.115.0", "", { "os": "linux", "cpu": "x64" }, "sha512-9iVX789DoC3SaOOG+X6NcF/tVChgLp2vcHffzOC2/Z1JTPlz6bMG2ogvcW6/9s0BG2qvhNQImd+gbWYeQbOwVw=="], - "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.115.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RmQmk+mjCB0nMNfEYhaCxwofLo1Z95ebHw1AGvRiWGCd4zhCNOyskgCbMogIcQzSB3SuEKWgkssyaiQYVAA4hQ=="], - "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.115.0", "", { "os": "none", "cpu": "arm64" }, "sha512-viigraWWQhhDvX5aGq+wrQq58k00Xq3MHz/0R4AFMxGlZ8ogNonpEfNc73Q5Ly87Z6sU9BvxEdG0dnYTfVnmew=="], + + "@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.115.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-IzGCrMwXhpb4kTXy/8lnqqqwjI7eOvy+r9AhVw+hsr8t1ecBBEHprcNy0aKatFHN6hsX7UMHHQmBAQjVvL/p1A=="], + + "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.115.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-/ym+Absk/TLFvbhh3se9XYuI1D7BrUVHw4RaG/2dmWKgBenrZHaJsgnRb7NJtaOyjEOLIPtULx1wDdVL0SX2eg=="], + + "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.115.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-AQSZjIR+b+Te7uaO/hGTMjT8/oxlYrvKrOTi4KTHF/O6osjHEatUQ3y6ZW2+8+lJxy20zIcGz6iQFmFq/qDKkg=="], + + "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.115.0", "", { "os": "win32", "cpu": "x64" }, "sha512-oxUl82N+fIO9jIaXPph8SPPHQXrA08BHokBBJW8ct9F/x6o6bZE6eUAhUtWajbtvFhL8UYcCWRMba+kww6MBlA=="], + + "@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="], + + "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + + "@polka/url": ["@polka/url@1.0.0-next.28", "", {}, "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw=="], "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], @@ -205,7 +228,9 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.44.1", "", { "os": "win32", "cpu": "x64" }, "sha512-J8o22LuF0kTe7m+8PvW9wk3/bRq5+mRo5Dqo6+vXb7otCm3TPhYOJqOaQtGU9YMWQSL3krMnoOxMr0+9E6F3Ug=="], - "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -217,47 +242,47 @@ "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="], - "@unocss/cli": ["@unocss/cli@66.6.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@unocss/config": "66.6.2", "@unocss/core": "66.6.2", "@unocss/preset-wind3": "66.6.2", "@unocss/preset-wind4": "66.6.2", "@unocss/transformer-directives": "66.6.2", "cac": "^6.7.14", "chokidar": "^5.0.0", "colorette": "^2.0.20", "consola": "^3.4.2", "magic-string": "^0.30.21", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "tinyglobby": "^0.2.15", "unplugin-utils": "^0.3.1" }, "bin": { "unocss": "bin/unocss.mjs" } }, "sha512-N7nKnOJ/36FRs3PE7+CFbzg7UBhIsucYYAK5xjJScX0H2q8O6rODaNM5uvc77Qh4q+y1S/Bt5ArOwIewzdpP4w=="], + "@unocss/cli": ["@unocss/cli@66.6.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@unocss/config": "66.6.4", "@unocss/core": "66.6.5", "@unocss/preset-wind3": "66.6.5", "@unocss/preset-wind4": "66.6.5", "@unocss/transformer-directives": "66.6.5", "cac": "^6.7.14", "chokidar": "^5.0.0", "colorette": "^2.0.20", "consola": "^3.4.2", "magic-string": "^0.30.21", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "tinyglobby": "^0.2.15", "unplugin-utils": "^0.3.1" }, "bin": { "unocss": "bin/unocss.mjs" } }, "sha512-UlETATpAZ+A5gOfj+z+BMXuIUcXCMjvlQteQE0VR2Yf0VIxz4sVO4z0VCXwXsxLTMfQiIMDpKVrGeczcYicvTA=="], - "@unocss/config": ["@unocss/config@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2", "colorette": "^2.0.20", "consola": "^3.4.2", "unconfig": "^7.5.0" } }, "sha512-qny2bRW1OA+MZbWShVZdBg6fJundm1LqQwCxJnIpeK3McpPHS3pnHBiwD1wfZHY2z5Pe+XgZOZkozNmG/eyyqg=="], + "@unocss/config": ["@unocss/config@66.6.4", "", { "dependencies": { "@unocss/core": "66.6.4", "colorette": "^2.0.20", "consola": "^3.4.2", "unconfig": "^7.5.0" } }, "sha512-iwHl5FG81cOAMalqigjw21Z2tMa0xjN0doQxnGOLx8KP+BllruXSjBj8CRk3m6Ny9fDxfpFY0ruYbIBA5AGwDQ=="], - "@unocss/core": ["@unocss/core@66.6.2", "", {}, "sha512-IOvN1BLRP0VTjjS5afSxmXhvKRDko2Shisp8spU+A9qiH1tXEFP3phyVevm/SuGwBHO1lC+SJ451/4oFkCAwJA=="], + "@unocss/core": ["@unocss/core@66.6.5", "", {}, "sha512-hzjo+0EF+pNbf+tb0OjRNZRF9BJoKECcZZgtufxRPpWJdlv+aYmNkH1p9fldlHHzYcn3ZqVnnHnmk7HwaolJbg=="], - "@unocss/extractor-arbitrary-variants": ["@unocss/extractor-arbitrary-variants@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2" } }, "sha512-D2tK/8QClrVViSuoH5eLjXwlVOK1UgXx7ukz/D260+R6vhCmjv97RXPouZkq40sxGzfxzaQZUyPEjXLjtnO3bw=="], + "@unocss/extractor-arbitrary-variants": ["@unocss/extractor-arbitrary-variants@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5" } }, "sha512-wqzRtbyy3I595WCwwb8VBmznJTHWcTdylzVT+WBgacJDjRlT1sXaq2fRlOsHvtTRj1qG70t3PwKc6XgU0hutNg=="], - "@unocss/inspector": ["@unocss/inspector@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2", "@unocss/rule-utils": "66.6.2", "colorette": "^2.0.20", "gzip-size": "^6.0.0", "sirv": "^3.0.2" } }, "sha512-q0kktb01dXeeXyNnNwYM1SkSHxrEOQhCZ/YQ5aCdC7BWNGF4yZMK0YrJXmGUTEHN4RhEPLN/rAIsDBsKcoFaAQ=="], + "@unocss/inspector": ["@unocss/inspector@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/rule-utils": "66.6.5", "colorette": "^2.0.20", "gzip-size": "^6.0.0", "sirv": "^3.0.2" } }, "sha512-rrXPlSeRfYajEL65FL1Ok9Hfhjy9zvuZZwqXh9P0qCJlou2r2IqDFO/Gf9j5yO89tnKIfJ8ff6jEyqUmzbKSMQ=="], - "@unocss/preset-attributify": ["@unocss/preset-attributify@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2" } }, "sha512-pRry38qO1kJvj5/cekbDk0QLosty+UFQ3fhNiph88D//jkT5tsUCn77nB/RTSe7oTqw/FqNwxPgbGz/wfNWqZg=="], + "@unocss/preset-attributify": ["@unocss/preset-attributify@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5" } }, "sha512-fx+pKMZ0WgT+dfinVaLkNXlx6oZFwtMbZj5O/1SQia0UcfhnyS+G35HYpbgoc9GEAl3DclxxotzZjveZm++9fA=="], - "@unocss/preset-icons": ["@unocss/preset-icons@66.6.2", "", { "dependencies": { "@iconify/utils": "^3.1.0", "@unocss/core": "66.6.2", "ofetch": "^1.5.1" } }, "sha512-FjhxvYX+21HefYdMIxJCq8C9v/K7fSlO1DMqDQgtrCp0/WvHyFncHILLOwp064M7m3AqzOVJx7Vw/zCvKy0Jrg=="], + "@unocss/preset-icons": ["@unocss/preset-icons@66.6.5", "", { "dependencies": { "@iconify/utils": "^3.1.0", "@unocss/core": "66.6.5", "ofetch": "^1.5.1" } }, "sha512-03ppAcTWD77w1WZhORT8c9beTHBtWu3cx+c4qfShOfY6LQmZgx5i7DhCij5Wcj/U1zYA4Vrh13CDEmpsdZO3Cw=="], - "@unocss/preset-mini": ["@unocss/preset-mini@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2", "@unocss/extractor-arbitrary-variants": "66.6.2", "@unocss/rule-utils": "66.6.2" } }, "sha512-mybpiAq9htF7PWPH1Mnb4y7hrxVwpsBg8VfbjSglY3SfLca8RrJtvBT+DVh7YUDRiYsZGfihRWkfD0AN68gkcA=="], + "@unocss/preset-mini": ["@unocss/preset-mini@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/extractor-arbitrary-variants": "66.6.5", "@unocss/rule-utils": "66.6.5" } }, "sha512-Ber3k2jlE8JP0y507hw/lvdDvcxfY0t4zaGA7hVZdEqlH6Eus/TqIVZ9tdMH4u0VDWYeAs98YV+auUJmMqGXpg=="], - "@unocss/preset-tagify": ["@unocss/preset-tagify@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2" } }, "sha512-ybb45So2x87P3bssLRp1uIS+VHAeNSecwkHqiv93PnuBDJ38/9XlqWF98uga2MEfNM3zvMj9plX9MauidxiPrw=="], + "@unocss/preset-tagify": ["@unocss/preset-tagify@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5" } }, "sha512-YYk/eg1OWX4Nx7rK1YZLMHXXntzNRDHp6BIInJteQmlXw0sFgrtdMKj7fnxrORsBDHwxWMp4sWEucPvfCtTlVQ=="], - "@unocss/preset-typography": ["@unocss/preset-typography@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2", "@unocss/rule-utils": "66.6.2" } }, "sha512-1f/ZfeuLQOnO48mRz1+6UdoJxa13ZYcamaLz7ft96n7D1eWvkOUAC/AUUke/kbHh3vvqwRVimC9OpdXxdGFQAQ=="], + "@unocss/preset-typography": ["@unocss/preset-typography@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/rule-utils": "66.6.5" } }, "sha512-Cb63tdC0P2rgj/4t4DrSCl6RHebNpjUp9FQArg0KCnFnW75nWtKlsKpHuEXpi7KwrgOIx+rjlkwC1bDcsdNLHw=="], - "@unocss/preset-uno": ["@unocss/preset-uno@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2", "@unocss/preset-wind3": "66.6.2" } }, "sha512-Wy3V25ZF29OmVHJk5ghP6HCCRNBJXm0t+bKLKJJknOjD+/D51DZbUsDqZBtTpVtgi/SOPDbw7cX3lY2oqt4Hnw=="], + "@unocss/preset-uno": ["@unocss/preset-uno@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/preset-wind3": "66.6.5" } }, "sha512-feZfGyzt3dH4h6yP2kjsx5MuoI1gU7vY/VL5O+ObosaB7HzzOFCsu2WzlvWn/FTRBi+scvdq436hsfflVyHYfQ=="], - "@unocss/preset-web-fonts": ["@unocss/preset-web-fonts@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2", "ofetch": "^1.5.1" } }, "sha512-0ckqiE8HkhETeghhxCXVGf96sNPhgBsB5q32iAuMM0HFR4x+ANiLqyfKrm/iqxKUw6rVO4+ItTV0RUWKcZvkXg=="], + "@unocss/preset-web-fonts": ["@unocss/preset-web-fonts@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "ofetch": "^1.5.1" } }, "sha512-u5jEHYTMeseykqinXd2VY2n7q9yFQlZotREpfSAft8ENNJdV7Yg/6It3lL68zT/k1AV/A8gk94KEuDh0fnoSxQ=="], - "@unocss/preset-wind": ["@unocss/preset-wind@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2", "@unocss/preset-wind3": "66.6.2" } }, "sha512-G0H4baUizmTByEowqGuYbKpU2TTisDhZ9W7hrIpYFbRkFv0i1kN2mIxCwj/FLmdY/6x8iSRJ7rO8Nez63YYhnw=="], + "@unocss/preset-wind": ["@unocss/preset-wind@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/preset-wind3": "66.6.5" } }, "sha512-GLu7LzVF0LHqdZoHFZ8dbsCv8TD5ZH/r10CQbrL5qwmp4a/uyfDEmsre4Nsqim7JktRyXn3HK2XQmTB8AmXpgQ=="], - "@unocss/preset-wind3": ["@unocss/preset-wind3@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2", "@unocss/preset-mini": "66.6.2", "@unocss/rule-utils": "66.6.2" } }, "sha512-UqdU2Obx3wXid9xeBHGY1MWxedXa43MGuP5Z2FA9modcXptReux4Zhy764SeQwx6acOUEql2/CTvOBwelZzheQ=="], + "@unocss/preset-wind3": ["@unocss/preset-wind3@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/preset-mini": "66.6.5", "@unocss/rule-utils": "66.6.5" } }, "sha512-0ccQoJmHq4tTnn5C0UKhP598B/gG65AjqlfgfRpwt059yAWYqizGy6MRUGdLklyEK4H06E6qbMBqIjla2rOexQ=="], - "@unocss/preset-wind4": ["@unocss/preset-wind4@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2", "@unocss/extractor-arbitrary-variants": "66.6.2", "@unocss/rule-utils": "66.6.2" } }, "sha512-XU+4NN9QIMefawDB9FqOeKONXeGDUJQuQgOeBcpbV/jwOYtyqRrHiqQg++fy1hRbluM+S+KqwRHYjvje8zCTow=="], + "@unocss/preset-wind4": ["@unocss/preset-wind4@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/extractor-arbitrary-variants": "66.6.5", "@unocss/rule-utils": "66.6.5" } }, "sha512-JT57CU60PY3/PHBvxY+UG53I9K+awin/TodZTn4lqQNnF2v6fjkeBKiys9cxeoP4wbHuQWorrW4GqRLNDWIMcw=="], - "@unocss/rule-utils": ["@unocss/rule-utils@66.6.2", "", { "dependencies": { "@unocss/core": "^66.6.2", "magic-string": "^0.30.21" } }, "sha512-cygfCtkeMrqMM6si1cnyOF16sS7M2gCAqgmZybAhGV7tmH7V8Izn52JZiZIrxVRNMz9dWMVWerHEI9nLbFdbrg=="], + "@unocss/rule-utils": ["@unocss/rule-utils@66.6.5", "", { "dependencies": { "@unocss/core": "^66.6.5", "magic-string": "^0.30.21" } }, "sha512-eDGXoMebb5aeEAFa2y4gnGLC+CHZPx93JYCt6uvEyf9xOoetwDcZaYC8brWdjaSKn+WVgsfxiZreC7F0rJywOQ=="], - "@unocss/transformer-attributify-jsx": ["@unocss/transformer-attributify-jsx@66.6.2", "", { "dependencies": { "@babel/parser": "7.27.7", "@babel/traverse": "7.27.7", "@unocss/core": "66.6.2" } }, "sha512-WiAEdEowGjQWu1ayhkGGBNGyw3mZLzZ+V5o3zx5U2GPuqvP67YIUfvY+/gTkCnd4+A8unkb+a1VeVgr4cHUkQw=="], + "@unocss/transformer-attributify-jsx": ["@unocss/transformer-attributify-jsx@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "oxc-parser": "^0.115.0", "oxc-walker": "^0.7.0" } }, "sha512-/dVaRR7V/2Alskb2rUPmP/lhyb/YCxYyYNxp30kxxW0ew6mZWXQRzsxOJJVmGp23Uw7HxUW63t8zXzUdoI0b+g=="], - "@unocss/transformer-compile-class": ["@unocss/transformer-compile-class@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2" } }, "sha512-L0yaQAmvWkm6LVLXMviqhHIi4c7WQpZFBgJF8jfsALyHihh8K9U9OrRJ81zfLH3Ltw5ZbGzoDE8m/2bB6aRhyw=="], + "@unocss/transformer-compile-class": ["@unocss/transformer-compile-class@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5" } }, "sha512-U/ukk5lyZOFNyz9hVzZBkxciayjgimyfPuQBa5PHSC4W3nDmnFd1zgXzUVaM6KduPmiTExzpJSDgELb2OTbpqg=="], - "@unocss/transformer-directives": ["@unocss/transformer-directives@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2", "@unocss/rule-utils": "66.6.2", "css-tree": "^3.1.0" } }, "sha512-gjLDLItTUJ4CV8K2AA0cw381a7rJ3U4kCHQmZmN3+956o2R7cEHSLyEczmMy04Mg2JBomrjIZjo+L66z5rvblQ=="], + "@unocss/transformer-directives": ["@unocss/transformer-directives@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/rule-utils": "66.6.5", "css-tree": "^3.1.0" } }, "sha512-QgofDdDedNK6dQ246+RXhM6gTzRz7NuetQQ8UnNgArm4PBHngVrrkjCzG1ByDTtEtoE8WR70UMR4Vf5dXTcHPw=="], - "@unocss/transformer-variant-group": ["@unocss/transformer-variant-group@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2" } }, "sha512-Uoo6xthOHJ36NdN4b7s/Y7R3fZOf4JYgKzuldHEyHAo0LL204Ss+Ah0+TEt4v72aq+Z86vrLJPyYCeGNKdr8cA=="], + "@unocss/transformer-variant-group": ["@unocss/transformer-variant-group@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5" } }, "sha512-k6vQgn/P7ObHBRYw6o1+xwdQIfwc6b9O5TFFe87UmBB6hJ2zaHWRVuPB6oky7F9Gz8bPfXC3WJuv7UyIwRmBQQ=="], - "@unocss/vite": ["@unocss/vite@66.6.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@unocss/config": "66.6.2", "@unocss/core": "66.6.2", "@unocss/inspector": "66.6.2", "chokidar": "^5.0.0", "magic-string": "^0.30.21", "pathe": "^2.0.3", "tinyglobby": "^0.2.15", "unplugin-utils": "^0.3.1" }, "peerDependencies": { "vite": "^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0-0" } }, "sha512-HLmzDvde3BJ2C6iromHVE21lmNm4SmGSMlbSbFuLPOmWV11XhhHBkAOzytSxPBRG0dbuo+InSGUM14Ek2d6UDg=="], + "@unocss/vite": ["@unocss/vite@66.6.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@unocss/config": "66.6.4", "@unocss/core": "66.6.5", "@unocss/inspector": "66.6.5", "chokidar": "^5.0.0", "magic-string": "^0.30.21", "pathe": "^2.0.3", "tinyglobby": "^0.2.15", "unplugin-utils": "^0.3.1" }, "peerDependencies": { "vite": "^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0-0" } }, "sha512-J/QZa6h94ordZlZytIKQkuYa+G2GiWiS3y9O1uoHAAN2tzFSkgCXNUif7lHu1h4eCrgC0AOHJSYWg1LIASNDkg=="], "@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.4", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.2" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", "vue": "^3.2.25" } }, "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ=="], @@ -295,7 +320,7 @@ "@vue/shared": ["@vue/shared@3.5.29", "", {}, "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg=="], - "@vue/tsconfig": ["@vue/tsconfig@0.8.1", "", { "peerDependencies": { "typescript": "5.x", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g=="], + "@vue/tsconfig": ["@vue/tsconfig@0.9.0", "", { "peerDependencies": { "typescript": "5.x", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-RP+v9Cpbsk1ZVXltCHHkYBr7+624x6gcijJXVjIcsYk7JXqvIpRtMwU2ARLvWDhmy9ffdFYxhsfJnPztADBohQ=="], "@vuetify/loader-shared": ["@vuetify/loader-shared@2.1.2", "", { "dependencies": { "upath": "^2.0.1" }, "peerDependencies": { "vue": "^3.0.0", "vuetify": ">=3" } }, "sha512-X+1jBLmXHkpQEnC0vyOb4rtX2QSkBiFhaFXz8yhQqN2A4vQ6k2nChxN4Ol7VAY5KoqMdFoRMnmNdp/1qYXDQig=="], @@ -321,14 +346,12 @@ "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], - "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - "centrifuge": ["centrifuge@5.5.3", "", { "dependencies": { "events": "^3.3.0", "protobufjs": "^7.2.5" } }, "sha512-LPkWnsAxu7JaE5S738XiREzJIko3Pf/qnWb3kd0q/OR0OJONUbSdnZ5pKsEI2yFi/odQ89SYsHPhLuEeZFhp/g=="], - "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], @@ -375,8 +398,6 @@ "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], - "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -393,8 +414,6 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "gzip-size": ["gzip-size@6.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q=="], @@ -413,8 +432,6 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], @@ -443,7 +460,7 @@ "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], - "long": ["long@5.3.1", "", {}, "sha512-ka87Jz3gcx/I7Hal94xaN2tZEOPoUOEVftkQqZx2EeQRN7LGdfLlI3FvZ+7WDplm+vK2Urx9ULrvSowtdCieng=="], + "magic-regexp": ["magic-regexp@0.10.0", "", { "dependencies": { "estree-walker": "^3.0.3", "magic-string": "^0.30.12", "mlly": "^1.7.2", "regexp-tree": "^0.1.27", "type-level-regexp": "~0.1.17", "ufo": "^1.5.4", "unplugin": "^2.0.0" } }, "sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -475,6 +492,10 @@ "oidc-client-ts": ["oidc-client-ts@3.4.1", "", { "dependencies": { "jwt-decode": "^4.0.0" } }, "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw=="], + "oxc-parser": ["oxc-parser@0.115.0", "", { "dependencies": { "@oxc-project/types": "^0.115.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.115.0", "@oxc-parser/binding-android-arm64": "0.115.0", "@oxc-parser/binding-darwin-arm64": "0.115.0", "@oxc-parser/binding-darwin-x64": "0.115.0", "@oxc-parser/binding-freebsd-x64": "0.115.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.115.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.115.0", "@oxc-parser/binding-linux-arm64-gnu": "0.115.0", "@oxc-parser/binding-linux-arm64-musl": "0.115.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.115.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.115.0", "@oxc-parser/binding-linux-riscv64-musl": "0.115.0", "@oxc-parser/binding-linux-s390x-gnu": "0.115.0", "@oxc-parser/binding-linux-x64-gnu": "0.115.0", "@oxc-parser/binding-linux-x64-musl": "0.115.0", "@oxc-parser/binding-openharmony-arm64": "0.115.0", "@oxc-parser/binding-wasm32-wasi": "0.115.0", "@oxc-parser/binding-win32-arm64-msvc": "0.115.0", "@oxc-parser/binding-win32-ia32-msvc": "0.115.0", "@oxc-parser/binding-win32-x64-msvc": "0.115.0" } }, "sha512-2w7Xn3CbS/zwzSY82S5WLemrRu3CT57uF7Lx8llrE/2bul6iMTcJE4Rbls7GDNbLn3ttATI68PfOz2Pt3KZ2cQ=="], + + "oxc-walker": ["oxc-walker@0.7.0", "", { "dependencies": { "magic-regexp": "^0.10.0" }, "peerDependencies": { "oxc-parser": ">=0.98.0" } }, "sha512-54B4KUhrzbzc4sKvKwVYm7E2PgeROpGba0/2nlNZMqfDyca+yOor5IMb4WLGBatGDT0nkzYdYuzylg7n3YfB7A=="], + "package-manager-detector": ["package-manager-detector@1.3.0", "", {}, "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ=="], "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], @@ -495,14 +516,14 @@ "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], - "protobufjs": ["protobufjs@7.4.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw=="], - "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "regexp-tree": ["regexp-tree@0.1.27", "", { "bin": { "regexp-tree": "bin/regexp-tree" } }, "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA=="], + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], "rollup": ["rollup@4.44.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.44.1", "@rollup/rollup-android-arm64": "4.44.1", "@rollup/rollup-darwin-arm64": "4.44.1", "@rollup/rollup-darwin-x64": "4.44.1", "@rollup/rollup-freebsd-arm64": "4.44.1", "@rollup/rollup-freebsd-x64": "4.44.1", "@rollup/rollup-linux-arm-gnueabihf": "4.44.1", "@rollup/rollup-linux-arm-musleabihf": "4.44.1", "@rollup/rollup-linux-arm64-gnu": "4.44.1", "@rollup/rollup-linux-arm64-musl": "4.44.1", "@rollup/rollup-linux-loongarch64-gnu": "4.44.1", "@rollup/rollup-linux-powerpc64le-gnu": "4.44.1", "@rollup/rollup-linux-riscv64-gnu": "4.44.1", "@rollup/rollup-linux-riscv64-musl": "4.44.1", "@rollup/rollup-linux-s390x-gnu": "4.44.1", "@rollup/rollup-linux-x64-gnu": "4.44.1", "@rollup/rollup-linux-x64-musl": "4.44.1", "@rollup/rollup-win32-arm64-msvc": "4.44.1", "@rollup/rollup-win32-ia32-msvc": "4.44.1", "@rollup/rollup-win32-x64-msvc": "4.44.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg=="], @@ -527,6 +548,10 @@ "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-level-regexp": ["type-level-regexp@0.1.17", "", {}, "sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], @@ -537,7 +562,7 @@ "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "unocss": ["unocss@66.6.2", "", { "dependencies": { "@unocss/cli": "66.6.2", "@unocss/core": "66.6.2", "@unocss/preset-attributify": "66.6.2", "@unocss/preset-icons": "66.6.2", "@unocss/preset-mini": "66.6.2", "@unocss/preset-tagify": "66.6.2", "@unocss/preset-typography": "66.6.2", "@unocss/preset-uno": "66.6.2", "@unocss/preset-web-fonts": "66.6.2", "@unocss/preset-wind": "66.6.2", "@unocss/preset-wind3": "66.6.2", "@unocss/preset-wind4": "66.6.2", "@unocss/transformer-attributify-jsx": "66.6.2", "@unocss/transformer-compile-class": "66.6.2", "@unocss/transformer-directives": "66.6.2", "@unocss/transformer-variant-group": "66.6.2", "@unocss/vite": "66.6.2" }, "peerDependencies": { "@unocss/astro": "66.6.2", "@unocss/postcss": "66.6.2", "@unocss/webpack": "66.6.2" }, "optionalPeers": ["@unocss/astro", "@unocss/postcss", "@unocss/webpack"] }, "sha512-ulkfFBFm++/yTdgDn/clpxtm3GxynZi57F4KETQkMQWRXUI7FwqPKGn0xooscvbtldlX67pkovwj/mzkwExitQ=="], + "unocss": ["unocss@66.6.5", "", { "dependencies": { "@unocss/cli": "66.6.5", "@unocss/core": "66.6.5", "@unocss/preset-attributify": "66.6.5", "@unocss/preset-icons": "66.6.5", "@unocss/preset-mini": "66.6.5", "@unocss/preset-tagify": "66.6.5", "@unocss/preset-typography": "66.6.5", "@unocss/preset-uno": "66.6.5", "@unocss/preset-web-fonts": "66.6.5", "@unocss/preset-wind": "66.6.5", "@unocss/preset-wind3": "66.6.5", "@unocss/preset-wind4": "66.6.5", "@unocss/transformer-attributify-jsx": "66.6.5", "@unocss/transformer-compile-class": "66.6.5", "@unocss/transformer-directives": "66.6.5", "@unocss/transformer-variant-group": "66.6.5", "@unocss/vite": "66.6.5" }, "peerDependencies": { "@unocss/astro": "66.6.5", "@unocss/postcss": "66.6.5", "@unocss/webpack": "66.6.5" }, "optionalPeers": ["@unocss/astro", "@unocss/postcss", "@unocss/webpack"] }, "sha512-WlpPlV7yAzEPREcwaKeacP+1jOm6ImhyKJRkK18tIW2b2BRZZDKln7X8P+NzJtAr0kziNY/ttUKZNZRnSmzP1A=="], "unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], @@ -561,43 +586,25 @@ "vue-tsc": ["vue-tsc@3.2.5", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.2.5" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-/htfTCMluQ+P2FISGAooul8kO4JMheOTCbCy4M6dYnYYjqLe3BExZudAua6MSIKSFYQtFOYAll7XobYwcpokGA=="], - "vuetify": ["vuetify@3.12.1", "", { "peerDependencies": { "typescript": ">=4.7", "vite-plugin-vuetify": ">=2.1.0", "vue": "^3.5.0", "webpack-plugin-vuetify": ">=3.1.0" }, "optionalPeers": ["typescript", "vite-plugin-vuetify", "webpack-plugin-vuetify"] }, "sha512-JDHDzs1e195YJ9L3X4nWQySGSMyTxr0BefIY4+l/CpAgTd9pPV5F6oZzI8ZLuikMxS4HhfSGHteOAe6u/zh4vQ=="], + "vuetify": ["vuetify@4.0.1", "", { "peerDependencies": { "typescript": ">=4.7", "vite-plugin-vuetify": ">=2.1.0", "vue": "^3.5.0", "webpack-plugin-vuetify": ">=3.1.0" }, "optionalPeers": ["typescript", "vite-plugin-vuetify", "webpack-plugin-vuetify"] }, "sha512-0z0lKd1r6KmVK4YliDb4w2k9Yk7Z83V5kxpn9z5glipIcFJEizJK+LfFBzy3G2tMxuhSZHU4DmQ4qxfpvLhjQg=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], - "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], - - "@babel/generator/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], - - "@babel/parser/@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="], - - "@babel/template/@babel/parser": ["@babel/parser@7.28.3", "", { "dependencies": { "@babel/types": "^7.28.2" }, "bin": "./bin/babel-parser.js" }, "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA=="], - - "@babel/template/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], - - "@babel/traverse/@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="], - - "@babel/traverse/@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="], - - "@babel/traverse/@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="], - "@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], "@quansync/fs/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], - "@vue-macros/common/@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.22", "", { "dependencies": { "@babel/parser": "^7.28.4", "@vue/compiler-core": "3.5.22", "@vue/compiler-dom": "3.5.22", "@vue/compiler-ssr": "3.5.22", "@vue/shared": "3.5.22", "estree-walker": "^2.0.2", "magic-string": "^0.30.19", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ=="], - - "@vue/compiler-core/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "@unocss/config/@unocss/core": ["@unocss/core@66.6.4", "", {}, "sha512-Fii3lhVJVFrKUz6hMGAkq3sXBfNnXB2G8bldNHuBHJpDAoP1F0oO/SU/oSqSjCYvtcD5RtOn8qwzcHuuN3B/mg=="], - "@vue/compiler-sfc/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "@vue-macros/common/@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.22", "", { "dependencies": { "@babel/parser": "^7.28.4", "@vue/compiler-core": "3.5.22", "@vue/compiler-dom": "3.5.22", "@vue/compiler-ssr": "3.5.22", "@vue/shared": "3.5.22", "estree-walker": "^2.0.2", "magic-string": "^0.30.19", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ=="], - "ast-kit/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "magic-regexp/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "ast-walker-scope/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "magic-regexp/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], @@ -607,16 +614,6 @@ "unconfig-core/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], - "@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], - - "@babel/template/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], - - "@babel/traverse/@babel/generator/@babel/parser": ["@babel/parser@7.28.3", "", { "dependencies": { "@babel/types": "^7.28.2" }, "bin": "./bin/babel-parser.js" }, "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA=="], - - "@babel/traverse/@babel/generator/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], - - "@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], - "@vue-macros/common/@vue/compiler-sfc/@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="], "@vue-macros/common/@vue/compiler-sfc/@vue/compiler-core": ["@vue/compiler-core@3.5.22", "", { "dependencies": { "@babel/parser": "^7.28.4", "@vue/shared": "3.5.22", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ=="], @@ -633,8 +630,6 @@ "mlly/pkg-types/mlly": ["mlly@1.7.4", "", { "dependencies": { "acorn": "^8.14.0", "pathe": "^2.0.1", "pkg-types": "^1.3.0", "ufo": "^1.5.4" } }, "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw=="], - "@babel/traverse/@babel/generator/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], - "@vue-macros/common/@vue/compiler-sfc/@babel/parser/@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="], "@vue-macros/common/@vue/compiler-sfc/@vue/compiler-core/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], diff --git a/src/ui/package.json b/src/ui/package.json index 981132ab..7446c4cb 100644 --- a/src/ui/package.json +++ b/src/ui/package.json @@ -19,23 +19,22 @@ "@mdi/font": "^7.4.47", "@vueuse/core": "^14.2.1", "axios": "^1.13.6", - "centrifuge": "^5.5.3", "oidc-client-ts": "^3.4.1", "vue": "^3.5.29", "vue-imask": "^7.6.1", "vue-router": "^5.0.3", - "vuetify": "^3.12.1" + "vuetify": "^4.0.1" }, "devDependencies": { "@biomejs/biome": "2.3.14", "@playwright/test": "^1.58.2", - "@types/bun": "^1.3.9", + "@types/bun": "^1.3.10", "@types/jsonwebtoken": "^9.0.10", "@vitejs/plugin-vue": "^6.0.4", - "@vue/tsconfig": "^0.8.1", + "@vue/tsconfig": "^0.9.0", "jsonwebtoken": "^9.0.3", "typescript": "~5.9.3", - "unocss": "^66.6.2", + "unocss": "^66.6.5", "vite": "^7.3.1", "vite-plugin-vuetify": "^2.1.3", "vue-tsc": "^3.2.5" diff --git a/src/ui/playwright.config.ts b/src/ui/playwright.config.ts index c2b44385..33774365 100644 --- a/src/ui/playwright.config.ts +++ b/src/ui/playwright.config.ts @@ -7,13 +7,13 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests', /* Run tests in files in parallel */ - /* Disabled: Network tests share a Centrifugo WebSocket channel, parallel execution causes interference */ + /* Disabled: Network tests share a WebSocket channel, parallel execution causes interference */ fullyParallel: false, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ retries: process.env.CI ? 2 : 0, - /* Use single worker to prevent Centrifugo channel interference between test files */ + /* Use single worker to prevent WebSocket channel interference between test files */ workers: 1, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', @@ -24,7 +24,7 @@ export default defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', - + /* Ignore HTTPS errors for self-signed certs */ ignoreHTTPSErrors: true, }, diff --git a/src/ui/src/App.vue b/src/ui/src/App.vue index 5e9bff82..5bc8afb8 100644 --- a/src/ui/src/App.vue +++ b/src/ui/src/App.vue @@ -1,6 +1,5 @@