diff --git a/README.md b/README.md index 7de8cc8a6..9355bdaaa 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ MeshCore runs simultaneously alongside Meshtastic and Reticulum. Use the protoco - BLE: waits for GATT init (`connected` event) before issuing commands; includes nudge timeout for stuck `deviceQuery` on some devices. On **Windows**, **pair the MeshCore device in Settings → Bluetooth & devices** before connecting in the app; WinRT may need a bonded device for a stable Nordic UART session. On **Linux**, the app checks BlueZ pairing and may prompt for the PIN **before** Web Bluetooth completes when the radio is not bonded. A **second connect attempt** may run automatically after some transient GATT discovery or handshake timeouts (retry reuses the granted device without a new picker gesture). - Serial: auto-reconnects on startup using a saved port signature so reconnect targets the same physical device when possible - TCP: connects to MeshCore companion radio; default port **5000**, configurable per connection -- **MQTT (JSON v1):** The Connection tab MQTT card includes **Network Preset** buttons (order: **LetsMesh**, **MeshMapper**, **Colorado Mesh**, **Ripple Networks**, **Custom**). New installs default to **LetsMesh** (WebSocket on port 443, topic prefix `meshcore/test`; broker auth uses `@michaelhart/meshcore-decoder`'s `createAuthToken`; MQTT username `v1_<64-hex public key>`, password token with JWT `aud` matching the **MQTT server hostname**; optional **Packet logger** forwards RX packet summaries to the broker when enabled; see [docs/letsmesh-mqtt-auth.md](docs/letsmesh-mqtt-auth.md)). **MeshMapper** uses the same transport/topic shape (`meshcore/test`). **Colorado Mesh** is regional (Colorado residents only; WebSocket on port 443, topic prefix `meshcore/DEN`; confirm dialog on select; existing Colorado users get a one-time stay-or-switch prompt). IATA-scoped brokers require topic `meshcore/{IATA}` or `meshcore/test`. **Ripple Networks** (TLS on port 8883, topic prefix `meshcore`, shared credentials, insecure TLS confirm) and **Custom** remain available for other brokers. +- **MQTT (JSON v1):** The Connection tab MQTT card includes a **Network Preset** picker (order: **LetsMesh**, **MeshMapper**, **Colorado Mesh**, **Waev**, **Meshat.se**, **MeshCore.CA**, **EastMesh**, **Ripple Networks**, **Custom**). New installs default to **LetsMesh** (WebSocket on port 443, topic prefix `meshcore/test`; broker auth uses `@michaelhart/meshcore-decoder`'s `createAuthToken`; MQTT username `v1_<64-hex public key>`, password token with JWT `aud` matching the **MQTT server hostname**; optional **Packet logger** forwards RX packet summaries to the broker when enabled; see [docs/letsmesh-mqtt-auth.md](docs/letsmesh-mqtt-auth.md)). **LetsMesh**, **MeshMapper**, **Waev**, **Meshat.se**, **MeshCore.CA**, and **EastMesh** share that device-signing JWT flow (WebSocket path `/ws` for LetsMesh/MeshMapper, `/mqtt` for Waev/Meshat.se/MeshCore.CA/EastMesh; **MeshCore.CA** adds a Primary/Backup broker toggle). **Colorado Mesh** is regional (Colorado residents only; WebSocket on port 443, topic prefix `meshcore/DEN`; confirm dialog on select; existing Colorado users get a one-time stay-or-switch prompt). IATA-scoped brokers require topic `meshcore/{IATA}` or `meshcore/test`. **Ripple Networks** (TLS on port 8883, topic prefix `meshcore`, shared credentials, insecure TLS confirm) and **Custom** remain available for other brokers. --- @@ -506,7 +506,7 @@ When both Meshtastic and MeshCore have different saved BLE peripherals, dual-rad ### MQTT -Enter your broker URL, topic, and optional credentials in the MQTT section of the Connection tab. When connected, the section collapses to a compact info card showing the server, client ID, and topic. You can send messages via MQTT without a radio when using **Meshtastic**, or **MeshCore** with brokers other than the public **LetsMesh** presets (Ripple / Custom still use the JSON v1 chat envelope for MQTT-only sends). **LetsMesh** public MQTT targets the **Analyzer** packet-logger model: optional RX summaries to `{topicPrefix}/meshcore/packets` when your radio is connected ([docs/letsmesh-mqtt-auth.md](docs/letsmesh-mqtt-auth.md)); MQTT-only channel chat to LetsMesh without a radio is not supported. **Meshtastic** uses the protobuf MQTT stack; **MeshCore** broker details are in [docs/meshcore-meshtastic-parity.md](docs/meshcore-meshtastic-parity.md). In **MeshCore** mode, **LetsMesh** / **Ripple Networks** / **Colorado Mesh** presets fill those fields for the corresponding public networks. **LetsMesh** uses the same contract as [meshcore-mqtt-broker](https://github.com/michaelhart/meshcore-mqtt-broker) with JWT `aud` matching the **regional broker hostname** you connect to (e.g. `mqtt-us-v1.letsmesh.net` / `mqtt-eu-v1.letsmesh.net`); mesh-client generates tokens from your imported MeshCore identity (`public_key` + `private_key` in config JSON). **Colorado Mesh** uses JWT auth with custom audience mapping for `meshcore_mqtt.coloradomesh.org`. Use **Custom** and paste credentials manually if your operator issued different rules. +Enter your broker URL, topic, and optional credentials in the MQTT section of the Connection tab. When connected, the section collapses to a compact info card showing the server, client ID, and topic. You can send messages via MQTT without a radio when using **Meshtastic**, or **MeshCore** with brokers other than the public **LetsMesh** presets (Ripple / Custom still use the JSON v1 chat envelope for MQTT-only sends). **LetsMesh** public MQTT targets the **Analyzer** packet-logger model: optional RX summaries to `{topicPrefix}/meshcore/packets` when your radio is connected ([docs/letsmesh-mqtt-auth.md](docs/letsmesh-mqtt-auth.md)); MQTT-only channel chat to LetsMesh without a radio is not supported. **Meshtastic** uses the protobuf MQTT stack; **MeshCore** broker details are in [docs/meshcore-meshtastic-parity.md](docs/meshcore-meshtastic-parity.md). In **MeshCore** mode, the **LetsMesh** / **MeshMapper** / **Colorado Mesh** / **Waev** / **Meshat.se** / **MeshCore.CA** / **EastMesh** / **Ripple Networks** presets fill those fields for the corresponding public networks. The device-signing presets (everything except Ripple / Custom) use the same contract as [meshcore-mqtt-broker](https://github.com/michaelhart/meshcore-mqtt-broker) with JWT `aud` matching the **broker hostname** you connect to (e.g. `mqtt-us-v1.letsmesh.net`, `mqtt.waev.app`, `mqtt1.meshcore.ca`); mesh-client generates tokens from your imported MeshCore identity (`public_key` + `private_key` in config JSON). **Custom** settings also use device signing when the configured server matches a known device-signing broker hostname (e.g. `mqtt.waev.app`); Custom is only non-device-signing for unmatched hosts. Use **Custom** and paste credentials manually if your operator issued different rules. --- diff --git a/docs/key-backup-and-crypto.md b/docs/key-backup-and-crypto.md index 5aa4f0557..f7eb367ac 100644 --- a/docs/key-backup-and-crypto.md +++ b/docs/key-backup-and-crypto.md @@ -76,7 +76,7 @@ Implementation: [`meshtasticDmKeyBackupStorage.ts`](../src/renderer/lib/meshtast **Index privacy:** Same as Meshtastic — encrypted per-node archives hold private keys; the index JSON stores plaintext `nodeLabel`, `backedUpAt`, and full `publicKeyB64` (UI shows an 8-character hex prefix only). -The **active MQTT cache** is separate from per-node archives: LetsMesh JWT signing uses whichever identity was last connected or restored. Per-node archives retain full pairs without overwriting each other. +The **active MQTT cache** is separate from per-node archives: device-signing MeshCore MQTT (JWT) signing — LetsMesh, MeshMapper, Colorado Mesh, Waev, Meshat.se, MeshCore.CA, EastMesh — uses whichever identity was last connected or restored. Per-node archives retain full pairs without overwriting each other. **How keys get into the active cache (not a per-node backup)** @@ -88,12 +88,12 @@ The **active MQTT cache** is separate from per-node archives: LetsMesh JWT signi 1. Decrypt and validate both keys. 2. `importPrivateKey(privateKeyBytes)` on the connected radio. -3. Sync active MQTT cache with full pair; identity-updated event refreshes Connection tab LetsMesh username. +3. Sync active MQTT cache with full pair; identity-updated event refreshes the Connection tab MeshCore MQTT username. 4. Best-effort verification after reconnect delay. Implementation: [`meshcoreKeyBackupStorage.ts`](../src/renderer/lib/meshcoreKeyBackupStorage.ts). -Details: [LetsMesh MQTT authentication](letsmesh-mqtt-auth.md). +Details: [MeshCore MQTT authentication](letsmesh-mqtt-auth.md). --- diff --git a/docs/letsmesh-mqtt-auth.md b/docs/letsmesh-mqtt-auth.md index 057c10ff6..896247236 100644 --- a/docs/letsmesh-mqtt-auth.md +++ b/docs/letsmesh-mqtt-auth.md @@ -1,21 +1,30 @@ # MeshCore MQTT authentication -This document describes the authentication contract used by many MeshCore MQTT brokers including **Colorado Mesh** and **LetsMesh**. mesh-client uses the same contract as [meshcore-mqtt-broker](https://github.com/michaelhart/meshcore-mqtt-broker): MQTT username `v1_<64-hex public key>` (uppercase) and a password produced by `@michaelhart/meshcore-decoder` `createAuthToken`. +This document describes the **device-signing** authentication contract shared by the public MeshCore MQTT presets: **LetsMesh** (US/EU), **MeshMapper**, **Colorado Mesh**, **Waev**, **Meshat.se**, **MeshCore.CA** (Primary/Backup), and **EastMesh**. mesh-client uses the same contract as [meshcore-mqtt-broker](https://github.com/michaelhart/meshcore-mqtt-broker): MQTT username `v1_<64-hex public key>` (uppercase) and a password produced by `@michaelhart/meshcore-decoder` `createAuthToken`. The broker allowlist and each broker's WebSocket path live in [`letsMeshJwt.ts`](../src/renderer/lib/letsMeshJwt.ts) (`DEVICE_SIGNING_HOST_WS_PATHS`). ## JWT audience (`aud`) The broker validates that the token’s `aud` claim matches its configured `AUTH_EXPECTED_AUDIENCE` when that value is set. -For **LetsMesh public presets** (`mqtt-us-v1.letsmesh.net`, `mqtt-eu-v1.letsmesh.net`), mesh-client sets: +For **every device-signing preset**, mesh-client sets the JWT `aud` to the **same hostname as the MQTT connect server** (not a separate apex domain), via `letsMeshJwtAudience()`. Examples: -- **MQTT connect host/port**: the regional hostname and `443` (WebSocket TLS). -- **JWT `aud`**: the **same** regional hostname as the MQTT server (not a separate apex domain). +- **MQTT connect host/port**: the broker hostname (e.g. `mqtt-us-v1.letsmesh.net`, `mqtt.waev.app`, `mqtt1.meshcore.ca`) and `443` (WebSocket TLS). +- **JWT `aud`**: that exact connect hostname. That aligns with common tooling such as [meshcoretomqtt](https://github.com/Cisien/meshcoretomqtt) (token `audience` matches the broker host). If your operator documents a different `aud`, use **Custom** MQTT and paste a manually generated token. +## WebSocket path (`wsPath`) + +Device-signing brokers use one of two WebSocket paths, enforced on connect ([`letsMeshConnectionGuards.ts`](../src/renderer/lib/letsMeshConnectionGuards.ts)): + +- **`/ws`** — LetsMesh (US/EU), MeshMapper, Colorado Mesh. +- **`/mqtt`** — Waev, Meshat.se, MeshCore.CA, EastMesh. + +Selecting a preset (or a broker toggle) sets the correct path; a mismatched `wsPath` blocks Connect with a clear error and raises the amber deviation banner. + ## WebSocket idle / keepalive -MeshCore MQTT over WSS uses **60s MQTT keepalive** (same order of magnitude as raw TCP). mqtt.js’s internal deadline is about **1.5× the keepalive**. The client sends **WebSocket `ping` frames** for proxy/LB idle paths, and periodically calls mqtt.js **`reschedulePing(true)`** so the internal keepalive timer resets when **PINGRESP** / **SUBACK** are not observed in time on the WebSocket path. +Device-signing presets default to a **30s MQTT keepalive**; the **LetsMesh Region (US/EU)** buttons raise it to **60s**. Both values are treated as valid (no deviation banner). mqtt.js’s internal deadline is about **1.5× the keepalive**. The client sends **WebSocket `ping` frames** for proxy/LB idle paths, and periodically calls mqtt.js **`reschedulePing(true)`** so the internal keepalive timer resets when **PINGRESP** / **SUBACK** are not observed in time on the WebSocket path. ## Debugging connection vs auth diff --git a/docs/meshcore-meshtastic-parity.md b/docs/meshcore-meshtastic-parity.md index e2ffa84e9..45c6a6a85 100644 --- a/docs/meshcore-meshtastic-parity.md +++ b/docs/meshcore-meshtastic-parity.md @@ -17,7 +17,7 @@ Shared UI gates use `ProtocolCapabilities` in [`src/renderer/lib/radio/BaseRadio | Transports | BLE, Serial, HTTP (`@meshtastic/core`), WiFi/TCP fast path (`TransportTcpIpc`, port 4403); launch auto-connect via `useProtocolRfAutoConnect` / `ProtocolAutoConnectCoordinator` + `protocolRfAutoConnectGate` | BLE, Web Serial, TCP bridge (5000; main `meshcore:tcp-*`, tolerates peer FIN after contact burst, `TCP_NODELAY` + keepalive); same RF auto-connect coordinator; TCP reconnect owned by `useMeshcoreRuntime` + `rfReconnectController` (conn side effects skip `handleConnectionLost` for TCP) | Different stacks | | Tab “Modules” / “Repeaters” | `ModulePanel` (protobuf modules; Remote Hardware GPIO, IP Tunnel status) | `RepeatersPanel` (trace, status, neighbors) | Product split | | Tab “Administration” | `AdminPanel` (reboot, shutdown, factory reset, NodeDB reset, OTA/DFU) | `AdminPanel` (reboot via companion; meshcore.js limits for shutdown/factory/OTA) | **App** (implemented; reboot wired; extended admin capability-limited) | -| MQTT broker UI | Full (with transport selection) | Same broker fields; transport protocol selected when connecting; MeshCore-only **LetsMesh** / **MeshMapper** / **Colorado Mesh** / **Ripple** / **Custom** presets fill known public brokers | **Post-MQTT** codec on broker path | +| MQTT broker UI | Full (with transport selection) | Same broker fields; transport protocol selected when connecting; MeshCore-only **LetsMesh** / **MeshMapper** / **Colorado Mesh** / **Waev** / **Meshat.se** / **MeshCore.CA** / **EastMesh** / **Ripple** / **Custom** presets fill known public brokers | **Post-MQTT** codec on broker path | | MQTT wire format | `ServiceEnvelope` / `MeshPacket` ([`mqtt-manager.ts`](../src/main/mqtt-manager.ts)) | JSON **v1** chat on `{topicPrefix}/meshcore/chat` (non-LetsMesh / private brokers); **LetsMesh**: optional meshcoretomqtt-style **packet** JSON on `{topicPrefix}/meshcore/packets` ([`meshcore-mqtt-adapter.ts`](../src/main/meshcore-mqtt-adapter.ts)); chat parser in [`meshcoreMqttEnvelope.ts`](../src/shared/meshcoreMqttEnvelope.ts) | Adapter vs protobuf | | MQTT channel crypto / uplink | AES-128/256-CTR, `channelPsks`, TLS ([`mqttTls.ts`](../src/renderer/lib/mqttTls.ts)), per-channel publish ([`meshtasticMqttPublish.ts`](../src/renderer/lib/meshtasticMqttPublish.ts)); [`mqtt-manager.ts`](../src/main/mqtt-manager.ts) | JSON v1 path unchanged | **App** (Meshtastic wire) | | Node list hops / MQTT columns | `hops_away`, `via_mqtt` from device | Contact model; node-list `hops_away` derives from contact `outPathLen` (`meshcoreInferHopsFromOutPath`); per-message chat hop pills instead use the companion `pathLen` on RX events 7/8 (`meshcoreCompanionRxPathLenToHopCount`) — see [AGENTS.md](../AGENTS.md) Chat Panel §Hop badges | **App** (implemented) | @@ -171,13 +171,13 @@ Subscribes under `{topicPrefix}/#`. Outbound optional publish uses `mqtt:publish ## Meshtastic MQTT network presets -In **Meshtastic** mode, [`ConnectionPanel.tsx`](../src/renderer/components/ConnectionPanel.tsx) shows **MQTT :1883**, **Liam's**, and **Custom** preset buttons. They populate `MQTTSettings` used by [`mqtt-manager.ts`](../src/main/mqtt-manager.ts). +In **Meshtastic** mode, [`ConnectionPanel.tsx`](../src/renderer/components/ConnectionPanel.tsx) shows a **Network Preset** picker (shared [`MqttNetworkPresetSelect`](../src/renderer/components/MqttNetworkPresetSelect.tsx)) with **Official**, **Liam's**, and **Custom** options. They populate `MQTTSettings` used by [`mqtt-manager.ts`](../src/main/mqtt-manager.ts). -| Preset | Broker host | Port | Notes | -| ---------- | -------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------- | -| MQTT :1883 | `mqtt.meshtastic.org` | 1883 | Plaintext; may be blocked on some networks | -| Liam's | `mqtt.meshtastic.liamcottle.net` | 1883 | **Uplink-only** (puts your node on Liam Cottle's map; no downlink). No TLS. Useful when `mqtt.meshtastic.org` is unreachable | -| Custom | (user) | ; | No automatic changes; use for private brokers | +| Preset | Broker host | Port | Notes | +| -------- | -------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------- | +| Official | `mqtt.meshtastic.org` | 1883 | Plaintext; may be blocked on some networks | +| Liam's | `mqtt.meshtastic.liamcottle.net` | 1883 | **Uplink-only** (puts your node on Liam Cottle's map; no downlink). No TLS. Useful when `mqtt.meshtastic.org` is unreachable | +| Custom | (user) | — | No automatic changes; use for private brokers | Topic prefix defaults to `msh/US/`; users can edit fields after choosing a preset. Defined in [`meshtasticMqttTlsMigration.ts`](../src/renderer/lib/meshtasticMqttTlsMigration.ts). @@ -193,17 +193,21 @@ For ham or private MQTT brokers (typically **Custom** preset), the Connection ta ## MeshCore MQTT network presets -In **MeshCore** mode only, [`ConnectionPanel.tsx`](../src/renderer/components/ConnectionPanel.tsx) shows **Network Preset** buttons in this order: **LetsMesh**, **MeshMapper**, **Colorado Mesh**, **Ripple Networks**, **Custom**. New installs default to **LetsMesh**. They populate the same `MQTTSettings` the main process uses for [`meshcore-mqtt-adapter.ts`](../src/main/meshcore-mqtt-adapter.ts) (with `mqttTransportProtocol: 'meshcore'`). Preset fields live in [`meshcoreMqttPresets.ts`](../src/renderer/lib/meshcoreMqttPresets.ts). +In **MeshCore** mode only, [`ConnectionPanel.tsx`](../src/renderer/components/ConnectionPanel.tsx) shows a **Network Preset** picker (shared [`MqttNetworkPresetSelect`](../src/renderer/components/MqttNetworkPresetSelect.tsx)) with, in order: **LetsMesh**, **MeshMapper**, **Colorado Mesh**, **Waev**, **Meshat.se**, **MeshCore.CA**, **EastMesh**, **Ripple Networks**, **Custom**. New installs default to **LetsMesh**. They populate the same `MQTTSettings` the main process uses for [`meshcore-mqtt-adapter.ts`](../src/main/meshcore-mqtt-adapter.ts) (with `mqttTransportProtocol: 'meshcore'`). Preset fields live in [`meshcoreMqttPresets.ts`](../src/renderer/lib/meshcoreMqttPresets.ts). -| Preset | Broker host | Port | Notes | -| --------------- | ------------------------------------------------------ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| LetsMesh | `mqtt-us-v1.letsmesh.net` or `mqtt-eu-v1.letsmesh.net` | 443 | **Default for new users.** **WebSocket** (`wss`). Topic prefix `meshcore/test`. JWT auth; see [Authentication](#meshcore-mqtt-authentication) below. Optional **Packet logger** publishes to `meshcore/packets`. See [`letsmesh-mqtt-auth.md`](letsmesh-mqtt-auth.md). | -| MeshMapper | `mqtt.meshmapper.net` | 443 | **WebSocket** (`wss`). Topic prefix `meshcore/test`. | -| Colorado Mesh | `mqtt.meshcore.coloradomesh.org` | 443 | **Colorado residents only.** **WebSocket** (`wss`). Topic prefix `meshcore/DEN`. Confirm on select; one-time stay-or-switch gate for existing Colorado users. JWT device-signing auth. | -| Ripple Networks | `mqtt.ripplenetworks.com.au` | 8883 | TLS; preset fills default shared credentials and **insecure TLS** for self-signed / non–public CA chains. Topic prefix `meshcore`. | -| Custom | (user) | — | No automatic changes; use for private brokers | +| Preset | Broker host | Port | Notes | +| --------------- | ------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| LetsMesh | `mqtt-us-v1.letsmesh.net` or `mqtt-eu-v1.letsmesh.net` | 443 | **Default for new users.** **WebSocket** (`wss`, path `/ws`). Topic prefix `meshcore/test`. JWT auth; see [Authentication](#meshcore-mqtt-authentication) below. Optional **Packet logger** publishes to `meshcore/packets`. See [`letsmesh-mqtt-auth.md`](letsmesh-mqtt-auth.md). | +| MeshMapper | `mqtt.meshmapper.net` | 443 | **WebSocket** (`wss`, path `/ws`). Topic prefix `meshcore/test`. | +| Colorado Mesh | `mqtt.meshcore.coloradomesh.org` | 443 | **Colorado residents only.** **WebSocket** (`wss`, path `/ws`). Topic prefix `meshcore/DEN`. Confirm on select; one-time stay-or-switch gate for existing Colorado users. JWT device-signing auth. | +| Waev | `mqtt.waev.app` | 443 | **WebSocket** (`wss`, path `/mqtt`). Topic prefix `meshcore/test`. JWT device-signing auth (broker enforces a short auth-token lifetime). | +| Meshat.se | `meshcore-mqtt.meshat.se` | 443 | **WebSocket** (`wss`, path `/mqtt`). Topic prefix `meshcore/test`. JWT device-signing auth. | +| MeshCore.CA | `mqtt1.meshcore.ca` (Primary) or `mqtt2.meshcore.ca` (Backup) | 443 | **WebSocket** (`wss`, path `/mqtt`). Topic prefix `meshcore/test`. JWT device-signing auth. **Primary/Backup** broker toggle appears under the picker. | +| EastMesh | `mqtt2.eastmesh.au` | 443 | **WebSocket** (`wss`, path `/mqtt`). Topic prefix `meshcore/test`. JWT device-signing auth. | +| Ripple Networks | `mqtt.ripplenetworks.com.au` | 8883 | TLS; preset fills default shared credentials and **insecure TLS** for self-signed / non–public CA chains. Topic prefix `meshcore`. | +| Custom | (user) | — | No automatic changes; use for private brokers | -Topic prefixes for LetsMesh / MeshMapper / Colorado (and Custom when pointed at those hosts) must be `meshcore/{IATA}` (3 letters) or `meshcore/test` — validated in [`meshcoreMqttTopicPrefix.ts`](../src/renderer/lib/meshcoreMqttTopicPrefix.ts). Users can still edit fields after choosing a preset. On upgrade, [`connectionPanelStorageMigrations.ts`](../src/renderer/lib/connectionPanelStorageMigrations.ts) migrates stale Colorado Mesh port **1883** → **443**, bare topic `meshcore` → IATA, repairs invalid IATA shapes, seeds LetsMesh for new installs, and reconciles preset-owned fields before MQTT auto-launch (also runs from [`main.tsx`](../src/renderer/main.tsx) before React mount). +Topic prefixes for the device-signing presets (LetsMesh / MeshMapper / Colorado / Waev / Meshat.se / MeshCore.CA / EastMesh — and Custom when pointed at those hosts) must be `meshcore/{IATA}` (3 letters) or `meshcore/test` — validated in [`meshcoreMqttTopicPrefix.ts`](../src/renderer/lib/meshcoreMqttTopicPrefix.ts). Users can still edit fields after choosing a preset. On upgrade, [`connectionPanelStorageMigrations.ts`](../src/renderer/lib/connectionPanelStorageMigrations.ts) migrates stale Colorado Mesh port **1883** → **443**, bare topic `meshcore` → IATA, repairs invalid IATA shapes, seeds LetsMesh for new installs, and reconciles preset-owned fields before MQTT auto-launch (also runs from [`main.tsx`](../src/renderer/main.tsx) before React mount). ## Log filtering @@ -215,21 +219,23 @@ When MeshCore firmware/SDK defines official MQTT topics and payloads, replace or ## MeshCore MQTT Authentication -### LetsMesh JWT authentication +### Device-signing JWT authentication -LetsMesh uses WebSocket (`wss`) with JWT authentication. The implementation matches [meshcore-mqtt-broker](https://github.com/michaelhart/meshcore-mqtt-broker): +The **LetsMesh**, **MeshMapper**, **Colorado Mesh**, **Waev**, **Meshat.se**, **MeshCore.CA**, and **EastMesh** presets all use WebSocket (`wss`) with the same device-signed JWT authentication (Custom settings pointed at those hosts use it too). The implementation matches [meshcore-mqtt-broker](https://github.com/michaelhart/meshcore-mqtt-broker): - **MQTT username**: `v1_<64-hex public key>` (uppercase hex) - **MQTT password**: A token from `@michaelhart/meshcore-decoder` `createAuthToken` with: - `publicKey`: 64-character hex public key - `iat`: Issued-at timestamp - `exp`: Expiration timestamp - - JWT `aud` (audience): The **regional broker hostname** (same as the Server field) + - JWT `aud` (audience): The **broker hostname you connect to** (same as the Server field), via `letsMeshJwtAudience()` -The JWT audience must match the regional broker hostname (`mqtt-us-v1.letsmesh.net` or `mqtt-eu-v1.letsmesh.net`). +The JWT audience always matches the connect hostname — e.g. `mqtt-us-v1.letsmesh.net`, `mqtt.waev.app`, `mqtt1.meshcore.ca`. The broker allowlist and each broker's WebSocket path (`/ws` for LetsMesh/MeshMapper/Colorado, `/mqtt` for Waev/Meshat.se/MeshCore.CA/EastMesh) live together in [`letsMeshJwt.ts`](../src/renderer/lib/letsMeshJwt.ts) (`DEVICE_SIGNING_HOST_WS_PATHS`); connect/deviation guards enforce port 443, TLS, and the expected `wsPath` in [`letsMeshConnectionGuards.ts`](../src/renderer/lib/letsMeshConnectionGuards.ts). Signing uses cached **private key** material from either a **Radio**-tab MeshCore JSON import or **automatic persistence** after a successful MeshCore radio session (same storage shape as import). +On upgrade, the legacy MeshMapper host `mqtt.meshmapper.cc` is rewritten to `mqtt.meshmapper.net` (TLS on `.cc` fails with alert 80) by [`connectionPanelStorageMigrations.ts`](../src/renderer/lib/connectionPanelStorageMigrations.ts). + ### Configuration Import a MeshCore config JSON file (Radio tab) when you need credentials before connecting a radio, or to replace missing data; otherwise connecting the MeshCore radio first fills the same cache. The implementation is in [`letsMeshJwt.ts`](../src/renderer/lib/letsMeshJwt.ts). diff --git a/docs/reticulum-games-parity.md b/docs/reticulum-games-parity.md index 646d0cd17..86184904b 100644 --- a/docs/reticulum-games-parity.md +++ b/docs/reticulum-games-parity.md @@ -7,7 +7,7 @@ Living matrix for [issue #773](https://github.com/Colorado-Mesh/mesh-client/issu Update this file when Games PRs land. `pnpm run update` warns on new Ratspeak releases with stub-kind `games-parity`. -**Last review:** 2026-08-06 (delivery_state / envelope persist / optimistic board / Chess promotion+claims). +**Last review:** 2026-08-08 (Ratspeak v1.0.25 — no Games protocol/API delta, CSS-only; added win confetti; LXMF `FIELD_REACTION` 0x40 interop for Chat tapbacks). Prior: 2026-08-06 (delivery_state / envelope persist / optimistic board / Chess promotion+claims). Status: `done` | `partial` | `wontfix` | `todo` @@ -26,21 +26,22 @@ Status: `done` | `partial` | `wontfix` | `todo` ## UI -| Ratspeak UI | mesh-client | Status | Notes | -| ----------------------------------- | --------------------------------------- | ------- | --------------------------------------------------------- | -| Games tab | Left-rail Games (`Gamepad2`) | done | Reticulum-only via `hasLrgpGames` | -| Session list filters | GamesPanel filters | done | | -| Unread badge | session unread + Games tab badge | done | sidebar red pill via `gamesUnread` | -| TTT board | `TicTacToeBoard` | done | | -| Chess board | `ChessBoard` | done | | -| Challenge from contacts | Peers / Chat DM Challenge | done | | -| Draw / resign | session actions | done | | -| Delivery state / resend | session `delivery_state` + Resend | done | LXMF outbound bridge; chips; Resend on `failed` | -| Notification route `lrgp:` | `lrgp:` + `lxm://game/` → Games tab | done | `MeshClientDeepLinkHost` + `openReticulumGameSession` | -| Optimistic rollback UI | client backup + restore | done | TTT + Chess optimistic paint; WS/`action_result` rollback | -| Chess promotion picker | `ChessBoard` chooser | done | q/r/b/n filtered by `legal_moves` | -| Threefold / 50-move claims | Claim buttons → `draw_offer` `{ r }` | done | `3fr` / `50m` when `draw_offer_reason` set | -| Win celebration | — | wontfix | optional polish; not required for interop | +| Ratspeak UI | mesh-client | Status | Notes | +| ----------------------------------- | --------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------- | +| Games tab | Left-rail Games (`Gamepad2`) | done | Reticulum-only via `hasLrgpGames` | +| Session list filters | GamesPanel filters | done | | +| Unread badge | session unread + Games tab badge | done | sidebar red pill via `gamesUnread` | +| TTT board | `TicTacToeBoard` | done | | +| Chess board | `ChessBoard` | done | | +| Challenge from contacts | Peers / Chat DM Challenge | done | | +| Draw / resign | session actions | done | | +| Delivery state / resend | session `delivery_state` + Resend | done | LXMF outbound bridge; chips; Resend on `failed` | +| Notification route `lrgp:` | `lrgp:` + `lxm://game/` → Games tab | done | `MeshClientDeepLinkHost` + `openReticulumGameSession` | +| Optimistic rollback UI | client backup + restore | done | TTT + Chess optimistic paint; WS/`action_result` rollback | +| Chess promotion picker | `ChessBoard` chooser | done | q/r/b/n filtered by `legal_moves` | +| Threefold / 50-move claims | Claim buttons → `draw_offer` `{ r }` | done | `3fr` / `50m` when `draw_offer_reason` set | +| Win celebration | `burstConfetti` on local win | done | `confettiBurst.ts` canvas burst; once per `session_id`; reduce-motion aware | +| Four in a Row | — | todo | Added to lrgp-rs + Ratspeak `main` after v1.0.25; sidecar already registers `four_in_a_row`; UI board missing | ## Wire interop diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index 72d780ff6..75ca35848 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -94,7 +94,7 @@ Routing bias between **RF** (LoRa / RNode) and **network** (TCP/UDP/I2P/gateway/ | POST | `/api/v1/lxmf/send` | `{ destination_hash, text, reply_to_hash?, reply_to_id?, reply_preview_text? }` | Live: stamps LXMF `FIELD_REPLY_TO` (0x30) / optional `FIELD_REPLY_QUOTE` (0x31) before sign; `{ ok, delivery_method?, delivery_status?, sent_via?, message? }` or `{ ok: false, error: "no_propagation_node" }`. **`delivery_status` on this response is initial enqueue state only** (`queued` or `sending`) — not delivery confirmation. Before live attach (`rns-stack` without bridge): fail-closed `{ ok: false, error: "lxmf send requires live rns-stack sidecar" }`. Stub build: `{ ok, sent_via?, message? }` | | POST | `/api/v1/lxmf/paper/create` | `{ destination_hash, text, reply_to_hash?, reply_to_id?, reply_preview_text? }` (Chat UI currently sends `destination_hash` + `text` only) | Live: signed `DeliveryMethod::Paper` + `to_paper_uri` (encrypt to peer pubkey in `known_identities`); **no** network send. `{ ok, uri, message_hash, delivery_method: "paper", message? }` or `{ ok: false, error: "identity_unknown" \| "paper_too_large" \| "identity_not_configured" \| "invalid_hash" \| "internal_error" }`. Emits WS `lxmf_message` outbound with `delivery_method`/`sent_via`/`received_via`=`paper` and `delivery_status: "delivered"` — **does not** emit `lxmf_outbound_status`. Stub: `{ ok: false, error: "identity_not_configured" }` | | POST | `/api/v1/lxmf/paper/ingest` | `{ uri }` (`lxm://` base64url paper blob; URI length capped before decrypt) | Live: `from_paper_uri` + local identity decrypt + `ingest_lxm_uri` → delivery callback / WS `lxmf_message` inbound (`delivery_method`/`received_via`=`paper`). `{ ok, message? }` or `{ ok: false, error: "invalid_uri" \| "decrypt_failed" \| "identity_not_configured" \| "paper_too_large" \| "internal_error" }`. Stub: `{ ok: false, error: "identity_not_configured" }` | -| POST | `/api/v1/lxmf/reaction` | `{ destination_hash, target_hash, emoji }` | Live: `{ ok, message? }`. Before live attach: fail-closed `{ ok: false, error: "lxmf reaction requires live rns-stack sidecar" }` | +| POST | `/api/v1/lxmf/reaction` | `{ destination_hash, target_hash, emoji }` | Live: stamps standard LXMF `FIELD_REACTION` (0x40) msgpack map (`REACTION_TO` 0x00 = 32-byte parent hash, `REACTION_CONTENT` 0x01 = emoji) before sign and keeps the emoji as message content; an unparsable `target_hash` fails open (sends content-only, no `0x40`). `{ ok, message? }`. Before live attach: fail-closed `{ ok: false, error: "lxmf reaction requires live rns-stack sidecar" }` | | GET | `/api/v1/lxmf/recent` | `?since_ts=` (ms, optional), `?since_seq=` (opaque `ring_seq`, optional), `?limit=` (default 200, max 500) | `{ messages: [], ring_len }` — ring buffer of recent **inbound** LXMF payloads for WS lag/reconnect catch-up (not durable across sidecar restart; capped at 200). Rows are chronological (oldest→newest) and each accepted row is stamped with monotonic `ring_seq`. Cursor: `since_ts` alone keeps `timestamp > since_ts`; with `since_seq`, keep rows after the complete `(since_ts, since_seq)` cursor (`timestamp > since_ts` **or** same-ms with `ring_seq > since_seq`) so same-ms twins remain recoverable without reprocessing the boundary; `ring_len` is current buffer occupancy | | DELETE | `/api/v1/lxmf/messages/{hash}` | | `{ ok }` | | GET | `/api/v1/contacts` | | `{ contacts: [] }` — overlays announce/peer/Nomad labels onto nameless or hash-prefix contact `display_name` values (does not overwrite a real name) and may persist fills | @@ -272,7 +272,7 @@ Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscri - **`announce.received`:** coalesced WS notify for LXMF identity announces / path responses (named or nameless). Sidecar applies identity-key + display-name cache updates immediately, but emits **at most one** WS frame per coalesce window (500ms normal / 1000ms when >256 distinct destinations are pending) so announce storms stay O(1) bus pressure on large meshes (~100k). Payload is either a single `{ destination_hash, display_name?, hops, aspect?, identity_hash? }` (legacy / one-row flush) or `{ announces: [{ destination_hash, display_name?, hops, aspect?, identity_hash? }, ...] }` (capped at 1024, named preferred; overflow dropped — slow peer poll recovers). `aspect` is set when announce `name_hash` maps to a known app name (`lxmf.delivery`, `lxmf.propagation`, `nomadnetwork.node`, `rrc.hub`, `lxst.telephony`); omitted for path responses / unknown hashes (clients must not invent `"unknown"`). `identity_hash` is the hex identity recovered from the validated announce when present. Each flush publishes pressure counters under `GET /api/v1/diagnostics` → `announce_ws` (ingress/unique/overflow + storm/flush timestamps) for the Diagnostics `reticulum/announce-bus-pressure` warning. Display names update the peer-label cache only — announces do **not** auto-create LXMF contacts. That cache is overlayed onto `GET /api/v1/peers` / topology rows **and** onto nameless/hash-prefix rows from `GET /api/v1/contacts` (`list_contacts` may persist those fills) so path-table and contact refreshes keep announce aliases. - **`peers_updated`:** also emitted when the live path table **gains** new destination hashes (maintenance tick). Payload may include `{ added: string[], patches: PeerRow[], count }` (added/patches capped at 1024). Renderer applies patches incrementally, including route-field changes. A full peer dump is used on connect, manual Refresh, restart, safety poll, or a `peers_updated` payload that cannot be applied incrementally: `cleared`, `demoted_from_contacts`, or a single-`hash` probe/path event. Hop/timestamp-only churn does not emit. -`lxmf_message` payload fields include `sender_hash`, `text`, `timestamp`, `message_hash`, optional `direction` (`inbound` / `outbound`), optional `delivery_status` (`sending` on optimistic outbound rows; `delivered` on paper Completes), optional `reply_to_hash` / `reply_preview_text` (from LXMF `FIELD_REPLY_TO` / `FIELD_REPLY_QUOTE`), and transport markers `received_via` / `sent_via`. Outbound `sent_via` is **path-table / PacketTap evidence**, not “any local RNode enabled”: atomic values are `rf`, `ble`, `tcp`, `network`, or **`paper`** (offline QR handoff); multi-egress observes join with `+` (e.g. `rf+tcp`, `ble+network`). Inbound `received_via` uses the path-table interface name **matched to local interface config** (same atoms — so a TCP hub named “RNS Testnet” is `tcp`, not `network`) or `paper` for decrypted paper URIs. Never use Meshtastic-style `both` for Reticulum network egress (legacy `both` may still appear in SQLite allowlists). +`lxmf_message` payload fields include `sender_hash`, `text`, `timestamp`, `message_hash`, optional `direction` (`inbound` / `outbound`), optional `delivery_status` (`sending` on optimistic outbound rows; `delivered` on paper Completes), optional `reply_to_hash` / `reply_preview_text` (from LXMF `FIELD_REPLY_TO` / `FIELD_REPLY_QUOTE`), optional `reaction_target` (from LXMF `FIELD_REACTION` 0x40, present as lowercase 64-hex when decoded — a reaction takes precedence over a reply, so `reply_to_hash` is omitted when `reaction_target` is set, and the field emoji fills `text` only when the message content is empty), and transport markers `received_via` / `sent_via`. When `FIELD_REACTION` is absent or malformed the payload is unchanged (no `reaction_target`; reply/plain paths intact). Outbound `sent_via` is **path-table / PacketTap evidence**, not “any local RNode enabled”: atomic values are `rf`, `ble`, `tcp`, `network`, or **`paper`** (offline QR handoff); multi-egress observes join with `+` (e.g. `rf+tcp`, `ble+network`). Inbound `received_via` uses the path-table interface name **matched to local interface config** (same atoms — so a TCP hub named “RNS Testnet” is `tcp`, not `network`) or `paper` for decrypted paper URIs. Never use Meshtastic-style `both` for Reticulum network egress (legacy `both` may still appear in SQLite allowlists). `lxmf_outbound_status` payload: `message_hash`, `status` (`delivered` / `stored_locally` / `failed` / `sending`), optional `delivery_method`, optional `sent_via` (egress evidence upgrade before Completes). diff --git a/docs/reticulum.md b/docs/reticulum.md index d0c29767d..4d954e963 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -283,6 +283,7 @@ When multiple enabled local RNode interfaces are connected, the interface list s - **DM-only** on the Chat tab — no RF channel pills (RRC covers hub rooms separately) - Text and emoji reactions. **Outbound LXMF file/voice attach is not offered** (removed); historic `[file:name:mime]` bubbles and inbound Sideband-style attachments render a read-only label; when the file remains in `reticulum/attachments/`, **raster images** (JPEG/PNG/GIF/WebP/AVIF/BMP — not SVG) display inline via main-process `chat:readReticulumAttachmentAsDataUrl` (magic-byte MIME check, 2 MiB cap, path jailed, IPC rate-limited). Peer file transfer is via Remote rncp. - **Replies:** outbound DMs stamp LXMF `FIELD_REPLY_TO` (0x30) and optional `FIELD_REPLY_QUOTE` (0x31, capped) before sign so peers see structured replies; ingest/Chat use `reticulum_reply_to_hash` plus quote preview (store parent when present, else wire quote) and jump-to-parent by message hash +- **Reactions:** outbound tapbacks stamp standard LXMF `FIELD_REACTION` (0x40) before sign — a msgpack map (`REACTION_TO` = 32-byte parent hash, `REACTION_CONTENT` = emoji) — while still carrying the emoji as message content, so Ratspeak/Sideband render a structured reaction and clients that ignore `0x40` still show the emoji. Inbound decode is fail-open: a valid `0x40` maps to `reaction_target` (accepting a 32-byte binary or 64-hex `REACTION_TO`, normalized to lowercase) and wins over a reply for classification; a missing or malformed field leaves the reply/plain-text path unchanged. Ratspeak legacy `0xFB`/`0xFC` reaction envelopes are not emitted or decoded. - Outbound **Sending** until sidecar emits `lxmf_outbound_status` (`delivered` / `stored_locally` / `failed`); `/api/v1/lxmf/send` may return `delivery_status: "queued"` or `"sending"` — that is enqueue/acceptance, not delivery confirmation. After Direct exhausts, the sidecar **cascades** preferred remote → other enabled remotes (hop-sorted) → in **Auto** only, up to 3 heard-but-not-added **Discovered** PNs (hop-sorted) → local-prop last, re-emitting `sending` with `delivery_method: "propagated"` (remote) or `"stored_locally"` (local inbox) between attempts. **Exception — paper:** Chat DM **Share as paper** (`createReticulumPaperMessage` → `POST /api/v1/lxmf/paper/create`) encrypts offline to a QR/`lxm://` URI with **no network send**; Completes immediately (`delivery_method: paper`, badge **Paper**) and does **not** use `lxmf_outbound_status`. Ingest via Chat **Scan paper**, Network **Scan / import**, or OS `lxm://` (`POST /api/v1/lxmf/paper/ingest` — HTTP `message` fallback-ingested when WS lags). Create needs peer pubkey (`identity_unknown` otherwise); ingest needs matching local identity (`decrypt_failed` otherwise); size-capped (`paper_too_large`). - Terminal **Completes** / **Failed** from `lxmf_outbound_status` are persisted to SQLite (`delivery_status` + `delivery_method` on `reticulum_messages`) via `applyReticulumOutboundDeliveryStatus.ts` so restart/DB hydration keeps PN vs Direct vs local-inbox labeling; early WS events before provisional id→hash rekey are buffered - **Optimistic pending rekey:** Chat send creates a `reticulum-pending-*` row; when the sidecar returns the real `message_hash`, ingest/SQLite upsert passes `replaces_message_hash` so the pending row is deleted atomically (avoids orphan Sending duplicates) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 35f6b7333..e286de31c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -685,14 +685,14 @@ After **24 hours** of uptime, the main process logs periodic **long-session heal ### MQTT keeps disconnecting -**Cause**: Wireless interference, broker downtime, token issues (LetsMesh/Colorado Mesh), or normal reconnect backoff after a failed attempt. +**Cause**: Wireless interference, broker downtime, token issues (device-signing brokers: LetsMesh / MeshMapper / Colorado Mesh / Waev / Meshat.se / MeshCore.CA / EastMesh), or normal reconnect backoff after a failed attempt. **Fix**: - Check your WiFi/signal strength - Verify the broker is online - Expect **exponential reconnect backoff** (60s base, capped at 45 minutes per `src/shared/mqttReconnectSchedule.ts`); connack timeouts retry faster (~250ms) -- For LetsMesh/Colorado Mesh: mesh-client refreshes JWT automatically when MeshCore identity is already cached (including after a successful MeshCore radio session). If you never imported identity and have not connected a MeshCore radio yet, import under **Radio** or use **Custom** credentials; if refresh still fails, try re-importing MeshCore config JSON to replace a corrupt cache +- For device-signing brokers (LetsMesh / MeshMapper / Colorado Mesh / Waev / Meshat.se / MeshCore.CA / EastMesh): mesh-client refreshes the JWT automatically when MeshCore identity is already cached (including after a successful MeshCore radio session). If you never imported identity and have not connected a MeshCore radio yet, import under **Radio** or use **Custom** credentials; if refresh still fails, try re-importing MeshCore config JSON to replace a corrupt cache - Enable debug logs to see the disconnect reason ### MQTT connected but no messages from other nodes @@ -701,9 +701,9 @@ After **24 hours** of uptime, the main process logs periodic **long-session heal **Fix**: Expected behavior for public brokers. For two-way MQTT, use a different broker or connect via BLE/Serial. -### "Token expired" on LetsMesh/Colorado Mesh +### "Token expired" on a device-signing broker -**Cause**: JWT tokens expire after 1 hour. +**Cause**: JWT tokens expire after 1 hour (LetsMesh / MeshMapper / Colorado Mesh / Waev / Meshat.se / MeshCore.CA / EastMesh). The JWT `aud` always matches the broker hostname you connect to. **Fix**: The client refreshes tokens proactively before expiry when identity is present. If you still see expiry errors, connect or re-import MeshCore so `public_key` / `private_key` are cached (Radio-tab JSON import, or automatic persistence after a successful MeshCore radio session). As a fallback, paste your `v1_` MQTT username and a manually generated token under **Custom** if your broker expects a different workflow. @@ -1370,9 +1370,9 @@ Bond-stale **TX queue full** hints (`txQueueDropsHintBleBondStale`) point at the **Symptoms**: MeshCore MQTT preset worked before upgrade; broker connection fails on port **1883** or wrong topic. -**Cause**: Colorado Mesh moved to **wss port 443** with topic **`meshcore/DEN`**; LetsMesh uses **`meshcore/test`**. Stale `mesh-client:mqttSettings:meshcore` may retain old port/topic. Malformed topic prefixes (not `meshcore/{IATA}` or `meshcore/test`) also block Connect on device-signing brokers. +**Cause**: Colorado Mesh moved to **wss port 443** with topic **`meshcore/DEN`**; LetsMesh uses **`meshcore/test`**. Stale `mesh-client:mqttSettings:meshcore` may retain old port/topic. Malformed topic prefixes (not `meshcore/{IATA}` or `meshcore/test`) also block Connect on device-signing brokers. A **stale WebSocket path** blocks Connect too: LetsMesh / MeshMapper / Colorado use `/ws`, while Waev / Meshat.se / MeshCore.CA / EastMesh use `/mqtt` — a mismatch gives a clear "requires WebSocket path …" error plus the amber deviation banner. -**Fix**: Re-select the preset on the Connection tab, or clear `mesh-client:mqttSettings:meshcore` in devtools Application → Local Storage and reconnect. Migrations run on app start via `connectionPanelStorageMigrations.ts` (port/topic repair + IATA shape normalize). +**Fix**: Re-select the preset on the Connection tab (or, for MeshCore.CA/LetsMesh, click the broker/region toggle) to repair `wsPath`/`tlsEnabled`, or clear `mesh-client:mqttSettings:meshcore` in devtools Application → Local Storage and reconnect. Migrations run on app start via `connectionPanelStorageMigrations.ts` (port/topic repair + IATA shape normalize + preset reconcile). ### MeshCore Colorado Mesh one-time region prompt diff --git a/package.json b/package.json index 272ae1c6f..de48f858a 100644 --- a/package.json +++ b/package.json @@ -198,7 +198,7 @@ "electron": "^41.10.4", "electron-builder": "^26.15.7", "esbuild": "^0.28.1", - "eslint": "^10.8.0", + "eslint": "^10.8.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-electron": "^7.0.0", "eslint-plugin-import": "^2.32.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0c8f9612..d474fcccd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,7 +125,7 @@ importers: version: 4.12.1 '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + version: 10.0.1(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) '@liamcottle/meshcore.js': specifier: ^1.14.0 version: 1.14.0(patch_hash=3b4aec317b533236af763c913077fb9b2ba93d74e9b35a065b0b6f96d833cf11)(supports-color@8.1.1) @@ -182,10 +182,10 @@ importers: version: 19.2.4(@types/react@19.2.18) '@typescript-eslint/eslint-plugin': specifier: ^8.66.0 - version: 8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + version: 8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) '@typescript-eslint/parser': specifier: ^8.66.0 - version: 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + version: 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) '@vitejs/plugin-react': specifier: ^6.0.5 version: 6.0.5(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -205,38 +205,38 @@ importers: specifier: ^0.28.1 version: 0.28.1 eslint: - specifier: ^10.8.0 - version: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + specifier: ^10.8.1 + version: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + version: 10.1.8(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) eslint-plugin-electron: specifier: ^7.0.0 - version: 7.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + version: 7.0.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + version: 2.32.0(@typescript-eslint/parser@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) eslint-plugin-jsx-a11y: specifier: ^6.10.2 - version: 6.10.2(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + version: 6.10.2(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) eslint-plugin-no-secrets: specifier: ^2.3.3 - version: 2.3.3(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + version: 2.3.3(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) eslint-plugin-prettier: specifier: ^5.5.6 - version: 5.5.6(eslint-config-prettier@10.1.8(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)))(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(prettier@3.9.6) + version: 5.5.6(eslint-config-prettier@10.1.8(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(prettier@3.9.6) eslint-plugin-react: specifier: ^7.37.5 - version: 7.37.5(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + version: 7.37.5(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) eslint-plugin-react-hooks: specifier: ^7.1.1 - version: 7.1.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + version: 7.1.1(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) eslint-plugin-security: specifier: ^4.0.1 version: 4.0.1 eslint-plugin-simple-import-sort: specifier: ^13.0.0 - version: 13.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + version: 13.0.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) fake-indexeddb: specifier: ^6.2.5 version: 6.2.5 @@ -287,7 +287,7 @@ importers: version: 6.0.3 typescript-eslint: specifier: ^8.66.0 - version: 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + version: 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) vite: specifier: ^8.2.1 version: 8.2.1(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) @@ -2287,8 +2287,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.8.0: - resolution: {integrity: sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==} + eslint@10.8.1: + resolution: {integrity: sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -5125,9 +5125,9 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))': + '@eslint-community/eslint-utils@4.10.1(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))': dependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -5148,9 +5148,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))': + '@eslint/js@10.0.1(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))': optionalDependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) '@eslint/object-schema@3.0.5': {} @@ -5782,15 +5782,15 @@ snapshots: dependencies: '@types/node': 25.9.5 - '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + '@typescript-eslint/parser': 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.66.0 - '@typescript-eslint/type-utils': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) - '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + '@typescript-eslint/type-utils': 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + '@typescript-eslint/utils': 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.66.0 - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -5798,14 +5798,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': + '@typescript-eslint/parser@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.66.0 '@typescript-eslint/types': 8.66.0 '@typescript-eslint/typescript-estree': 8.66.0(supports-color@8.1.1)(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.66.0 debug: 4.4.3(patch_hash=cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37)(supports-color@8.1.1) - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -5828,13 +5828,13 @@ snapshots: dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.66.0 '@typescript-eslint/typescript-estree': 8.66.0(supports-color@8.1.1)(typescript@6.0.3) - '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + '@typescript-eslint/utils': 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) debug: 4.4.3(patch_hash=cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37)(supports-color@8.1.1) - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: @@ -5857,13 +5857,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': + '@typescript-eslint/utils@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) '@typescript-eslint/scope-manager': 8.66.0 '@typescript-eslint/types': 8.66.0 '@typescript-eslint/typescript-estree': 8.66.0(supports-color@8.1.1)(typescript@6.0.3) - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -6805,9 +6805,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)): + eslint-config-prettier@10.1.8(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)): dependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) eslint-import-resolver-node@0.3.10(supports-color@8.1.1): dependencies: @@ -6817,22 +6817,22 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1): dependencies: debug: 3.2.7(supports-color@8.1.1) optionalDependencies: - '@typescript-eslint/parser': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + '@typescript-eslint/parser': 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) eslint-import-resolver-node: 0.3.10(supports-color@8.1.1) transitivePeerDependencies: - supports-color - eslint-plugin-electron@7.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)): + eslint-plugin-electron@7.0.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)): dependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) requireindex: 1.1.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -6841,9 +6841,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7(supports-color@8.1.1) doctrine: 2.1.0 - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) eslint-import-resolver-node: 0.3.10(supports-color@8.1.1) - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -6855,13 +6855,13 @@ snapshots: string.prototype.trimend: 1.0.10 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + '@typescript-eslint/parser': 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)): + eslint-plugin-jsx-a11y@6.10.2(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -6871,7 +6871,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -6880,31 +6880,31 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-no-secrets@2.3.3(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)): + eslint-plugin-no-secrets@2.3.3(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)): dependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) - eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)))(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(prettier@3.9.6): + eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(prettier@3.9.6): dependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) prettier: 3.9.6 prettier-linter-helpers: 1.0.1 synckit: 0.11.13 optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + eslint-config-prettier: 10.1.8(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) - eslint-plugin-react-hooks@7.1.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1): + eslint-plugin-react-hooks@7.1.1(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1): dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/parser': 7.29.8 - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) hermes-parser: 0.25.1 zod: 4.4.3 zod-validation-error: 4.0.2(zod@4.4.3) transitivePeerDependencies: - supports-color - eslint-plugin-react@7.37.5(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)): + eslint-plugin-react@7.37.5(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -6912,7 +6912,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.4.0 - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) estraverse: 5.3.0 hasown: 2.0.4 jsx-ast-utils: 3.3.5 @@ -6930,9 +6930,9 @@ snapshots: dependencies: safe-regex: 2.1.1 - eslint-plugin-simple-import-sort@13.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)): + eslint-plugin-simple-import-sort@13.0.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)): dependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) eslint-scope@9.1.2: dependencies: @@ -6945,9 +6945,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1): + eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5(supports-color@8.1.1) '@eslint/config-helpers': 0.7.0 @@ -9286,13 +9286,13 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3): + typescript-eslint@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) - '@typescript-eslint/parser': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + '@typescript-eslint/parser': 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) '@typescript-eslint/typescript-estree': 8.66.0(supports-color@8.1.1)(typescript@6.0.3) - '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + '@typescript-eslint/utils': 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) typescript: 6.0.3 transitivePeerDependencies: - supports-color diff --git a/reticulum-sidecar/patches/README.md b/reticulum-sidecar/patches/README.md index b57d62f8e..48867c25b 100644 --- a/reticulum-sidecar/patches/README.md +++ b/reticulum-sidecar/patches/README.md @@ -250,7 +250,9 @@ Halt BLE RNode reconnect when CoreBluetooth reports **Peer removed pairing infor | Field | Value | | ----- | ----- | | **Base commit** | tip after `rsReticulum-ble-rnode-pairing-transition-debounce.patch` | -| **Upstream PR** | (mesh-client local; no upstream PR yet) | +| **Upstream PR** | https://github.com/ratspeak/rsReticulum/pull/21 | + +The upstream PR is **standalone off `main`** (independent of [#20](https://github.com/ratspeak/rsReticulum/pull/20) — the bond-desync code does not touch `PAIRING_TRANSITION_RETRY_WAIT`). The local overlay is still regenerated on top of the pairing-transition debounce overlay, so apply it **after** that overlay here. **Modifies (1 file):** diff --git a/reticulum-sidecar/src/stack/games_session.rs b/reticulum-sidecar/src/stack/games_session.rs index 66ce203aa..6da4e1e08 100644 --- a/reticulum-sidecar/src/stack/games_session.rs +++ b/reticulum-sidecar/src/stack/games_session.rs @@ -15,10 +15,11 @@ use std::sync::{Arc, Mutex}; use lrgp::app_base::IncomingDispatch; use lrgp::constants::{ - CMD_CHALLENGE, CMD_MOVE, ERR_INVALID_MOVE, ERR_NOT_YOUR_TURN, KEY_APP, KEY_COMMAND, - KEY_PAYLOAD, KEY_SESSION, + CMD_CHALLENGE, CMD_MOVE, ERR_INVALID_MOVE, ERR_NOT_YOUR_TURN, ERR_SESSION_EXPIRED, KEY_APP, + KEY_COMMAND, KEY_PAYLOAD, KEY_SESSION, STATUS_EXPIRED, }; use lrgp::envelope; +use lrgp::errors::LrgpError; use lrgp::router::LrgpRouter; use lrgp::session::Session; use lrgp::store::LrgpStore; @@ -373,6 +374,22 @@ impl GamesSessionManager { .map_err(|e| e.to_string()) } + /// Best-effort: mark a session's stored status as `expired` after the local + /// app reports its idle TTL elapsed. Keeps the SQLite mirror consistent with + /// the router's in-memory verdict so `list_sessions` (and future refreshes) + /// stop showing a dead game as active. Never fails the caller. + fn persist_session_expired(&self, session_id: &str) { + let Some(store) = &self.store else { + return; + }; + let mut updates = HashMap::new(); + updates.insert("status".to_string(), STATUS_EXPIRED.to_string()); + updates.insert("updated_at".to_string(), now_secs().to_string()); + if let Err(e) = store.update_session(session_id, &self.identity_id, &updates) { + tracing::warn!(target: "games", "failed to persist expired session status: {e}"); + } + } + pub fn delete_session(&self, session_id: &str) -> Result<(), String> { let store = self .store @@ -591,18 +608,27 @@ impl GamesSessionManager { // Challenges must bind the remote peer before accept can succeed; use // dispatch_outgoing_to for every outbound action (matches Ratspeak). - let prepared = self - .router - .dispatch_outgoing_to( - app_id, - version, - command, - &session_id, - &payload, - &self.identity_id, - &dest_hash, - ) - .map_err(|e| format!("dispatch_error: {e}"))?; + let prepared = match self.router.dispatch_outgoing_to( + app_id, + version, + command, + &session_id, + &payload, + &self.identity_id, + &dest_hash, + ) { + Ok(prepared) => prepared, + Err(LrgpError::SessionExpired(_)) => { + // The local app considers this session past its idle TTL, so the + // protocol can no longer act on it (resign/move/draw all fail). + // Persist the terminal state so `list_sessions` and the UI stop + // offering active-only controls and the row can be deleted, then + // surface a stable error code the renderer humanizes. + self.persist_session_expired(&session_id); + return Err(ERR_SESSION_EXPIRED.to_string()); + } + Err(e) => return Err(format!("dispatch_error: {e}")), + }; let fields = match transport::pack_into_preencoded_fields(&prepared.envelope) { Ok(fields) => fields, @@ -1154,7 +1180,7 @@ fn json_payload_to_rmpv_map(value: Option<&JsonValue>) -> HashMap std::sync::MutexGuard<'static, ()> { @@ -1391,6 +1417,51 @@ mod tests { assert_eq!(detail["session"]["delivery_state"], "sending"); } + #[test] + fn expired_session_action_returns_session_expired_and_persists_status() { + let dir = tempfile::tempdir().expect("tempdir"); + let identity = "selfidentityhash"; + let peer = "f".repeat(32); + let session_id = "eeeeeeeeeeeeeeee"; + let (event_tx, _rx) = broadcast::channel(16); + + // Establish an active session, then age it past the idle TTL in SQLite. + { + let manager = GamesSessionManager::spawn(dir.path(), identity.into(), event_tx.clone()); + assert!(manager.handle_inbound_lxmf(&inbound_challenge_fields(session_id), &peer, "")); + let _guard = hydrate_err_test_guard(); + let accept = manager + .prepare_action(&peer, "ttt", CMD_ACCEPT, Some(session_id), None) + .expect("accept"); + manager.commit_action(&accept, Some("mh-expire")); + assert_eq!( + manager.session_detail(session_id)["session"]["status"], + "active" + ); + let mut aged = HashMap::new(); + aged.insert("last_action_at".to_string(), "0".to_string()); + manager + .store + .as_ref() + .expect("store") + .update_session(session_id, identity, &aged) + .expect("age session"); + } + + // Fresh manager rehydrates the aged row; the app now reports it expired. + let manager = GamesSessionManager::spawn(dir.path(), identity.into(), event_tx); + let _guard = hydrate_err_test_guard(); + let err = manager + .prepare_action(&peer, "ttt", CMD_RESIGN, Some(session_id), None) + .expect_err("expired session must reject the action"); + assert_eq!(err, ERR_SESSION_EXPIRED); + // The stored status is flipped to a terminal value so the UI can remove it. + assert_eq!( + manager.session_detail(session_id)["session"]["status"], + "expired" + ); + } + #[test] fn delete_session_clears_outbound_envelope() { let (_dir, manager, _) = test_manager(); diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index d2bba51bf..cf2f6141a 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -12,7 +12,10 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use lxmf_core::constants::{DeliveryMethod, FIELD_FILE_ATTACHMENTS, FIELD_ICON_APPEARANCE}; +use lxmf_core::constants::{ + DeliveryMethod, FIELD_FILE_ATTACHMENTS, FIELD_ICON_APPEARANCE, FIELD_REACTION, + REACTION_CONTENT, REACTION_TO, +}; use lxmf_core::message::LxMessage; /// Upstream LXMF 1.0.0 reply-to (`LXMF.py`); not yet named in rsLXMF constants. @@ -3295,8 +3298,33 @@ impl LiveBridge { } }; - let (msg, message_hash_hex) = - self.prepare_signed_outbound_lxmf(dest, "", &req.emoji, delivery_method, None, None)?; + // Stamp the standard LXMF FIELD_REACTION (0x40) so Ratspeak / Sideband peers + // render a structured tapback. The emoji is always kept as message content so + // clients that ignore 0x40 still show something. An unparsable target_hash is + // treated as absent (send content-only) rather than poisoning the message. + let reaction_target = parse_optional_reply_to_hash(Some(&req.target_hash)); + // Only surface structured reaction metadata to the renderer when the target hash parsed + // cleanly. The content-only fallback below must omit `reaction_target` so downstream + // consumers do not treat a plain-emoji send as a structured tapback. + let reaction_target_out: Option = + reaction_target.as_ref().map(|_| req.target_hash.clone()); + let (msg, message_hash_hex) = if let Some(target) = reaction_target { + let mut fields = HashMap::new(); + fields.insert(FIELD_REACTION, encode_reaction_field(&target, &req.emoji)); + self.prepare_signed_outbound_lxmf_with_fields( + dest, + "", + &req.emoji, + delivery_method, + &fields, + )? + } else { + tracing::warn!( + target: "lxmf", + "send_reaction: target_hash is not a 32-byte hex, sending content-only", + ); + self.prepare_signed_outbound_lxmf(dest, "", &req.emoji, delivery_method, None, None)? + }; let mut router = self.router.lock().await; router .try_send(msg) @@ -3313,7 +3341,7 @@ impl LiveBridge { "text": req.emoji, "timestamp": ts_ms, "to_hash": req.destination_hash, - "reaction_target": req.target_hash, + "reaction_target": reaction_target_out, "direction": "outbound", "message_hash": message_hash_hex, "delivery_status": "sending" @@ -4584,7 +4612,30 @@ pub(super) fn lxmf_payload_from_message( obj.insert("icon_appearance".into(), icon); } } - if let Some(reply) = reply_fields_from_message(msg) { + // A structured reaction (0x40) wins over a reply (0x30) for tapback classification: + // renderer ingest keys off `reaction_target`, so we must not also emit `reply_to_hash` + // (which would render a reply bubble). When 0x40 is absent, reply/plain paths are + // unchanged. + if let Some(reaction) = reaction_fields_from_message(msg) { + if let Some(obj) = payload.as_object_mut() { + obj.insert( + "reaction_target".into(), + serde_json::Value::String(reaction.reaction_target), + ); + // Fall back to the field emoji only when the message content is empty, + // so peers that carry the emoji solely in 0x40 still show it. + let text_empty = obj + .get("text") + .and_then(|t| t.as_str()) + .map(|t| t.trim().is_empty()) + .unwrap_or(true); + if text_empty { + if let Some(emoji) = reaction.emoji { + obj.insert("text".into(), serde_json::Value::String(emoji)); + } + } + } + } else if let Some(reply) = reply_fields_from_message(msg) { if let Some(obj) = payload.as_object_mut() { obj.insert( "reply_to_hash".into(), @@ -4648,6 +4699,95 @@ fn reply_fields_from_message(msg: &LxMessage) -> Option { }) } +struct LxmfReactionFields { + /// Lowercase 64-hex parent message hash. + reaction_target: String, + /// Reaction content (emoji) when carried in the field; may be absent. + emoji: Option, +} + +/// Encode a standard LXMF `FIELD_REACTION` (0x40) value as a msgpack map keyed by +/// `REACTION_TO` (0x00 = 32-byte parent hash, Binary) and `REACTION_CONTENT` +/// (0x01 = UTF-8 emoji). Returned bytes are a single complete msgpack value for +/// [`LxMessage::set_msgpack_field`]. +fn encode_reaction_field(target: &[u8; 32], emoji: &str) -> Vec { + let map = rmpv::Value::Map(vec![ + ( + rmpv::Value::Integer(rmpv::Integer::from(REACTION_TO)), + rmpv::Value::Binary(target.to_vec()), + ), + ( + rmpv::Value::Integer(rmpv::Integer::from(REACTION_CONTENT)), + rmpv::Value::String(emoji.into()), + ), + ]); + let mut buf = Vec::new(); + // Encoding a well-formed rmpv value to a Vec is infallible. + let _ = rmpv::encode::write_value(&mut buf, &map); + buf +} + +/// Normalize a `REACTION_TO` msgpack value to a lowercase 64-hex string. +/// Accepts raw 32-byte Binary (Python/Sideband) or a 64-char ASCII-hex string +/// (Ratspeak-compatible). Anything else is rejected. +fn reaction_target_hex(value: &rmpv::Value) -> Option { + match value { + rmpv::Value::Binary(bin) if bin.len() == 32 => Some(hex::encode(bin)), + rmpv::Value::String(s) => { + let text = s.as_str()?.trim(); + if text.len() == 64 && text.bytes().all(|b| b.is_ascii_hexdigit()) { + Some(text.to_ascii_lowercase()) + } else { + None + } + } + _ => None, + } +} + +/// Extract reaction content (emoji) from a `REACTION_CONTENT` msgpack value. +fn reaction_content_text(value: &rmpv::Value) -> Option { + let text = match value { + rmpv::Value::String(s) => s.as_str()?.to_string(), + rmpv::Value::Binary(bin) => std::str::from_utf8(bin).ok()?.to_string(), + _ => return None, + }; + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +/// Decode LXMF 1.0.1 `FIELD_REACTION` (0x40). Fail-open: any missing/malformed +/// field (not a msgpack map, missing/invalid `REACTION_TO`, wrong types) returns +/// `None` so the message ingests as normal text/reply. +fn reaction_fields_from_message(msg: &LxMessage) -> Option { + let raw = msg.get_field(FIELD_REACTION)?; + let mut cursor = Cursor::new(raw.as_slice()); + let value = rmpv::decode::read_value(&mut cursor).ok()?; + // A conformant FIELD_REACTION is exactly one msgpack map. Trailing bytes after the map mean + // the field is malformed, so fail open (ingest as normal text/reply) rather than trust it. + if cursor.position() as usize != raw.len() { + return None; + } + let map = value.as_map()?; + let mut reaction_target: Option = None; + let mut emoji: Option = None; + for (key, val) in map { + match key.as_u64() { + Some(k) if k == u64::from(REACTION_TO) => reaction_target = reaction_target_hex(val), + Some(k) if k == u64::from(REACTION_CONTENT) => emoji = reaction_content_text(val), + _ => {} + } + } + Some(LxmfReactionFields { + reaction_target: reaction_target?, + emoji, + }) +} + fn mime_from_file_name(file_name: &str) -> String { let lower = file_name.to_lowercase(); if lower.ends_with(".webm") { @@ -6217,4 +6357,184 @@ mod reply_field_tests { assert_eq!(payload["text"], "paper body"); assert_eq!(payload["sender_name"], "Bob"); } + + fn write_reaction_map(pairs: Vec<(rmpv::Value, rmpv::Value)>) -> Vec { + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &rmpv::Value::Map(pairs)).expect("encode reaction"); + buf + } + + #[test] + fn encode_reaction_field_round_trips_target_and_emoji() { + let target = [0x11u8; 32]; + let mut msg = LxMessage::new([0u8; 16], [1u8; 16], "", "👍", DeliveryMethod::Direct); + msg.set_msgpack_field(FIELD_REACTION, encode_reaction_field(&target, "👍")) + .expect("set reaction field"); + + let decoded = reaction_fields_from_message(&msg).expect("reaction fields"); + assert_eq!(decoded.reaction_target, hex::encode(target)); + assert_eq!(decoded.emoji.as_deref(), Some("👍")); + + let payload = lxmf_payload_from_message( + &msg, + "aabbccddeeff00112233445566778899", + "Self", + None, + None, + "inbound", + Some("Alice"), + ); + assert_eq!(payload["reaction_target"], hex::encode(target)); + // Emoji stays as message content for clients that ignore 0x40. + assert_eq!(payload["text"], "👍"); + assert!(payload.get("reply_to_hash").is_none()); + } + + #[test] + fn reaction_missing_field_leaves_payload_untouched() { + let msg = LxMessage::new([0u8; 16], [1u8; 16], "", "hello", DeliveryMethod::Direct); + assert!(reaction_fields_from_message(&msg).is_none()); + + let payload = lxmf_payload_from_message( + &msg, + "aabbccddeeff00112233445566778899", + "Self", + None, + None, + "inbound", + Some("Alice"), + ); + assert!(payload.get("reaction_target").is_none()); + assert_eq!(payload["text"], "hello"); + } + + #[test] + fn reaction_decodes_from_64_hex_string_target() { + let target_hex = "AB".repeat(32); // uppercase, Ratspeak-style hex string + let map = write_reaction_map(vec![ + ( + rmpv::Value::Integer(rmpv::Integer::from(REACTION_TO)), + rmpv::Value::String(target_hex.as_str().into()), + ), + ( + rmpv::Value::Integer(rmpv::Integer::from(REACTION_CONTENT)), + rmpv::Value::String("🎉".into()), + ), + ]); + let mut msg = LxMessage::new([0u8; 16], [1u8; 16], "", "", DeliveryMethod::Direct); + msg.set_msgpack_field(FIELD_REACTION, map) + .expect("set field"); + + let decoded = reaction_fields_from_message(&msg).expect("reaction fields"); + assert_eq!(decoded.reaction_target, target_hex.to_ascii_lowercase()); + assert_eq!(decoded.emoji.as_deref(), Some("🎉")); + + // Content was empty, so the field emoji fills `text`. + let payload = lxmf_payload_from_message( + &msg, + "aabbccddeeff00112233445566778899", + "Self", + None, + None, + "inbound", + Some("Alice"), + ); + assert_eq!(payload["text"], "🎉"); + } + + #[test] + fn reaction_malformed_field_is_ignored() { + // Not a msgpack map (a plain string value). + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &rmpv::Value::String("garbage".into())) + .expect("encode garbage"); + let mut msg = LxMessage::new( + [0u8; 16], + [1u8; 16], + "", + "still here", + DeliveryMethod::Direct, + ); + msg.set_msgpack_field(FIELD_REACTION, buf) + .expect("set field"); + assert!(reaction_fields_from_message(&msg).is_none()); + + // Map present but REACTION_TO has a bad length → treated as absent. + let bad_len = write_reaction_map(vec![( + rmpv::Value::Integer(rmpv::Integer::from(REACTION_TO)), + rmpv::Value::Binary(vec![0u8; 16]), + )]); + let mut msg2 = LxMessage::new( + [0u8; 16], + [1u8; 16], + "", + "still here", + DeliveryMethod::Direct, + ); + msg2.set_msgpack_field(FIELD_REACTION, bad_len) + .expect("set field"); + assert!(reaction_fields_from_message(&msg2).is_none()); + + let payload = lxmf_payload_from_message( + &msg2, + "aabbccddeeff00112233445566778899", + "Self", + None, + None, + "inbound", + Some("Alice"), + ); + assert!(payload.get("reaction_target").is_none()); + assert_eq!(payload["text"], "still here"); + } + + #[test] + fn reaction_wins_over_reply_when_both_present() { + let reaction_target = [0x22u8; 32]; + let reply_parent = [0x33u8; 32]; + let mut msg = LxMessage::new([0u8; 16], [1u8; 16], "", "😀", DeliveryMethod::Direct); + msg.set_field(FIELD_REPLY_TO, reply_parent.to_vec()); + msg.set_field(FIELD_REPLY_QUOTE, b"quoted".to_vec()); + msg.set_msgpack_field( + FIELD_REACTION, + encode_reaction_field(&reaction_target, "😀"), + ) + .expect("set reaction field"); + + let payload = lxmf_payload_from_message( + &msg, + "aabbccddeeff00112233445566778899", + "Self", + None, + None, + "inbound", + Some("Alice"), + ); + assert_eq!(payload["reaction_target"], hex::encode(reaction_target)); + // A reaction must not also render as a reply bubble. + assert!(payload.get("reply_to_hash").is_none()); + assert!(payload.get("reply_preview_text").is_none()); + } + + #[test] + fn reaction_field_with_trailing_bytes_is_rejected() { + // A well-formed reaction map that is followed by extra bytes must fail open: the field + // is malformed, so decoding returns None (ingest as normal text) rather than trust it. + let target = [0x44u8; 32]; + let mut buf = encode_reaction_field(&target, "🔥"); + buf.push(0x00); // trailing byte after the complete msgpack map + // Use the raw field setter: set_msgpack_field itself rejects trailing bytes, but a hostile + // or buggy peer can still place raw bytes in the field, which decode must reject. + let mut msg = LxMessage::new([0u8; 16], [1u8; 16], "", "🔥", DeliveryMethod::Direct); + msg.set_field(FIELD_REACTION, buf); + assert!(reaction_fields_from_message(&msg).is_none()); + + // Sanity: the same map without trailing bytes still decodes. + let mut ok_msg = LxMessage::new([0u8; 16], [1u8; 16], "", "🔥", DeliveryMethod::Direct); + ok_msg + .set_msgpack_field(FIELD_REACTION, encode_reaction_field(&target, "🔥")) + .expect("set field"); + let decoded = reaction_fields_from_message(&ok_msg).expect("reaction fields"); + assert_eq!(decoded.reaction_target, hex::encode(target)); + } } diff --git a/scripts/update.sh b/scripts/update.sh index cdd39ef91..f401fa1fe 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -216,7 +216,7 @@ check_ratspeak_patches() { 'rsReticulum-link-client-nomad.patch|ratspeak/rsReticulum|14|rsReticulum LinkClient Nomad|https://github.com/ratspeak/rsReticulum/pull/14' 'rsReticulum-link-client-proof-budget.patch|ratspeak/rsReticulum||rsReticulum LinkClient proof-budget remaining-deadline|' 'rsReticulum-ble-rnode-pairing-transition-debounce.patch|ratspeak/rsReticulum|20|rsReticulum BLE RNode pairing-transition debounce|https://github.com/ratspeak/rsReticulum/pull/20' - 'rsReticulum-ble-rnode-bond-desync.patch|ratspeak/rsReticulum||rsReticulum BLE RNode bond-desync halt + bond-aware reconnect|' + 'rsReticulum-ble-rnode-bond-desync.patch|ratspeak/rsReticulum|21|rsReticulum BLE RNode bond-desync halt + bond-aware reconnect|https://github.com/ratspeak/rsReticulum/pull/21' 'rsReticulum-discovery-announce-egress.patch|ratspeak/rsReticulum|19|rsReticulum discovery announce egress|https://github.com/ratspeak/rsReticulum/pull/19' 'rsReticulum-inbound-raw-saturation-log.patch|ratspeak/rsReticulum||rsReticulum inbound-raw saturation log|' 'rsLXMF-propagation-sync-peering.patch|ratspeak/rsLXMF|4|rsLXMF propagation sync peering|https://github.com/ratspeak/rsLXMF/pull/4' diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a9f296b73..680014a31 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -3052,7 +3052,6 @@ function AppContent() { } : undefined } - suppressMountAutoConnect /> )} {protocol === 'meshcore' && @@ -3091,7 +3090,6 @@ function AppContent() { } : undefined } - suppressMountAutoConnect /> )} {protocol === 'reticulum' && capabilities.hasReticulumInterfaceConfig && ( @@ -3127,7 +3125,6 @@ function AppContent() { setActiveTab(adminTabIdx); } }} - suppressMountAutoConnect /> )} diff --git a/src/renderer/components/ConnectionPanel.hostLinkMeter.test.tsx b/src/renderer/components/ConnectionPanel.hostLinkMeter.test.tsx index 4b3f01b5e..fd2782a64 100644 --- a/src/renderer/components/ConnectionPanel.hostLinkMeter.test.tsx +++ b/src/renderer/components/ConnectionPanel.hostLinkMeter.test.tsx @@ -38,7 +38,6 @@ describe('ConnectionPanel host link meter', () => { onDisconnect={vi.fn().mockResolvedValue(undefined)} mqttStatus="disconnected" protocol="meshtastic" - suppressMountAutoConnect />, ); expect(screen.getByText('Signal')).toBeInTheDocument(); @@ -65,7 +64,6 @@ describe('ConnectionPanel host link meter', () => { onDisconnect={vi.fn().mockResolvedValue(undefined)} mqttStatus="disconnected" protocol="meshtastic" - suppressMountAutoConnect />, ); expect(screen.getByText('Unavailable (Web Bluetooth)')).toBeInTheDocument(); @@ -87,7 +85,6 @@ describe('ConnectionPanel host link meter', () => { onDisconnect={vi.fn().mockResolvedValue(undefined)} mqttStatus="disconnected" protocol="meshtastic" - suppressMountAutoConnect />, ); expect(screen.getByText('Link quality')).toBeInTheDocument(); @@ -111,7 +108,6 @@ describe('ConnectionPanel host link meter', () => { onDisconnect={vi.fn().mockResolvedValue(undefined)} mqttStatus="disconnected" protocol="meshtastic" - suppressMountAutoConnect />, ); expect(screen.queryByText('Signal')).not.toBeInTheDocument(); @@ -136,7 +132,6 @@ describe('ConnectionPanel host link meter', () => { onDisconnect={vi.fn().mockResolvedValue(undefined)} mqttStatus="disconnected" protocol="meshcore" - suppressMountAutoConnect />, ); expect(screen.getByText('Link quality')).toBeInTheDocument(); @@ -168,7 +163,6 @@ describe('ConnectionPanel host link meter', () => { onDisconnect={vi.fn().mockResolvedValue(undefined)} mqttStatus="disconnected" protocol="meshtastic" - suppressMountAutoConnect />, ); expect(screen.getByText('Link quality')).toBeInTheDocument(); diff --git a/src/renderer/components/ConnectionPanel.test.tsx b/src/renderer/components/ConnectionPanel.test.tsx index ff90d969b..9a583f7bc 100644 --- a/src/renderer/components/ConnectionPanel.test.tsx +++ b/src/renderer/components/ConnectionPanel.test.tsx @@ -239,7 +239,7 @@ describe('ConnectionPanel MeshCore MQTT presets', () => { vi.restoreAllMocks(); }); - it('orders presets LetsMesh, MeshMapper, Colorado Mesh before Ripple', () => { + it('lists all MeshCore presets in the network preset picker', () => { localStorage.setItem('mesh-client:coloradoMqttRegionAck-v1', '1'); localStorage.setItem('mesh-client:mqttPreset:meshcore', 'letsmesh'); localStorage.setItem( @@ -268,19 +268,158 @@ describe('ConnectionPanel MeshCore MQTT presets', () => { />, ); - const group = screen.getByRole('group', { name: 'Network Preset' }); - const labels = within(group) - .getAllByRole('button') - .map((b) => b.textContent); + const select = screen.getByRole('combobox', { name: 'Network Preset' }); + const labels = within(select) + .getAllByRole('option') + .map((o) => o.textContent); expect(labels).toEqual([ 'LetsMesh', 'MeshMapper', 'Colorado Mesh', + 'Waev', + 'Meshat.se', + 'MeshCore.CA', + 'EastMesh', 'Ripple Networks', 'Custom', ]); }); + it('applies Waev broker fields when selected from the picker', async () => { + const user = userEvent.setup(); + localStorage.setItem('mesh-client:coloradoMqttRegionAck-v1', '1'); + localStorage.setItem('mesh-client:mqttPreset:meshcore', 'letsmesh'); + localStorage.setItem( + 'mesh-client:mqttSettings:meshcore', + JSON.stringify({ + server: 'mqtt-us-v1.letsmesh.net', + topicPrefix: 'meshcore/test', + port: 443, + useWebSocket: true, + tlsEnabled: true, + }), + ); + localStorage.setItem('mesh-client:migrated:meshcore-letsmesh-default-v1', '1'); + localStorage.setItem('mesh-client:migrated:meshcore-topic-iata-v1', '1'); + localStorage.setItem('mesh-client:migrated:colorado-mesh-port-443-v1', '1'); + localStorage.setItem('mesh-client:migrated:meshcore-topic-iata-shape-v1', '1'); + + render( + , + ); + + const select = screen.getByRole('combobox', { name: 'Network Preset' }); + await user.selectOptions(select, within(select).getByRole('option', { name: 'Waev' })); + + expect(localStorage.getItem('mesh-client:mqttPreset:meshcore')).toBe('waev'); + expect(screen.getByLabelText(/^Server$/i).value).toBe('mqtt.waev.app'); + expect(screen.getByLabelText(/^Port$/i).value).toBe('443'); + }); + + function renderLetsMeshMeshcorePanel() { + localStorage.setItem('mesh-client:coloradoMqttRegionAck-v1', '1'); + localStorage.setItem('mesh-client:mqttPreset:meshcore', 'letsmesh'); + localStorage.setItem( + 'mesh-client:mqttSettings:meshcore', + JSON.stringify({ + server: 'mqtt-us-v1.letsmesh.net', + topicPrefix: 'meshcore/test', + port: 443, + useWebSocket: true, + tlsEnabled: true, + wsPath: '/ws', + keepalive: 30, + }), + ); + localStorage.setItem('mesh-client:migrated:meshcore-letsmesh-default-v1', '1'); + localStorage.setItem('mesh-client:migrated:meshcore-topic-iata-v1', '1'); + localStorage.setItem('mesh-client:migrated:colorado-mesh-port-443-v1', '1'); + localStorage.setItem('mesh-client:migrated:meshcore-topic-iata-shape-v1', '1'); + return render( + , + ); + } + + it('normalizes an IATA topic prefix on blur without leaving the preset', async () => { + const user = userEvent.setup(); + renderLetsMeshMeshcorePanel(); + + const topic = screen.getByLabelText(/^Topic Prefix$/i); + await user.clear(topic); + await user.type(topic, 'meshcore/den'); + fireEvent.blur(topic); + + expect(topic.value).toBe('meshcore/DEN'); + expect(screen.getByRole('combobox', { name: 'Network Preset' })).toHaveValue('letsmesh'); + }); + + it('disables Connect and flags an invalid IATA topic prefix', async () => { + const user = userEvent.setup(); + renderLetsMeshMeshcorePanel(); + + const topic = screen.getByLabelText(/^Topic Prefix$/i); + await user.clear(topic); + await user.type(topic, 'meshcore/zzzz'); + + expect(topic).toHaveAttribute('aria-invalid', 'true'); + const mqttCard = screen.getByText('MQTT Connection').closest('.bg-deep-black'); + const connectBtn = within(mqttCard as HTMLElement).getByRole('button', { name: 'Connect' }); + expect(connectBtn).toBeDisabled(); + }); + + it('offers MeshCore.CA Primary/Backup broker toggle that switches the server', async () => { + const user = userEvent.setup(); + localStorage.setItem('mesh-client:coloradoMqttRegionAck-v1', '1'); + localStorage.setItem('mesh-client:mqttPreset:meshcore', 'letsmesh'); + localStorage.setItem( + 'mesh-client:mqttSettings:meshcore', + JSON.stringify({ + server: 'mqtt-us-v1.letsmesh.net', + topicPrefix: 'meshcore/test', + port: 443, + useWebSocket: true, + tlsEnabled: true, + }), + ); + localStorage.setItem('mesh-client:migrated:meshcore-letsmesh-default-v1', '1'); + localStorage.setItem('mesh-client:migrated:meshcore-topic-iata-v1', '1'); + localStorage.setItem('mesh-client:migrated:colorado-mesh-port-443-v1', '1'); + localStorage.setItem('mesh-client:migrated:meshcore-topic-iata-shape-v1', '1'); + + render( + , + ); + + const select = screen.getByRole('combobox', { name: 'Network Preset' }); + await user.selectOptions(select, within(select).getByRole('option', { name: 'MeshCore.CA' })); + expect(screen.getByLabelText(/^Server$/i).value).toBe('mqtt1.meshcore.ca'); + + const brokerGroup = screen.getByRole('group', { name: 'MeshCore.CA broker' }); + await user.click(within(brokerGroup).getByRole('button', { name: 'Backup' })); + expect(screen.getByLabelText(/^Server$/i).value).toBe('mqtt2.meshcore.ca'); + }); + it('does not apply Colorado preset fields when confirm is cancelled', async () => { const user = userEvent.setup(); localStorage.setItem('mesh-client:coloradoMqttRegionAck-v1', '1'); @@ -314,10 +453,14 @@ describe('ConnectionPanel MeshCore MQTT presets', () => { />, ); - await user.click(screen.getByRole('button', { name: 'Colorado Mesh' })); + const select = screen.getByRole('combobox', { name: 'Network Preset' }); + await user.selectOptions(select, within(select).getByRole('option', { name: 'Colorado Mesh' })); expect(window.confirm).toHaveBeenCalled(); expect(localStorage.getItem('mesh-client:mqttPreset:meshcore')).toBe('letsmesh'); expect(screen.getByLabelText(/^Server$/i)).toHaveValue('mqtt-us-v1.letsmesh.net'); + // The controlled select must snap back to the current preset after a cancelled confirm + // (re-query: cancelling remounts the select so the original node is detached). + expect(screen.getByRole('combobox', { name: 'Network Preset' })).toHaveValue('letsmesh'); }); it('shows one-time Colorado region gate and switches to LetsMesh on cancel', async () => { @@ -362,6 +505,48 @@ describe('ConnectionPanel MeshCore MQTT presets', () => { ).not.toBeInTheDocument(); }); + it('keeps Colorado Mesh when the region gate is acknowledged with Stay', async () => { + const user = userEvent.setup(); + localStorage.setItem('mesh-client:mqttPreset:meshcore', 'coloradomesh'); + localStorage.setItem( + 'mesh-client:mqttSettings:meshcore', + JSON.stringify({ + server: 'mqtt.meshcore.coloradomesh.org', + topicPrefix: 'meshcore/DEN', + port: 443, + useWebSocket: true, + tlsEnabled: true, + wsPath: '/ws', + keepalive: 30, + password: '', + }), + ); + localStorage.setItem('mesh-client:migrated:meshcore-letsmesh-default-v1', '1'); + localStorage.setItem('mesh-client:migrated:meshcore-topic-iata-v1', '1'); + localStorage.setItem('mesh-client:migrated:colorado-mesh-port-443-v1', '1'); + localStorage.setItem('mesh-client:migrated:meshcore-topic-iata-shape-v1', '1'); + + render( + , + ); + + const dialog = await screen.findByRole('alertdialog', { name: 'Colorado Mesh MQTT' }); + await user.click(within(dialog).getByRole('button', { name: 'I am in Colorado' })); + expect(localStorage.getItem('mesh-client:coloradoMqttRegionAck-v1')).toBe('1'); + expect(localStorage.getItem('mesh-client:mqttPreset:meshcore')).toBe('coloradomesh'); + expect(screen.getByLabelText(/^Server$/i)).toHaveValue('mqtt.meshcore.coloradomesh.org'); + expect( + screen.queryByRole('alertdialog', { name: 'Colorado Mesh MQTT' }), + ).not.toBeInTheDocument(); + }); + it('shows Colorado region gate even when MQTT is already connected', async () => { localStorage.setItem('mesh-client:mqttPreset:meshcore', 'coloradomesh'); localStorage.setItem( @@ -518,7 +703,7 @@ describe('ConnectionPanel Linux BLE auto-connect', () => { return userAgentSpy; } - it('does not auto-connect BLE on mount when last connection is saved', async () => { + it('does not mount-auto-connect BLE when last connection is saved (coordinator owns cold-start)', async () => { const userAgentSpy = mockLinuxUserAgent(); const bleId = 'linux-ble-device'; const lastConnKey = 'mesh-client:lastConnection:meshtastic'; @@ -968,11 +1153,12 @@ describe("ConnectionPanel Meshtastic MQTT presets — Liam's server", () => { ); } - it("clicking Liam's preset populates liamcottle.net credentials", async () => { + it("selecting Liam's preset populates liamcottle.net credentials", async () => { const user = userEvent.setup(); renderMeshtasticMqtt(); - await user.click(screen.getByRole('button', { name: "Liam's" })); + const select = screen.getByRole('combobox', { name: 'Network Preset' }); + await user.selectOptions(select, within(select).getByRole('option', { name: "Liam's" })); expect(screen.getByLabelText(/^Server$/i).value).toBe( 'mqtt.meshtastic.liamcottle.net', @@ -985,19 +1171,23 @@ describe("ConnectionPanel Meshtastic MQTT presets — Liam's server", () => { const user = userEvent.setup(); renderMeshtasticMqtt(); - await user.click(screen.getByRole('button', { name: "Liam's" })); + const select = screen.getByRole('combobox', { name: 'Network Preset' }); + await user.selectOptions(select, within(select).getByRole('option', { name: "Liam's" })); expect(screen.getByText(/uplink-only/i)).toBeInTheDocument(); }); - it('hides uplink-only warning for official presets', async () => { + it('selecting the Official preset applies official 1883 fields and hides the uplink warning', async () => { const user = userEvent.setup(); renderMeshtasticMqtt(); - // First activate Liam's, then switch away - await user.click(screen.getByRole('button', { name: "Liam's" })); - await user.click(screen.getByRole('button', { name: 'MQTT :1883' })); + const select = screen.getByRole('combobox', { name: 'Network Preset' }); + // First activate Liam's, then switch back to Official + await user.selectOptions(select, within(select).getByRole('option', { name: "Liam's" })); + await user.selectOptions(select, within(select).getByRole('option', { name: 'Official' })); + expect(screen.getByLabelText(/^Server$/i).value).toBe('mqtt.meshtastic.org'); + expect(screen.getByLabelText(/^Port$/i).value).toBe('1883'); expect(screen.queryByText(/uplink-only/i)).not.toBeInTheDocument(); }); }); @@ -1297,17 +1487,11 @@ describe('ConnectionPanel BLE noble manual connect', () => { />, ); - await waitFor(() => { - expect(onAutoConnect).toHaveBeenCalledWith( - 'ble', - undefined, - undefined, - 'previously-paired-radio', - ); - }); await waitFor(() => { expect(screen.getByRole('radiogroup', { name: 'Connection Type' })).toBeInTheDocument(); }); + // Cold-start BLE is owned by ProtocolAutoConnectCoordinator — panel must not mount-connect. + expect(onAutoConnect).not.toHaveBeenCalled(); vi.mocked(window.electronAPI.startNobleBleScanning).mockClear(); @@ -1329,323 +1513,7 @@ describe('ConnectionPanel BLE noble manual connect', () => { }); }); -describe('ConnectionPanel BLE noble auto-connect', () => { - it('calls onAutoConnect with saved peripheral id without waiting for renderer discovery', async () => { - const { restore } = mockMacNoblePlatform(); - const bleId = 'ble-known-device'; - const lastConnKey = 'mesh-client:lastConnection:meshtastic'; - localStorage.setItem(lastConnKey, JSON.stringify({ type: 'ble', bleDeviceId: bleId })); - const onAutoConnect = vi.fn().mockResolvedValue(undefined); - vi.mocked(window.electronAPI.startNobleBleScanning).mockClear(); - - try { - render( - , - ); - - await waitFor(() => { - expect(onAutoConnect).toHaveBeenCalledWith('ble', undefined, undefined, bleId); - }); - expect(window.electronAPI.startNobleBleScanning).not.toHaveBeenCalled(); - } finally { - localStorage.removeItem(lastConnKey); - restore(); - } - }); -}); - -describe('ConnectionPanel meshcore shared Meshtastic BLE auto-connect', () => { - it('skips meshcore noble scan when last BLE device matches Meshtastic', async () => { - const { restore } = mockMacNoblePlatform(); - const sharedId = 'shared-ble-peripheral'; - const mcConnKey = 'mesh-client:lastConnection:meshcore'; - const mtBleKey = 'mesh-client:lastBleDevice:meshtastic'; - localStorage.setItem(mcConnKey, JSON.stringify({ type: 'ble', bleDeviceId: sharedId })); - localStorage.setItem(mtBleKey, sharedId); - vi.mocked(window.electronAPI.startNobleBleScanning).mockClear(); - - try { - render( - , - ); - - await waitFor(() => { - expect(window.electronAPI.startNobleBleScanning).not.toHaveBeenCalled(); - }); - } finally { - localStorage.removeItem(mcConnKey); - localStorage.removeItem(mtBleKey); - restore(); - } - }); -}); - -describe('ConnectionPanel active-protocol-first BLE auto-connect', () => { - const protocolKey = 'mesh-client:protocol'; - - afterEach(() => { - localStorage.removeItem(protocolKey); - vi.restoreAllMocks(); - }); - - it('connects meshcore immediately when meshcore is the stored active protocol', async () => { - const { restore } = mockMacNoblePlatform(); - const mcConnKey = 'mesh-client:lastConnection:meshcore'; - const mtConnKey = 'mesh-client:lastConnection:meshtastic'; - localStorage.setItem(protocolKey, 'meshcore'); - localStorage.setItem('mesh-client:lastBleDevice:meshcore', 'meshcore-ble-device'); - localStorage.setItem('mesh-client:lastBleDevice:meshtastic', 'meshtastic-ble-device'); - localStorage.setItem( - mcConnKey, - JSON.stringify({ type: 'ble', bleDeviceId: 'meshcore-ble-device' }), - ); - localStorage.setItem( - mtConnKey, - JSON.stringify({ type: 'ble', bleDeviceId: 'meshtastic-ble-device' }), - ); - const onAutoConnect = vi.fn().mockResolvedValue(undefined); - const dualNoble = await import('../lib/meshcoreDualNobleBleInit'); - dualNoble.resetNobleBleConnectMutexForTests(); - dualNoble.initNobleBleDualRadioStartup(); - const settleSpy = vi.spyOn(dualNoble, 'awaitNobleBlePrimaryAutoConnectSettled'); - - try { - render( - , - ); - - await waitFor(() => { - expect(onAutoConnect).toHaveBeenCalledWith( - 'ble', - undefined, - undefined, - 'meshcore-ble-device', - ); - }); - expect(settleSpy).not.toHaveBeenCalled(); - } finally { - localStorage.removeItem(mcConnKey); - localStorage.removeItem(mtConnKey); - localStorage.removeItem('mesh-client:lastBleDevice:meshcore'); - localStorage.removeItem('mesh-client:lastBleDevice:meshtastic'); - dualNoble.resetNobleBleConnectMutexForTests(); - restore(); - } - }); - - it('defers inactive meshtastic auto-connect until active meshcore primary settles', async () => { - const { restore } = mockMacNoblePlatform(); - const mcConnKey = 'mesh-client:lastConnection:meshcore'; - const mtConnKey = 'mesh-client:lastConnection:meshtastic'; - localStorage.setItem(protocolKey, 'meshcore'); - localStorage.setItem('mesh-client:lastBleDevice:meshcore', 'meshcore-ble-device'); - localStorage.setItem('mesh-client:lastBleDevice:meshtastic', 'meshtastic-ble-device'); - localStorage.setItem( - mcConnKey, - JSON.stringify({ type: 'ble', bleDeviceId: 'meshcore-ble-device' }), - ); - localStorage.setItem( - mtConnKey, - JSON.stringify({ type: 'ble', bleDeviceId: 'meshtastic-ble-device' }), - ); - const onAutoConnect = vi.fn().mockResolvedValue(undefined); - const dualNoble = await import('../lib/meshcoreDualNobleBleInit'); - dualNoble.resetNobleBleConnectMutexForTests(); - dualNoble.initNobleBleDualRadioStartup(); - let releaseSettle!: () => void; - const settleSpy = vi - .spyOn(dualNoble, 'awaitNobleBlePrimaryAutoConnectSettled') - .mockImplementation( - () => - new Promise((resolve) => { - releaseSettle = resolve; - }), - ); - - try { - render( - , - ); - - await Promise.resolve(); - expect(settleSpy).toHaveBeenCalled(); - expect(onAutoConnect).not.toHaveBeenCalled(); - - releaseSettle(); - await waitFor(() => { - expect(onAutoConnect).toHaveBeenCalledWith( - 'ble', - undefined, - undefined, - 'meshtastic-ble-device', - ); - }); - } finally { - localStorage.removeItem(mcConnKey); - localStorage.removeItem(mtConnKey); - localStorage.removeItem('mesh-client:lastBleDevice:meshcore'); - localStorage.removeItem('mesh-client:lastBleDevice:meshtastic'); - dualNoble.resetNobleBleConnectMutexForTests(); - restore(); - } - }); - - it('defers inactive meshcore auto-connect until active meshtastic primary settles', async () => { - const { restore } = mockMacNoblePlatform(); - const mcConnKey = 'mesh-client:lastConnection:meshcore'; - const mtConnKey = 'mesh-client:lastConnection:meshtastic'; - localStorage.setItem(protocolKey, 'meshtastic'); - localStorage.setItem('mesh-client:lastBleDevice:meshcore', 'meshcore-ble-device'); - localStorage.setItem('mesh-client:lastBleDevice:meshtastic', 'meshtastic-ble-device'); - localStorage.setItem( - mcConnKey, - JSON.stringify({ type: 'ble', bleDeviceId: 'meshcore-ble-device' }), - ); - localStorage.setItem( - mtConnKey, - JSON.stringify({ type: 'ble', bleDeviceId: 'meshtastic-ble-device' }), - ); - const onAutoConnect = vi.fn().mockResolvedValue(undefined); - const dualNoble = await import('../lib/meshcoreDualNobleBleInit'); - dualNoble.resetNobleBleConnectMutexForTests(); - dualNoble.initNobleBleDualRadioStartup(); - let releaseSettle!: () => void; - const settleSpy = vi - .spyOn(dualNoble, 'awaitNobleBlePrimaryAutoConnectSettled') - .mockImplementation( - () => - new Promise((resolve) => { - releaseSettle = resolve; - }), - ); - - try { - render( - , - ); - - await Promise.resolve(); - expect(settleSpy).toHaveBeenCalled(); - expect(onAutoConnect).not.toHaveBeenCalled(); - - releaseSettle(); - await waitFor(() => { - expect(onAutoConnect).toHaveBeenCalledWith( - 'ble', - undefined, - undefined, - 'meshcore-ble-device', - ); - }); - } finally { - localStorage.removeItem(mcConnKey); - localStorage.removeItem(mtConnKey); - localStorage.removeItem('mesh-client:lastBleDevice:meshcore'); - localStorage.removeItem('mesh-client:lastBleDevice:meshtastic'); - dualNoble.resetNobleBleConnectMutexForTests(); - restore(); - } - }); - - // OS-specific: dual-Noble deferred BLE auto-connect (Cancel-before-primary-settle) exists only - // on macOS/Windows Noble. Linux uses Web Bluetooth and skips remembered BLE cold-start in - // useProtocolRfAutoConnect (`!isLinux`) — there is no equivalent dual-radio deferral to cancel. - it.each(['darwin', 'win32'] as const)( - 'cancels deferred meshcore BLE auto-connect when user cancels before primary settle completes (%s)', - async (platform) => { - const { restore } = mockNobleBlePlatform(platform); - const mcConnKey = 'mesh-client:lastConnection:meshcore'; - const mtConnKey = 'mesh-client:lastConnection:meshtastic'; - localStorage.setItem(protocolKey, 'meshtastic'); - localStorage.setItem('mesh-client:lastBleDevice:meshcore', 'meshcore-ble-device'); - localStorage.setItem('mesh-client:lastBleDevice:meshtastic', 'meshtastic-ble-device'); - localStorage.setItem( - mcConnKey, - JSON.stringify({ type: 'ble', bleDeviceId: 'meshcore-ble-device' }), - ); - localStorage.setItem( - mtConnKey, - JSON.stringify({ type: 'ble', bleDeviceId: 'meshtastic-ble-device' }), - ); - const onAutoConnect = vi.fn().mockResolvedValue(undefined); - const dualNoble = await import('../lib/meshcoreDualNobleBleInit'); - dualNoble.resetNobleBleConnectMutexForTests(); - dualNoble.initNobleBleDualRadioStartup(); - let releaseSettle!: () => void; - vi.spyOn(dualNoble, 'awaitNobleBlePrimaryAutoConnectSettled').mockImplementation( - () => - new Promise((resolve) => { - releaseSettle = resolve; - }), - ); - - const user = userEvent.setup(); - try { - render( - , - ); - - // Secondary BLE waits on Meshtastic — connecting UI exposes Cancel. - const cancelBtn = await screen.findByRole('button', { name: /^Cancel$/i }); - await user.click(cancelBtn); - - releaseSettle(); - await Promise.resolve(); - await Promise.resolve(); - expect(onAutoConnect).not.toHaveBeenCalled(); - } finally { - localStorage.removeItem(mcConnKey); - localStorage.removeItem(mtConnKey); - localStorage.removeItem('mesh-client:lastBleDevice:meshcore'); - localStorage.removeItem('mesh-client:lastBleDevice:meshtastic'); - dualNoble.resetNobleBleConnectMutexForTests(); - restore(); - } - }, - ); - +describe('ConnectionPanel ProtocolAutoConnectCoordinator cancel', () => { it('cancels ProtocolAutoConnectCoordinator when user clicks Reconnect with a pending last connection', async () => { const user = userEvent.setup(); const lastConnKey = 'mesh-client:lastConnection:meshtastic'; @@ -1666,7 +1534,6 @@ describe('ConnectionPanel active-protocol-first BLE auto-connect', () => { onDisconnect={vi.fn().mockResolvedValue(undefined)} mqttStatus="disconnected" protocol="meshtastic" - suppressMountAutoConnect />, ); @@ -1687,226 +1554,6 @@ describe('ConnectionPanel active-protocol-first BLE auto-connect', () => { localStorage.removeItem(lastConnKey); } }); - - it('shows shared-peripheral notice when meshcore is active and targets the same BLE device as meshtastic', async () => { - const { restore } = mockMacNoblePlatform(); - const sharedId = 'shared-ble-peripheral'; - const mcConnKey = 'mesh-client:lastConnection:meshcore'; - const mtBleKey = 'mesh-client:lastBleDevice:meshtastic'; - localStorage.setItem(protocolKey, 'meshcore'); - localStorage.setItem(mcConnKey, JSON.stringify({ type: 'ble', bleDeviceId: sharedId })); - localStorage.setItem(mtBleKey, sharedId); - - try { - render( - , - ); - - await waitFor(() => { - expect(screen.getByText(/already connected via Meshtastic/i)).toBeInTheDocument(); - }); - } finally { - localStorage.removeItem(mcConnKey); - localStorage.removeItem(mtBleKey); - localStorage.removeItem(protocolKey); - restore(); - } - }); -}); - -describe('ConnectionPanel BLE auto-connect error humanization', () => { - it('shows macOS wake recovery hint when noble auto-connect fails', async () => { - const { restore } = mockMacNoblePlatform(); - const lastConnKey = 'mesh-client:lastConnection:meshtastic'; - localStorage.setItem( - lastConnKey, - JSON.stringify({ type: 'ble', bleDeviceId: 'ble-known-device' }), - ); - const reconnectModule = await import('../lib/bleReconnectHelper'); - const reconnectSpy = vi - .spyOn(reconnectModule, 'reconnectBleWithScan') - .mockRejectedValue(new Error('BLE connectAsync timed out')); - - try { - render( - , - ); - - await waitFor(() => { - expect(reconnectSpy).toHaveBeenCalled(); - }); - await waitFor(() => { - expect( - screen.getByText(/After sleep, quit mesh-client \(Cmd\+Q\), toggle Bluetooth/i), - ).toBeInTheDocument(); - }); - } finally { - reconnectSpy.mockRestore(); - localStorage.removeItem(lastConnKey); - restore(); - } - }); - - it('does not surface MeshCore setup AbortError as an unhandledrejection or UI error', async () => { - const { restore } = mockMacNoblePlatform(); - const protocolKey = 'mesh-client:protocol'; - const mcConnKey = 'mesh-client:lastConnection:meshcore'; - const mtConnKey = 'mesh-client:lastConnection:meshtastic'; - const mcBleKey = 'mesh-client:lastBleDevice:meshcore'; - const mtBleKey = 'mesh-client:lastBleDevice:meshtastic'; - localStorage.setItem(protocolKey, 'meshcore'); - localStorage.setItem(mcBleKey, 'meshcore-ble'); - localStorage.setItem(mtBleKey, 'meshtastic-ble'); - localStorage.setItem(mcConnKey, JSON.stringify({ type: 'ble', bleDeviceId: 'meshcore-ble' })); - localStorage.setItem(mtConnKey, JSON.stringify({ type: 'ble', bleDeviceId: 'meshtastic-ble' })); - const { MESHCORE_SETUP_ABORT_MESSAGE } = await import('../lib/bleConnectErrors'); - const abort = new DOMException(MESHCORE_SETUP_ABORT_MESSAGE, 'AbortError'); - const onAutoConnect = vi.fn().mockRejectedValue(abort); - const reconnectModule = await import('../lib/bleReconnectHelper'); - // Invoke the connect callback so dual-primary `void attempt.finally` runs (regression for - // unhandledrejection from the finally chain when setup AbortError rejects). - const reconnectSpy = vi - .spyOn(reconnectModule, 'reconnectBleWithScan') - .mockImplementation(async (_protocol, _id, connect) => { - await connect(); - }); - const dualNoble = await import('../lib/meshcoreDualNobleBleInit'); - dualNoble.resetNobleBleConnectMutexForTests(); - dualNoble.initNobleBleDualRadioStartup(); - const unhandled: unknown[] = []; - const onUnhandled = (ev: PromiseRejectionEvent) => { - unhandled.push(ev.reason); - }; - window.addEventListener('unhandledrejection', onUnhandled); - - try { - render( - , - ); - - await waitFor(() => { - expect(onAutoConnect).toHaveBeenCalled(); - }); - await waitFor(() => { - expect(screen.queryByText(/Auto-connect failed/i)).not.toBeInTheDocument(); - }); - await Promise.resolve(); - await Promise.resolve(); - expect(unhandled).toEqual([]); - } finally { - window.removeEventListener('unhandledrejection', onUnhandled); - reconnectSpy.mockRestore(); - dualNoble.resetNobleBleConnectMutexForTests(); - localStorage.removeItem(protocolKey); - localStorage.removeItem(mcConnKey); - localStorage.removeItem(mtConnKey); - localStorage.removeItem(mcBleKey); - localStorage.removeItem(mtBleKey); - restore(); - } - }); -}); - -describe('ConnectionPanel serial auto-connect BLE fallback', () => { - it('falls back to noble BLE scan when serial auto-connect fails and lastBleDevice exists', async () => { - const { restore } = mockMacNoblePlatform(); - const lastConnKey = 'mesh-client:lastConnection:meshcore'; - const lastBleKey = 'mesh-client:lastBleDevice:meshcore'; - localStorage.setItem(lastConnKey, JSON.stringify({ type: 'serial', serialPortId: 'port-1' })); - localStorage.setItem(lastBleKey, 'ble-device-abc'); - const onAutoConnect = vi - .fn() - .mockRejectedValue(new Error('Serial auto-connect failed (radio did not respond)')); - vi.mocked(window.electronAPI.startNobleBleScanning).mockResolvedValue({ ok: true }); - - try { - render( - , - ); - - await waitFor(() => { - expect(onAutoConnect).toHaveBeenCalledWith('serial', undefined, 'port-1'); - }); - await waitFor(() => { - expect(window.electronAPI.startNobleBleScanning).toHaveBeenCalledWith('meshcore'); - }); - - const migrated = JSON.parse(localStorage.getItem(lastConnKey) ?? '{}') as { - type?: string; - bleDeviceId?: string; - }; - expect(migrated.type).toBe('ble'); - expect(migrated.bleDeviceId).toBe('ble-device-abc'); - } finally { - localStorage.removeItem(lastConnKey); - localStorage.removeItem(lastBleKey); - restore(); - } - }); - - it('does not fall back to noble BLE when serial fails but BLE id is shared with Meshtastic', async () => { - const { restore } = mockMacNoblePlatform(); - const sharedId = 'shared-ble-device'; - const lastConnKey = 'mesh-client:lastConnection:meshcore'; - localStorage.setItem(lastConnKey, JSON.stringify({ type: 'serial', serialPortId: 'port-1' })); - localStorage.setItem('mesh-client:lastBleDevice:meshcore', sharedId); - localStorage.setItem('mesh-client:lastBleDevice:meshtastic', sharedId); - const onAutoConnect = vi - .fn() - .mockRejectedValue(new Error('Serial auto-connect failed (radio did not respond)')); - vi.mocked(window.electronAPI.startNobleBleScanning).mockClear(); - - try { - render( - , - ); - - await waitFor(() => { - expect(onAutoConnect).toHaveBeenCalledWith('serial', undefined, 'port-1'); - }); - expect(window.electronAPI.startNobleBleScanning).not.toHaveBeenCalled(); - } finally { - localStorage.removeItem(lastConnKey); - localStorage.removeItem('mesh-client:lastBleDevice:meshcore'); - localStorage.removeItem('mesh-client:lastBleDevice:meshtastic'); - restore(); - } - }); }); describe('ConnectionPanel MeshCore TCP port field', () => { diff --git a/src/renderer/components/ConnectionPanel.tsx b/src/renderer/components/ConnectionPanel.tsx index 7bde33433..1ffff00f5 100644 --- a/src/renderer/components/ConnectionPanel.tsx +++ b/src/renderer/components/ConnectionPanel.tsx @@ -10,14 +10,8 @@ import { ConnectionIcon, MqttGlobeIcon } from '@/renderer/lib/icons/connectionIc import { useParentIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; import { SpinnerIcon, SpinnerIconLg } from '@/renderer/lib/icons/spinnerIcon'; import { - awaitNobleBlePrimaryAutoConnectSettled, - dualNobleBleBothRadiosConfigured, - getNobleBleDualRadioPrimaryProtocol, - initNobleBleDualRadioStartup, - isNobleBleDualRadioSecondary, isRendererNobleBlePlatform, meshcoreTargetsSharedMeshtasticBlePeripheral, - notifyNobleBlePrimaryAutoConnectSettled, } from '@/renderer/lib/meshcoreDualNobleBleInit'; import { markMqttUserDisconnect } from '@/renderer/lib/mqttDisconnectIntent'; import { mqttUsesTls } from '@/renderer/lib/mqttTls'; @@ -66,6 +60,8 @@ import { LETSMESH_HOST_EU, LETSMESH_HOST_US, letsMeshMqttUsernameFromIdentity, + MESHCORE_CA_HOST_BACKUP, + MESHCORE_CA_HOST_PRIMARY, meshcoreIdentityHasFullKeyPair, meshcoreIdentityHasPrivateKey, readMeshcoreIdentity, @@ -74,8 +70,10 @@ import { import { translateMeshcoreUserMessage } from '../lib/meshcore/meshcoreMessageI18n'; import { applyMeshcoreMqttPreset, + isDeviceSigningMeshcorePreset, type MeshcoreMqttPreset, readStoredMeshcoreMqttPreset, + usesMeshcoreDeviceSigningMqtt, } from '../lib/meshcoreMqttPresets'; import { isIataScopedMeshcoreMqtt, @@ -107,8 +105,6 @@ import { parseStoredJson } from '../lib/parseStoredJson'; import { getSerialPortNodeName } from '../lib/serialPortNodeNames'; import { LAST_SERIAL_PORT_KEY } from '../lib/serialPortSignature'; import { isWeakBleRssi } from '../lib/signal'; -import { getStoredMeshProtocol } from '../lib/storedMeshProtocol'; -import { POWER_RESUME_MESHCORE_MESHTASTIC_SETTLE_MS } from '../lib/timeConstants'; import type { ConnectionType, DeviceState, @@ -126,6 +122,7 @@ import ConnectionBatteryGauge from './ConnectionBatteryGauge'; import ConnectionLinkMeter from './ConnectionLinkMeter'; import FirmwareStatusIndicator from './FirmwareStatusIndicator'; import { HelpTooltip } from './HelpTooltip'; +import { MqttNetworkPresetSelect } from './MqttNetworkPresetSelect'; import { ReticulumStackPanel } from './ReticulumStackPanel'; import SignalBars from './SignalBars'; // ─── Last Connection (localStorage) ─────────────────────────────── @@ -186,14 +183,6 @@ const STAGE_LINUX_UNPAIRED = 'connectionPanel.stageLinuxUnpaired'; const STAGE_WAITING_NOBLE_BLE_MESHTASTIC = 'connectionPanel.stageWaitingNobleBleMeshtastic'; const STAGE_WAITING_NOBLE_BLE_MESHCORE = 'connectionPanel.stageWaitingNobleBleMeshcore'; -function resolveBleAutoConnectLabel( - bleId: string, - lc?: LastConnection | null, - fallbackName?: string | null, -): string { - return lc?.bleDeviceName ?? getBleDeviceName(bleId) ?? fallbackName ?? bleId; -} - function resolveConnectionStageText( stage: string, autoConnectTarget: string | null, @@ -217,6 +206,29 @@ function resolveConnectionStageText( return t(stage); } +/** Static-key lookup for the MeshCore device-signing preset deviation banner (keeps i18n scanner happy). */ +function meshcorePresetDeviationText( + t: (key: string) => string, + preset: MeshcoreMqttPreset, +): string { + switch (preset) { + case 'coloradomesh': + return t('connectionPanel.meshcorePresetDeviation.coloradomesh'); + case 'meshmapper': + return t('connectionPanel.meshcorePresetDeviation.meshmapper'); + case 'waev': + return t('connectionPanel.meshcorePresetDeviation.waev'); + case 'meshatse': + return t('connectionPanel.meshcorePresetDeviation.meshatse'); + case 'meshcoreca': + return t('connectionPanel.meshcorePresetDeviation.meshcoreca'); + case 'eastmesh': + return t('connectionPanel.meshcorePresetDeviation.eastmesh'); + default: + return t('connectionPanel.meshcorePresetDeviation.letsmesh'); + } +} + function shouldForgetGrantedWebBluetoothDevice( device: BluetoothDevice, macAddress: string, @@ -350,8 +362,6 @@ interface Props { onOpenAppGpsSettings?: () => void; /** Reticulum: open Admin Bluetooth for USB Clear paired / Start pairing. */ onOpenAdminBluetooth?: () => void; - /** Auto-connect is owned by ProtocolAutoConnectCoordinator when panels are tab-mounted. */ - suppressMountAutoConnect?: boolean; } export default function ConnectionPanel({ @@ -371,7 +381,6 @@ export default function ConnectionPanel({ onOpenReticulumRmapSettings, onOpenAppGpsSettings, onOpenAdminBluetooth, - suppressMountAutoConnect = false, }: Props) { const { t } = useTranslation(); const capabilities = useRadioProvider(protocol); @@ -463,6 +472,9 @@ export default function ConnectionPanel({ const [meshcorePreset, setMeshcorePreset] = useState(() => readStoredMeshcoreMqttPreset(), ); + // Bumped when a preset selection is cancelled (Ripple / Colorado confirm) so the controlled + // { + onSelect(e.target.value); + }} + className="bg-secondary-dark focus:border-brand-green w-full rounded border border-gray-600 px-2 py-1.5 text-xs font-medium text-gray-200 focus:outline-none" + > + {options.map((opt) => ( + + ))} + + ); +} diff --git a/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx b/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx index 3128d5784..84fccf5e4 100644 --- a/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx +++ b/src/renderer/hooks/useProtocolRfAutoConnect.test.tsx @@ -7,6 +7,7 @@ import type { DeviceState } from '@/renderer/lib/types'; const mocks = vi.hoisted(() => ({ loadLastConnection: vi.fn(), loadLastBleDeviceId: vi.fn(), + saveLastConnection: vi.fn(), reconnectBleWithScan: vi.fn(), awaitReticulumBleCoexistenceClear: vi.fn(), dualNobleBleBothRadiosConfigured: vi.fn(), @@ -15,6 +16,7 @@ const mocks = vi.hoisted(() => ({ isRendererNobleBlePlatform: vi.fn(), meshcoreTargetsSharedMeshtasticBlePeripheral: vi.fn(), notifyNobleBlePrimaryAutoConnectSettled: vi.fn(), + awaitNobleBlePrimaryAutoConnectSettled: vi.fn(), tryGetMeshtasticSession: vi.fn(), tryGetMeshcoreSession: vi.fn(), awaitNobleBleProtocolSettle: vi.fn(), @@ -23,7 +25,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/renderer/lib/lastConnectionStorage', () => ({ loadLastConnection: mocks.loadLastConnection, loadLastBleDeviceId: mocks.loadLastBleDeviceId, - saveLastConnection: vi.fn(), + saveLastConnection: mocks.saveLastConnection, })); vi.mock('@/renderer/lib/bleReconnectHelper', () => ({ @@ -35,7 +37,7 @@ vi.mock('@/renderer/lib/reticulum/reticulumStartupAutostartGate', () => ({ })); vi.mock('@/renderer/lib/meshcoreDualNobleBleInit', () => ({ - awaitNobleBlePrimaryAutoConnectSettled: vi.fn().mockResolvedValue(undefined), + awaitNobleBlePrimaryAutoConnectSettled: mocks.awaitNobleBlePrimaryAutoConnectSettled, awaitNobleBleProtocolSettle: mocks.awaitNobleBleProtocolSettle, dualNobleBleBothRadiosConfigured: mocks.dualNobleBleBothRadiosConfigured, getNobleBleDualRadioPrimaryProtocol: mocks.getNobleBleDualRadioPrimaryProtocol, @@ -78,6 +80,7 @@ describe('useProtocolRfAutoConnect cold-start skip paths', () => { mocks.tryGetMeshtasticSession.mockReturnValue({ connectAutomatic: vi.fn() }); mocks.tryGetMeshcoreSession.mockReturnValue({ connectAutomatic: vi.fn() }); mocks.awaitNobleBleProtocolSettle.mockResolvedValue(undefined); + mocks.awaitNobleBlePrimaryAutoConnectSettled.mockResolvedValue(undefined); vi.spyOn(window.electronAPI, 'getPlatform').mockReturnValue('darwin'); }); @@ -230,6 +233,7 @@ describe('useProtocolRfAutoConnect cold-start TCP/HTTP', () => { mocks.tryGetMeshtasticSession.mockReturnValue({ connectAutomatic: vi.fn() }); mocks.tryGetMeshcoreSession.mockReturnValue({ connectAutomatic: vi.fn() }); mocks.awaitNobleBleProtocolSettle.mockResolvedValue(undefined); + mocks.awaitNobleBlePrimaryAutoConnectSettled.mockResolvedValue(undefined); }); afterEach(() => { @@ -337,3 +341,160 @@ describe('useProtocolRfAutoConnect cold-start TCP/HTTP', () => { }, ); }); + +describe('useProtocolRfAutoConnect cold-start serial + BLE', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadLastConnection.mockReturnValue(null); + mocks.loadLastBleDeviceId.mockReturnValue(null); + mocks.reconnectBleWithScan.mockImplementation(async (_p, _id, attempt) => { + await attempt(); + }); + mocks.awaitReticulumBleCoexistenceClear.mockResolvedValue(undefined); + mocks.dualNobleBleBothRadiosConfigured.mockReturnValue(false); + mocks.getNobleBleDualRadioPrimaryProtocol.mockReturnValue(null); + mocks.isNobleBleDualRadioSecondary.mockReturnValue(false); + mocks.isRendererNobleBlePlatform.mockReturnValue(true); + mocks.meshcoreTargetsSharedMeshtasticBlePeripheral.mockReturnValue(false); + mocks.tryGetMeshtasticSession.mockReturnValue({ connectAutomatic: vi.fn() }); + mocks.tryGetMeshcoreSession.mockReturnValue({ connectAutomatic: vi.fn() }); + mocks.awaitNobleBleProtocolSettle.mockResolvedValue(undefined); + mocks.awaitNobleBlePrimaryAutoConnectSettled.mockResolvedValue(undefined); + vi.spyOn(window.electronAPI, 'getPlatform').mockReturnValue('darwin'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('auto-connects remembered serial without Reticulum BLE gate', async () => { + mocks.loadLastConnection.mockReturnValue({ type: 'serial', serialPortId: 'port-1' }); + const connectAutomatic = vi.fn().mockResolvedValue(undefined); + + renderHook(() => { + useProtocolRfAutoConnect({ + protocol: 'meshtastic', + state: disconnected, + connectAutomatic, + }); + }); + + await waitFor(() => { + expect(connectAutomatic).toHaveBeenCalledWith('serial', undefined, 'port-1'); + }); + expect(mocks.awaitReticulumBleCoexistenceClear).not.toHaveBeenCalled(); + }); + + it('falls back to Noble BLE when serial auto-connect fails and lastBleDevice exists', async () => { + mocks.loadLastConnection.mockReturnValue({ + type: 'serial', + serialPortId: 'port-1', + bleDeviceId: 'ble-fallback', + bleDeviceName: 'Radio', + }); + mocks.loadLastBleDeviceId.mockReturnValue('ble-fallback'); + const connectAutomatic = vi + .fn() + .mockRejectedValueOnce(new Error('Serial auto-connect failed')) + .mockResolvedValue(undefined); + + renderHook(() => { + useProtocolRfAutoConnect({ + protocol: 'meshcore', + state: disconnected, + connectAutomatic, + }); + }); + + await waitFor(() => { + expect(mocks.saveLastConnection).toHaveBeenCalledWith( + 'meshcore', + expect.objectContaining({ type: 'ble', bleDeviceId: 'ble-fallback' }), + ); + }); + await waitFor(() => { + expect(connectAutomatic).toHaveBeenCalledWith('ble', undefined, undefined, 'ble-fallback'); + }); + expect(mocks.awaitReticulumBleCoexistenceClear).toHaveBeenCalled(); + }); + + it.each(['darwin', 'win32'] as const)( + 'auto-connects primary Noble BLE happy path (%s)', + async (platform) => { + vi.spyOn(window.electronAPI, 'getPlatform').mockReturnValue(platform); + mocks.loadLastConnection.mockReturnValue({ type: 'ble', bleDeviceId: 'primary-ble' }); + const connectAutomatic = vi.fn().mockResolvedValue(undefined); + + renderHook(() => { + useProtocolRfAutoConnect({ + protocol: 'meshtastic', + state: disconnected, + connectAutomatic, + }); + }); + + await waitFor(() => { + expect(connectAutomatic).toHaveBeenCalledWith('ble', undefined, undefined, 'primary-ble'); + }); + expect(mocks.awaitReticulumBleCoexistenceClear).toHaveBeenCalled(); + expect(mocks.awaitNobleBlePrimaryAutoConnectSettled).not.toHaveBeenCalled(); + }, + ); + + it('skips remembered BLE cold-start on Linux', async () => { + vi.spyOn(window.electronAPI, 'getPlatform').mockReturnValue('linux'); + mocks.loadLastConnection.mockReturnValue({ type: 'ble', bleDeviceId: 'linux-ble' }); + const connectAutomatic = vi.fn(); + + renderHook(() => { + useProtocolRfAutoConnect({ + protocol: 'meshtastic', + state: disconnected, + connectAutomatic, + }); + }); + + await waitFor(() => { + expect(mocks.tryGetMeshtasticSession).toHaveBeenCalled(); + }); + expect(connectAutomatic).not.toHaveBeenCalled(); + expect(mocks.awaitReticulumBleCoexistenceClear).not.toHaveBeenCalled(); + }); + + it.each(['darwin', 'win32'] as const)( + 'secondary waits for primary settle then protocol settle before BLE connect (%s)', + async (platform) => { + vi.spyOn(window.electronAPI, 'getPlatform').mockReturnValue(platform); + mocks.loadLastConnection.mockReturnValue({ type: 'ble', bleDeviceId: 'secondary-ble' }); + mocks.dualNobleBleBothRadiosConfigured.mockReturnValue(true); + mocks.isNobleBleDualRadioSecondary.mockReturnValue(true); + mocks.getNobleBleDualRadioPrimaryProtocol.mockReturnValue('meshtastic'); + const connectAutomatic = vi.fn().mockResolvedValue(undefined); + + renderHook(() => { + useProtocolRfAutoConnect({ + protocol: 'meshcore', + state: disconnected, + connectAutomatic, + }); + }); + + await waitFor(() => { + expect(connectAutomatic).toHaveBeenCalledWith('ble', undefined, undefined, 'secondary-ble'); + }); + expect(mocks.awaitNobleBlePrimaryAutoConnectSettled).toHaveBeenCalled(); + expect(mocks.awaitNobleBleProtocolSettle).toHaveBeenCalledWith( + 'meshtastic', + expect.any(Number), + ); + + // Order matters: primary settle → protocol settle → BLE connect. Assert the actual call + // sequence, not merely that each was invoked. + const primaryOrder = mocks.awaitNobleBlePrimaryAutoConnectSettled.mock.invocationCallOrder[0]; + const protocolOrder = mocks.awaitNobleBleProtocolSettle.mock.invocationCallOrder[0]; + const connectOrder = connectAutomatic.mock.invocationCallOrder[0]; + expect(primaryOrder).toBeLessThan(protocolOrder); + expect(protocolOrder).toBeLessThan(connectOrder); + }, + ); +}); diff --git a/src/renderer/lib/confettiBurst.test.ts b/src/renderer/lib/confettiBurst.test.ts new file mode 100644 index 000000000..a3db22573 --- /dev/null +++ b/src/renderer/lib/confettiBurst.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + burstConfetti, + clampConfettiCount, + clampConfettiDuration, + isConfettiActive, + shouldSkipConfetti, +} from './confettiBurst'; + +function makeFakeCtx(): CanvasRenderingContext2D { + const noop = () => {}; + return { + clearRect: noop, + save: noop, + restore: noop, + translate: noop, + rotate: noop, + fillRect: noop, + beginPath: noop, + arc: noop, + fill: noop, + globalAlpha: 1, + fillStyle: '#000', + } as unknown as CanvasRenderingContext2D; +} + +describe('clampConfettiCount', () => { + it('defaults and clamps to 10..80', () => { + expect(clampConfettiCount(undefined)).toBe(40); + expect(clampConfettiCount(1)).toBe(10); + expect(clampConfettiCount(1000)).toBe(80); + expect(clampConfettiCount(Number.NaN)).toBe(40); + expect(clampConfettiCount(50)).toBe(50); + }); +}); + +describe('clampConfettiDuration', () => { + it('defaults and clamps to 600..4000', () => { + expect(clampConfettiDuration(undefined)).toBe(1600); + expect(clampConfettiDuration(10)).toBe(600); + expect(clampConfettiDuration(99999)).toBe(4000); + expect(clampConfettiDuration(Number.NaN)).toBe(1600); + expect(clampConfettiDuration(2000)).toBe(2000); + }); +}); + +describe('shouldSkipConfetti', () => { + afterEach(() => { + delete document.documentElement.dataset.reduceMotion; + vi.unstubAllGlobals(); + }); + + it('skips when the reduce-motion document flag is set', () => { + document.documentElement.dataset.reduceMotion = 'true'; + expect(shouldSkipConfetti()).toBe(true); + }); + + it('skips when OS prefers reduced motion', () => { + vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches: true })); + expect(shouldSkipConfetti()).toBe(true); + }); + + it('does not skip by default', () => { + vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches: false })); + expect(shouldSkipConfetti()).toBe(false); + }); +}); + +describe('burstConfetti', () => { + let rafCb: FrameRequestCallback | null; + + beforeEach(() => { + rafCb = null; + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((cb: FrameRequestCallback) => { + rafCb = cb; + return 1; + }), + ); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(makeFakeCtx()); + vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches: false })); + }); + + afterEach(() => { + // Drive any in-flight animation to completion so single-flight state resets. + rafCb?.(1_000_000); + rafCb = null; + document.querySelectorAll('canvas').forEach((c) => { + c.remove(); + }); + delete document.documentElement.dataset.reduceMotion; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('appends a canvas and starts animating', () => { + burstConfetti({ x: 100, y: 100 }); + expect(document.querySelectorAll('canvas')).toHaveLength(1); + expect(isConfettiActive()).toBe(true); + expect(requestAnimationFrame).toHaveBeenCalledTimes(1); + }); + + it('is single-flight: a second call while active is a no-op', () => { + burstConfetti(); + burstConfetti(); + expect(document.querySelectorAll('canvas')).toHaveLength(1); + expect(requestAnimationFrame).toHaveBeenCalledTimes(1); + }); + + it('removes the canvas and clears active state when the burst ends', () => { + burstConfetti(); + expect(isConfettiActive()).toBe(true); + rafCb?.(1_000_000); + expect(document.querySelectorAll('canvas')).toHaveLength(0); + expect(isConfettiActive()).toBe(false); + }); + + it('does nothing under reduced motion', () => { + document.documentElement.dataset.reduceMotion = 'true'; + burstConfetti(); + expect(document.querySelectorAll('canvas')).toHaveLength(0); + expect(isConfettiActive()).toBe(false); + expect(requestAnimationFrame).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/lib/confettiBurst.ts b/src/renderer/lib/confettiBurst.ts new file mode 100644 index 000000000..4534e3b45 --- /dev/null +++ b/src/renderer/lib/confettiBurst.ts @@ -0,0 +1,199 @@ +/** + * Lightweight canvas confetti burst — no external deps. + * + * Ported in spirit from Ratspeak's `dashboard/static/js/confetti.js` (`rsConfetti`): + * a fixed, full-viewport, pointer-transparent canvas that fires a short particle + * burst and removes itself when done. Single-flight so overlapping calls (e.g. a + * rapid win + re-render) cannot stack canvases. + * + * Skips entirely when the user prefers reduced motion (app setting via the + * `data-reduce-motion` document flag, or OS `prefers-reduced-motion`). + */ + +export interface ConfettiBurstOptions { + /** Origin x in viewport px. Defaults to horizontal center. */ + x?: number; + /** Origin y in viewport px. Defaults to ~upper third (h / 2.4). */ + y?: number; + /** Particle colors (CSS color strings). Defaults to theme greens + accents. */ + colors?: string[]; + /** Particle count (clamped 10..80). Default 40. */ + count?: number; + /** Lifetime in ms (clamped 600..4000). Default 1600. */ + duration?: number; +} + +const COUNT_MIN = 10; +const COUNT_MAX = 80; +const COUNT_DEFAULT = 40; +const DURATION_MIN = 600; +const DURATION_MAX = 4000; +const DURATION_DEFAULT = 1600; + +const GRAVITY = 0.18; +const DRAG = 0.992; +const CANVAS_Z_INDEX = 10_000; + +let active = false; + +interface Particle { + x: number; + y: number; + vx: number; + vy: number; + rot: number; + vr: number; + size: number; + color: string; + shape: 0 | 1; +} + +/** Clamp requested particle count into the safe range. */ +export function clampConfettiCount(count: number | undefined): number { + const n = typeof count === 'number' && Number.isFinite(count) ? count : COUNT_DEFAULT; + return Math.max(COUNT_MIN, Math.min(COUNT_MAX, Math.round(n))); +} + +/** Clamp requested duration (ms) into the safe range. */ +export function clampConfettiDuration(duration: number | undefined): number { + const ms = + typeof duration === 'number' && Number.isFinite(duration) ? duration : DURATION_DEFAULT; + return Math.max(DURATION_MIN, Math.min(DURATION_MAX, Math.round(ms))); +} + +/** True when motion should be suppressed (app reduce-motion flag or OS preference). */ +export function shouldSkipConfetti(): boolean { + if (typeof document !== 'undefined' && document.documentElement.dataset.reduceMotion === 'true') { + return true; + } + if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { + try { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return true; + } catch { + // catch-no-log-ok matchMedia can throw in restricted/test environments + } + } + return false; +} + +/** Resolve default palette from theme CSS custom properties, with hard fallbacks. */ +function resolveColors(custom: string[] | undefined): string[] { + if (custom && custom.length > 0) return custom.slice(); + const fallback = ['#86efac', '#15803d', '#0e9aa7', '#d4a72c', '#ffffff']; + if (typeof document === 'undefined' || typeof getComputedStyle !== 'function') { + return fallback; + } + try { + const cs = getComputedStyle(document.documentElement); + const pick = (name: string, fb: string): string => { + const v = cs.getPropertyValue(name).trim(); + return v.length > 0 ? v : fb; + }; + return [ + pick('--color-brand-green', '#86efac'), + pick('--color-readable-green', '#15803d'), + pick('--color-bright-green', '#9ae6b4'), + '#d4a72c', + '#ffffff', + ]; + } catch { + // catch-no-log-ok getComputedStyle unavailable; use fallback palette + return fallback; + } +} + +/** True while a burst is animating (test/introspection helper). */ +export function isConfettiActive(): boolean { + return active; +} + +/** + * Fire a one-shot confetti burst. No-op when reduced motion is preferred, when a + * burst is already in flight, or when no canvas 2D context is available. + * + * @returns `true` when a burst actually started, `false` when it was skipped (single-flight, + * reduced motion, or no canvas). Callers can retry a `false` caused by single-flight once the + * active burst finishes. + */ +export function burstConfetti(opts: ConfettiBurstOptions = {}): boolean { + if (active) return false; + if (typeof document === 'undefined') return false; + if (typeof requestAnimationFrame !== 'function') return false; + if (shouldSkipConfetti()) return false; + + const w = typeof window !== 'undefined' ? window.innerWidth : 0; + const h = typeof window !== 'undefined' ? window.innerHeight : 0; + if (w <= 0 || h <= 0) return false; + + const count = clampConfettiCount(opts.count); + const duration = clampConfettiDuration(opts.duration); + const colors = resolveColors(opts.colors); + const originX = typeof opts.x === 'number' && Number.isFinite(opts.x) ? opts.x : w / 2; + const originY = typeof opts.y === 'number' && Number.isFinite(opts.y) ? opts.y : h / 2.4; + + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + canvas.style.cssText = `position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:${CANVAS_Z_INDEX};`; + canvas.setAttribute('aria-hidden', 'true'); + + const ctx = canvas.getContext('2d'); + if (!ctx) return false; + + document.body.appendChild(canvas); + active = true; + + const particles: Particle[] = []; + for (let i = 0; i < count; i++) { + const angle = Math.PI * 2 * (i / count) + (Math.random() - 0.5) * 0.6; + const speed = 4 + Math.random() * 5; + particles.push({ + x: originX, + y: originY, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed - 3, + rot: Math.random() * Math.PI, + vr: (Math.random() - 0.5) * 0.3, + size: 6 + Math.random() * 5, + color: colors[i % colors.length], + shape: (i % 2) as 0 | 1, + }); + } + + const startedAt = performance.now(); + + const frame = (now: number): void => { + const elapsed = now - startedAt; + const life = 1 - elapsed / duration; + if (life <= 0) { + canvas.parentNode?.removeChild(canvas); + active = false; + return; + } + ctx.clearRect(0, 0, w, h); + ctx.globalAlpha = Math.max(0, Math.min(1, life * 1.2)); + for (const p of particles) { + p.vx *= DRAG; + p.vy = p.vy * DRAG + GRAVITY; + p.x += p.vx; + p.y += p.vy; + p.rot += p.vr; + ctx.save(); + ctx.translate(p.x, p.y); + ctx.rotate(p.rot); + ctx.fillStyle = p.color; + if (p.shape === 0) { + ctx.fillRect(-p.size / 2, -p.size / 3, p.size, p.size * 0.6); + } else { + ctx.beginPath(); + ctx.arc(0, 0, p.size / 2, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + requestAnimationFrame(frame); + }; + + requestAnimationFrame(frame); + return true; +} diff --git a/src/renderer/lib/connectionPanelStorageMigrations.test.ts b/src/renderer/lib/connectionPanelStorageMigrations.test.ts index cae6434ef..62995a916 100644 --- a/src/renderer/lib/connectionPanelStorageMigrations.test.ts +++ b/src/renderer/lib/connectionPanelStorageMigrations.test.ts @@ -3,16 +3,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { COLORADO_MESH_PORT_MIGRATION_KEY, + COLORADO_MQTT_REGION_ACK_KEY, LEGACY_MQTT_SETTINGS_KEY, MESHCORE_LETSMESH_DEFAULT_MIGRATION_KEY, MESHCORE_MQTT_SETTINGS_KEY, MESHCORE_TOPIC_IATA_MIGRATION_KEY, MESHCORE_TOPIC_IATA_SHAPE_MIGRATION_KEY, + meshcoreMqttNeedsColoradoRegionAck, MESHMAPPER_HOST_LEGACY_CC, MESHMAPPER_HOST_NET_MIGRATION_KEY, runConnectionPanelStorageMigrations, } from './connectionPanelStorageMigrations'; -import { COLORADO_MESH_HOST, LETSMESH_HOST_US, MESHMAPPER_HOST } from './letsMeshJwt'; +import { COLORADO_MESH_HOST, LETSMESH_HOST_US, MESHMAPPER_HOST, WAEV_HOST } from './letsMeshJwt'; const store = new Map(); @@ -141,6 +143,82 @@ describe('runConnectionPanelStorageMigrations', () => { expect(parsed.autoLaunch).toBe(true); }); + it('reconciles stale Waev preset settings to the WSS device-signing defaults', () => { + localStorage.setItem('mesh-client:mqttPreset:meshcore', 'waev'); + localStorage.setItem( + MESHCORE_MQTT_SETTINGS_KEY, + JSON.stringify({ + server: WAEV_HOST, + topicPrefix: 'meshcore/test', + port: 1883, + useWebSocket: false, + tlsEnabled: false, + autoLaunch: true, + }), + ); + // Skip unrelated one-time migrations so only the preset reconcile runs. + localStorage.setItem(MESHCORE_TOPIC_IATA_MIGRATION_KEY, '1'); + localStorage.setItem(COLORADO_MESH_PORT_MIGRATION_KEY, '1'); + localStorage.setItem(MESHCORE_TOPIC_IATA_SHAPE_MIGRATION_KEY, '1'); + localStorage.setItem(MESHCORE_LETSMESH_DEFAULT_MIGRATION_KEY, '1'); + localStorage.setItem(MESHMAPPER_HOST_NET_MIGRATION_KEY, '1'); + + runConnectionPanelStorageMigrations(); + + const parsed = JSON.parse(localStorage.getItem(MESHCORE_MQTT_SETTINGS_KEY) ?? '{}') as { + server?: string; + port?: number; + useWebSocket?: boolean; + tlsEnabled?: boolean; + wsPath?: string; + autoLaunch?: boolean; + }; + expect(parsed.server).toBe(WAEV_HOST); + expect(parsed.port).toBe(443); + expect(parsed.useWebSocket).toBe(true); + expect(parsed.tlsEnabled).toBe(true); + expect(parsed.wsPath).toBe('/mqtt'); + expect(parsed.autoLaunch).toBe(true); + }); + + it('preserves a manually stored password while reconciling Waev transport fields', () => { + localStorage.setItem('mesh-client:mqttPreset:meshcore', 'waev'); + localStorage.setItem( + MESHCORE_MQTT_SETTINGS_KEY, + JSON.stringify({ + server: WAEV_HOST, + topicPrefix: 'meshcore/test', + port: 1883, // stale transport → triggers reconcile + useWebSocket: false, + tlsEnabled: false, + username: 'v1_' + 'a'.repeat(64), + password: 'my-manual-secret', + autoLaunch: true, + }), + ); + // Skip unrelated one-time migrations so only the preset reconcile runs. + localStorage.setItem(MESHCORE_TOPIC_IATA_MIGRATION_KEY, '1'); + localStorage.setItem(COLORADO_MESH_PORT_MIGRATION_KEY, '1'); + localStorage.setItem(MESHCORE_TOPIC_IATA_SHAPE_MIGRATION_KEY, '1'); + localStorage.setItem(MESHCORE_LETSMESH_DEFAULT_MIGRATION_KEY, '1'); + localStorage.setItem(MESHMAPPER_HOST_NET_MIGRATION_KEY, '1'); + + runConnectionPanelStorageMigrations(); + + const parsed = JSON.parse(localStorage.getItem(MESHCORE_MQTT_SETTINGS_KEY) ?? '{}') as { + port?: number; + tlsEnabled?: boolean; + password?: string; + username?: string; + }; + // Transport fields reconciled... + expect(parsed.port).toBe(443); + expect(parsed.tlsEnabled).toBe(true); + // ...but the user's manual credentials are left untouched. + expect(parsed.password).toBe('my-manual-secret'); + expect(parsed.username).toBe('v1_' + 'a'.repeat(64)); + }); + it('does not overwrite custom preset settings', () => { localStorage.setItem('mesh-client:mqttPreset:meshcore', 'custom'); const custom = JSON.stringify({ @@ -304,3 +382,34 @@ describe('runConnectionPanelStorageMigrations', () => { expect(parsed.server).toBe(MESHMAPPER_HOST); }); }); + +describe('meshcoreMqttNeedsColoradoRegionAck', () => { + it('is false once the region ack key is set', () => { + localStorage.setItem(COLORADO_MQTT_REGION_ACK_KEY, '1'); + localStorage.setItem('mesh-client:mqttPreset:meshcore', 'coloradomesh'); + expect(meshcoreMqttNeedsColoradoRegionAck()).toBe(false); + }); + + it('is true for the Colorado preset before the gate is answered', () => { + localStorage.setItem('mesh-client:mqttPreset:meshcore', 'coloradomesh'); + expect(meshcoreMqttNeedsColoradoRegionAck()).toBe(true); + }); + + it('is true when a custom preset points at the Colorado host', () => { + localStorage.setItem('mesh-client:mqttPreset:meshcore', 'custom'); + localStorage.setItem( + MESHCORE_MQTT_SETTINGS_KEY, + JSON.stringify({ server: COLORADO_MESH_HOST, topicPrefix: 'meshcore/DEN', port: 443 }), + ); + expect(meshcoreMqttNeedsColoradoRegionAck()).toBe(true); + }); + + it('is false for a non-Colorado device-signing preset', () => { + localStorage.setItem('mesh-client:mqttPreset:meshcore', 'letsmesh'); + localStorage.setItem( + MESHCORE_MQTT_SETTINGS_KEY, + JSON.stringify({ server: LETSMESH_HOST_US, topicPrefix: 'meshcore/test', port: 443 }), + ); + expect(meshcoreMqttNeedsColoradoRegionAck()).toBe(false); + }); +}); diff --git a/src/renderer/lib/connectionPanelStorageMigrations.ts b/src/renderer/lib/connectionPanelStorageMigrations.ts index b4fb34fb1..065d4f396 100644 --- a/src/renderer/lib/connectionPanelStorageMigrations.ts +++ b/src/renderer/lib/connectionPanelStorageMigrations.ts @@ -7,6 +7,7 @@ import { } from './letsMeshJwt'; import { applyMeshcoreMqttPreset, + DEVICE_SIGNING_MESHCORE_PRESETS, MESHCORE_MQTT_PRESET_STORAGE_KEY, type MeshcoreMqttPreset, meshcoreMqttPresetFields, @@ -34,14 +35,30 @@ export function meshcoreMqttNeedsColoradoRegionAck(): boolean { return readMeshcoreMqttSettingsFromStorage().server.trim() === COLORADO_MESH_HOST; } -const PRESET_RECONCILE_PRESETS = new Set([ - 'letsmesh', - 'coloradomesh', - 'meshmapper', -]); +const PRESET_RECONCILE_PRESETS = DEVICE_SIGNING_MESHCORE_PRESETS; -function meshcorePresetFieldsDiffer(preset: MeshcoreMqttPreset, settings: MQTTSettings): boolean { +/** + * Preset fields that startup reconcile is allowed to rewrite. Only transport fields are reconciled; + * authentication fields (a manually stored password / username) are preserved so re-applying a + * preset's transport defaults never clobbers user-entered credentials. + */ +function reconcilablePresetFields( + preset: MeshcoreMqttPreset, + settings: MQTTSettings, +): Partial | null { const fields = meshcoreMqttPresetFields(preset, settings); + if (!fields) return null; + const transport: Partial = { ...fields }; + delete transport.password; + delete transport.username; + return transport; +} + +function meshcorePresetTransportDiffers( + preset: MeshcoreMqttPreset, + settings: MQTTSettings, +): boolean { + const fields = reconcilablePresetFields(preset, settings); if (!fields) return false; return (Object.keys(fields) as (keyof MQTTSettings)[]).some( (key) => settings[key] !== fields[key], @@ -183,9 +200,11 @@ function reconcileMeshcoreMqttPresetSettings(): void { ? parseStoredJson>(raw, 'reconcileMeshcoreMqttPresetSettings') : null; const current = (parsed ?? {}) as MQTTSettings; - if (!meshcorePresetFieldsDiffer(preset, current)) return; + const transport = reconcilablePresetFields(preset, current); + if (!transport || !meshcorePresetTransportDiffers(preset, current)) return; - const next = applyMeshcoreMqttPreset(preset, current); + // Merge only transport fields; a manually stored password / username is left untouched. + const next: MQTTSettings = { ...current, ...transport }; localStorage.setItem(MESHCORE_MQTT_SETTINGS_KEY, JSON.stringify(next)); } diff --git a/src/renderer/lib/letsMeshConnectionGuards.test.ts b/src/renderer/lib/letsMeshConnectionGuards.test.ts index 48416162a..ef5183b67 100644 --- a/src/renderer/lib/letsMeshConnectionGuards.test.ts +++ b/src/renderer/lib/letsMeshConnectionGuards.test.ts @@ -2,14 +2,18 @@ import { describe, expect, it, vi } from 'vitest'; vi.mock('./i18n', () => ({ default: { - t: (key: string, opts?: { port?: number }) => { + t: (key: string, opts?: { port?: number; wsPath?: string }) => { switch (key) { case 'connectionPanel.letsMeshRequiresWebSocket': - return 'LetsMesh requires WebSocket transport.'; + return 'This broker preset requires WebSocket transport (wss).'; case 'connectionPanel.letsMeshRequiresPort': - return `LetsMesh requires port ${opts?.port ?? ''}.`; + return `This broker preset requires port ${opts?.port ?? ''} (WebSocket TLS).`; + case 'connectionPanel.letsMeshRequiresTls': + return 'This broker preset requires TLS (wss). Enable TLS or switch to Custom.'; + case 'connectionPanel.letsMeshRequiresWsPath': + return `This broker preset requires WebSocket path ${opts?.wsPath ?? ''}. Reset the preset or switch to Custom.`; case 'connectionPanel.letsMeshKnownBrokersOnly': - return 'LetsMesh / MeshMapper preset only supports known device-signing brokers. Use Custom for other brokers.'; + return 'This preset only supports known public MeshCore MQTT brokers. Switch to Custom for other brokers.'; case 'connectionPanel.letsMeshUsernameV1Hex': return 'Username must be v1_ followed by 64 hex characters (public key).'; default: @@ -24,7 +28,15 @@ import { validateLetsMeshManualCredentials, validateLetsMeshPresetConnect, } from './letsMeshConnectionGuards'; -import { LETSMESH_HOST_US, MESHMAPPER_HOST } from './letsMeshJwt'; +import { + EASTMESH_HOST, + LETSMESH_HOST_US, + MESHATSE_HOST, + MESHCORE_CA_HOST_BACKUP, + MESHCORE_CA_HOST_PRIMARY, + MESHMAPPER_HOST, + WAEV_HOST, +} from './letsMeshJwt'; import type { MQTTSettings } from './types'; const base: MQTTSettings = { @@ -35,6 +47,8 @@ const base: MQTTSettings = { topicPrefix: 'meshcore', autoLaunch: false, useWebSocket: true, + tlsEnabled: true, + wsPath: '/ws', }; describe('validateLetsMeshPresetConnect', () => { @@ -77,6 +91,48 @@ describe('validateLetsMeshPresetConnect', () => { }), ).toBeNull(); }); + + it.each([ + [WAEV_HOST], + [MESHATSE_HOST], + [MESHCORE_CA_HOST_PRIMARY], + [MESHCORE_CA_HOST_BACKUP], + [EASTMESH_HOST], + ])('accepts device-signing host %s with its /mqtt path', (server) => { + expect(validateLetsMeshPresetConnect({ ...base, server, wsPath: '/mqtt' })).toBeNull(); + }); + + it('rejects TLS disabled', () => { + expect( + validateLetsMeshPresetConnect({ + ...base, + tlsEnabled: false, + }), + ).toContain('TLS'); + }); + + it('rejects wrong wsPath for /mqtt broker', () => { + expect( + validateLetsMeshPresetConnect({ + ...base, + server: WAEV_HOST, + wsPath: '/ws', + }), + ).toContain('/mqtt'); + }); + + it('rejects wrong wsPath for /ws broker', () => { + expect( + validateLetsMeshPresetConnect({ + ...base, + wsPath: '/mqtt', + }), + ).toContain('/ws'); + }); + + it('rejects an omitted wsPath for a /ws broker (stale settings)', () => { + expect(validateLetsMeshPresetConnect({ ...base, wsPath: undefined })).toContain('/ws'); + }); }); describe('validateLetsMeshManualCredentials', () => { @@ -120,4 +176,28 @@ describe('letsMeshPresetConfigurationDeviation', () => { it('is true when WebSocket off', () => { expect(letsMeshPresetConfigurationDeviation({ ...base, useWebSocket: false })).toBe(true); }); + + it('is true when TLS off', () => { + expect(letsMeshPresetConfigurationDeviation({ ...base, tlsEnabled: false })).toBe(true); + }); + + it('is true when wsPath does not match the broker', () => { + expect( + letsMeshPresetConfigurationDeviation({ ...base, server: WAEV_HOST, wsPath: '/ws' }), + ).toBe(true); + }); + + it('is false for a /mqtt broker with its matching path', () => { + expect( + letsMeshPresetConfigurationDeviation({ ...base, server: WAEV_HOST, wsPath: '/mqtt' }), + ).toBe(false); + }); + + it('accepts LetsMesh region keepalive of 60', () => { + expect(letsMeshPresetConfigurationDeviation({ ...base, keepalive: 60 })).toBe(false); + }); + + it('is true for an unexpected keepalive', () => { + expect(letsMeshPresetConfigurationDeviation({ ...base, keepalive: 90 })).toBe(true); + }); }); diff --git a/src/renderer/lib/letsMeshConnectionGuards.ts b/src/renderer/lib/letsMeshConnectionGuards.ts index e871980c1..84d54369c 100644 --- a/src/renderer/lib/letsMeshConnectionGuards.ts +++ b/src/renderer/lib/letsMeshConnectionGuards.ts @@ -1,10 +1,14 @@ import i18n from './i18n'; -import { isLetsMeshSettings } from './letsMeshJwt'; +import { deviceSigningWsPathForHost } from './letsMeshJwt'; import type { MQTTSettings } from './types'; const DEVICE_SIGNING_BROKER_PORT = 443; -/** Hard validation before connecting with the LetsMesh preset (public US/EU brokers only). */ +/** + * Hard validation before connecting with a device-signing MeshCore MQTT preset + * (LetsMesh / MeshMapper / Colorado / Waev / Meshat.se / MeshCore.CA / EastMesh). + * Requires WebSocket + TLS on 443 and the broker's expected `wsPath` (`/ws` vs `/mqtt`). + */ export function validateLetsMeshPresetConnect(settings: MQTTSettings): string | null { if (!(settings.useWebSocket ?? false)) { return i18n.t('connectionPanel.letsMeshRequiresWebSocket'); @@ -12,9 +16,16 @@ export function validateLetsMeshPresetConnect(settings: MQTTSettings): string | if (settings.port !== DEVICE_SIGNING_BROKER_PORT) { return i18n.t('connectionPanel.letsMeshRequiresPort', { port: DEVICE_SIGNING_BROKER_PORT }); } - if (!isLetsMeshSettings(settings.server)) { + const expectedWsPath = deviceSigningWsPathForHost(settings.server); + if (!expectedWsPath) { return i18n.t('connectionPanel.letsMeshKnownBrokersOnly'); } + if (!(settings.tlsEnabled ?? false)) { + return i18n.t('connectionPanel.letsMeshRequiresTls'); + } + if ((settings.wsPath?.trim() ?? '') !== expectedWsPath) { + return i18n.t('connectionPanel.letsMeshRequiresWsPath', { wsPath: expectedWsPath }); + } return null; } @@ -31,11 +42,20 @@ export function validateLetsMeshManualCredentials(settings: MQTTSettings): strin return null; } -/** True if current fields diverge from what the public LetsMesh brokers need. */ +/** + * True if current fields diverge from what the active device-signing broker needs. + * Host-aware: the expected `wsPath` follows the configured broker (`/ws` vs `/mqtt`), so a + * mismatched path or host surfaces the deviation banner. LetsMesh region tuning may use a 60s + * keepalive, so both 30 and 60 are accepted. + */ export function letsMeshPresetConfigurationDeviation(settings: MQTTSettings): boolean { if (!(settings.useWebSocket ?? false)) return true; if (settings.port !== DEVICE_SIGNING_BROKER_PORT) return true; - if (!isLetsMeshSettings(settings.server)) return true; - if ((settings.keepalive ?? 30) !== 30) return true; + if (!(settings.tlsEnabled ?? false)) return true; + const expectedWsPath = deviceSigningWsPathForHost(settings.server); + if (!expectedWsPath) return true; + if ((settings.wsPath?.trim() ?? '') !== expectedWsPath) return true; + const keepalive = settings.keepalive ?? 30; + if (keepalive !== 30 && keepalive !== 60) return true; return false; } diff --git a/src/renderer/lib/letsMeshJwt.test.ts b/src/renderer/lib/letsMeshJwt.test.ts index 2e741447d..7afa691b7 100644 --- a/src/renderer/lib/letsMeshJwt.test.ts +++ b/src/renderer/lib/letsMeshJwt.test.ts @@ -2,12 +2,17 @@ import { Utils, verifyAuthToken } from '@michaelhart/meshcore-decoder'; import { beforeAll, describe, expect, it, vi } from 'vitest'; import { + deviceSigningWsPathForHost, + EASTMESH_HOST, generateLetsMeshAuthToken, isLetsMeshSettings, LETSMESH_HOST_EU, LETSMESH_HOST_US, letsMeshJwtAudience, letsMeshMqttUsernameFromIdentity, + MESHATSE_HOST, + MESHCORE_CA_HOST_BACKUP, + MESHCORE_CA_HOST_PRIMARY, MESHCORE_ENC_PK_KEY, MESHCORE_IDENTITY_STORAGE_KEY, MESHCORE_PUBLIC_KEY_LENGTH, @@ -18,6 +23,7 @@ import { readMeshcoreIdentity, tryPersistMeshcoreIdentityFromRadioExport, tryPersistMeshcorePublicKeyFromRadio, + WAEV_HOST, } from './letsMeshJwt'; import { meshcoreSyntheticPlaceholderPubKeyHex } from './meshcoreUtils'; @@ -56,6 +62,19 @@ describe('letsMeshJwt', () => { expect(isLetsMeshSettings(MESHMAPPER_HOST_LEGACY_CC)).toBe(true); }); + it('isLetsMeshSettings matches the newly added device-signing hosts', () => { + expect(isLetsMeshSettings(WAEV_HOST)).toBe(true); + expect(isLetsMeshSettings(MESHATSE_HOST)).toBe(true); + expect(isLetsMeshSettings(MESHCORE_CA_HOST_PRIMARY)).toBe(true); + expect(isLetsMeshSettings(MESHCORE_CA_HOST_BACKUP)).toBe(true); + expect(isLetsMeshSettings(EASTMESH_HOST)).toBe(true); + expect(WAEV_HOST).toBe('mqtt.waev.app'); + expect(MESHATSE_HOST).toBe('meshcore-mqtt.meshat.se'); + expect(MESHCORE_CA_HOST_PRIMARY).toBe('mqtt1.meshcore.ca'); + expect(MESHCORE_CA_HOST_BACKUP).toBe('mqtt2.meshcore.ca'); + expect(EASTMESH_HOST).toBe('mqtt2.eastmesh.au'); + }); + it('migrateMeshmapperServerHost rewrites .cc to .net', () => { expect(migrateMeshmapperServerHost('mqtt.meshmapper.cc')).toBe(MESHMAPPER_HOST); expect(migrateMeshmapperServerHost(' mqtt.meshmapper.cc ')).toBe(MESHMAPPER_HOST); @@ -69,6 +88,44 @@ describe('letsMeshJwt', () => { expect(letsMeshJwtAudience(' mqtt.example.com ')).toBe('mqtt.example.com'); }); + it.each([ + [WAEV_HOST], + [MESHATSE_HOST], + [MESHCORE_CA_HOST_PRIMARY], + [MESHCORE_CA_HOST_BACKUP], + [EASTMESH_HOST], + ])('letsMeshJwtAudience returns the broker host itself for %s', (host) => { + expect(letsMeshJwtAudience(host)).toBe(host); + }); + + it.each([ + [LETSMESH_HOST_US, '/ws'], + [LETSMESH_HOST_EU, '/ws'], + [MESHMAPPER_HOST, '/ws'], + [MESHMAPPER_HOST_LEGACY_CC, '/ws'], + [WAEV_HOST, '/mqtt'], + [MESHATSE_HOST, '/mqtt'], + [MESHCORE_CA_HOST_PRIMARY, '/mqtt'], + [MESHCORE_CA_HOST_BACKUP, '/mqtt'], + [EASTMESH_HOST, '/mqtt'], + ])('deviceSigningWsPathForHost maps %s to %s', (host, wsPath) => { + expect(deviceSigningWsPathForHost(host)).toBe(wsPath); + }); + + it('deviceSigningWsPathForHost returns null for unknown hosts', () => { + expect(deviceSigningWsPathForHost('mqtt.example.com')).toBeNull(); + }); + + it('generateLetsMeshAuthToken stamps aud to the connect host for a new /mqtt broker', async () => { + const identity = { + public_key: sampleKeyPair.publicKey, + private_key: sampleKeyPair.privateKey, + }; + const { token } = await generateLetsMeshAuthToken(identity, WAEV_HOST); + const verified = await verifyAuthToken(token, sampleKeyPair.publicKey); + expect(verified?.aud).toBe(WAEV_HOST); + }); + it('generateLetsMeshAuthToken produces verifyAuthToken-valid tokens (full private key)', async () => { const identity = { public_key: sampleKeyPair.publicKey, diff --git a/src/renderer/lib/letsMeshJwt.ts b/src/renderer/lib/letsMeshJwt.ts index 10b0ae144..b302cf755 100644 --- a/src/renderer/lib/letsMeshJwt.ts +++ b/src/renderer/lib/letsMeshJwt.ts @@ -26,23 +26,49 @@ export const MESHMAPPER_HOST = 'mqtt.meshmapper.net'; export const MESHMAPPER_HOST_LEGACY_CC = 'mqtt.meshmapper.cc'; /** Colorado Mesh broker (WebSocket TLS on 443). */ export const COLORADO_MESH_HOST = 'mqtt.meshcore.coloradomesh.org'; +/** Waev broker (WebSocket TLS on 443, JWT auth; `/mqtt` websocket path). */ +export const WAEV_HOST = 'mqtt.waev.app'; +/** Meshat.se MeshCore broker (WebSocket TLS on 443, JWT auth; `/mqtt` websocket path). */ +export const MESHATSE_HOST = 'meshcore-mqtt.meshat.se'; +/** MeshCore Canada primary broker (WebSocket TLS on 443, JWT auth; `/mqtt` websocket path). */ +export const MESHCORE_CA_HOST_PRIMARY = 'mqtt1.meshcore.ca'; +/** MeshCore Canada backup broker (WebSocket TLS on 443, JWT auth; `/mqtt` websocket path). */ +export const MESHCORE_CA_HOST_BACKUP = 'mqtt2.meshcore.ca'; +/** EastMesh AU broker (WebSocket TLS on 443, JWT auth; `/mqtt` websocket path). */ +export const EASTMESH_HOST = 'mqtt2.eastmesh.au'; /** @deprecated Use {@link LETSMESH_HOST_US} */ export const LETSMESH_HOST = LETSMESH_HOST_US; -/** All device-signing MQTT brokers (WebSocket TLS on 443, JWT auth). */ -const DEVICE_SIGNING_HOSTS = new Set([ - LETSMESH_HOST_US, - LETSMESH_HOST_EU, - MESHMAPPER_HOST, - MESHMAPPER_HOST_LEGACY_CC, - COLORADO_MESH_HOST, -]); +/** + * All device-signing MQTT brokers (WebSocket TLS on 443, JWT auth) mapped to their required + * WebSocket path. Single source of truth for both the host allowlist ({@link isLetsMeshSettings}) + * and the expected `wsPath` ({@link deviceSigningWsPathForHost}) so the two never drift. + */ +const DEVICE_SIGNING_HOST_WS_PATHS: Record = { + [LETSMESH_HOST_US]: '/ws', + [LETSMESH_HOST_EU]: '/ws', + [MESHMAPPER_HOST]: '/ws', + [MESHMAPPER_HOST_LEGACY_CC]: '/ws', + [COLORADO_MESH_HOST]: '/ws', + [WAEV_HOST]: '/mqtt', + [MESHATSE_HOST]: '/mqtt', + [MESHCORE_CA_HOST_PRIMARY]: '/mqtt', + [MESHCORE_CA_HOST_BACKUP]: '/mqtt', + [EASTMESH_HOST]: '/mqtt', +}; + +const DEVICE_SIGNING_HOSTS = new Set(Object.keys(DEVICE_SIGNING_HOST_WS_PATHS)); export function isLetsMeshSettings(server: string): boolean { return DEVICE_SIGNING_HOSTS.has(server.trim()); } +/** Required WebSocket path for a device-signing broker host, or null when host is not device-signing. */ +export function deviceSigningWsPathForHost(server: string): '/ws' | '/mqtt' | null { + return DEVICE_SIGNING_HOST_WS_PATHS[server.trim()] ?? null; +} + /** Rewrite legacy MeshMapper `.cc` host to the canonical `.net` broker. */ export function migrateMeshmapperServerHost(server: string): string { return server.trim() === MESHMAPPER_HOST_LEGACY_CC ? MESHMAPPER_HOST : server; diff --git a/src/renderer/lib/loraRfReconnectAttempt.test.ts b/src/renderer/lib/loraRfReconnectAttempt.test.ts new file mode 100644 index 000000000..23666c43e --- /dev/null +++ b/src/renderer/lib/loraRfReconnectAttempt.test.ts @@ -0,0 +1,308 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { raceWithDeadline } from './bleReconnectHelper'; +import { + type LoraRfReconnectAttemptDeps, + runLoraRfReconnectAttempt, +} from './loraRfReconnectAttempt'; +import { createRfReconnectController } from './rfReconnectController'; +import { loadRendererLibSource } from './sourceContractTestHelpers'; +import { setSystemSuspended } from './systemPowerState'; +import { NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS } from './timeConstants'; + +vi.mock('./bleReconnectHelper', async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importOriginal needs typeof import() + const actual = await importOriginal(); + return { + ...actual, + raceWithDeadline: vi.fn(actual.raceWithDeadline), + }; +}); + +interface TestParams { + type: 'ble' | 'serial' | 'tcp'; +} + +function boolRef(initial = false) { + let value = initial; + return { + get: () => value, + set: (next: boolean) => { + value = next; + }, + }; +} + +function numberRef(initial = 0) { + let value = initial; + return { + get: () => value, + set: (next: number) => { + value = next; + }, + }; +} + +describe('runLoraRfReconnectAttempt', () => { + let microtasks: (() => void)[]; + let controller: ReturnType; + let isReconnecting: ReturnType; + let deferredReconnect: ReturnType; + let connectInFlight: ReturnType; + let bleConnectInProgress: ReturnType; + let generation: ReturnType; + let attemptCounter: ReturnType; + let scheduleAttempt: ReturnType void>>; + let setReconnectingUi: ReturnType void>>; + let setDisconnectedUi: ReturnType void>>; + let onExhausted: ReturnType Promise>>; + let onMissingParams: ReturnType void>>; + let runOpenAndAttach: ReturnType< + typeof vi.fn<(ctx: unknown, params: TestParams) => Promise> + >; + let onAttemptError: ReturnType< + typeof vi.fn<(err: unknown, ctx: unknown) => Promise<'retry' | 'defer' | 'done'>> + >; + let explicitDisconnect: boolean; + let params: TestParams | null; + + beforeEach(() => { + vi.useFakeTimers(); + microtasks = []; + setSystemSuspended(false); + explicitDisconnect = false; + params = { type: 'tcp' }; + isReconnecting = boolRef(true); + deferredReconnect = boolRef(false); + connectInFlight = boolRef(false); + bleConnectInProgress = boolRef(false); + generation = numberRef(1); + attemptCounter = numberRef(0); + scheduleAttempt = vi.fn<() => void>(); + setReconnectingUi = vi.fn<(attempt: number) => void>(); + setDisconnectedUi = vi.fn<(opts?: { connectionLoss?: boolean }) => void>(); + onExhausted = vi.fn<(params: TestParams) => Promise>().mockResolvedValue(undefined); + onMissingParams = vi.fn<() => void>(); + runOpenAndAttach = vi + .fn<(ctx: unknown, params: TestParams) => Promise>() + .mockResolvedValue(undefined); + onAttemptError = vi + .fn<(err: unknown, ctx: unknown) => Promise<'retry' | 'defer' | 'done'>>() + .mockResolvedValue('retry'); + controller = createRfReconnectController({ + logTag: 'test', + scheduleMicrotask: (fn) => { + microtasks.push(fn); + }, + }); + controller.onLinkLost(); + generation.set(controller.generation); + vi.spyOn(console, 'debug').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.mocked(raceWithDeadline).mockImplementation((work) => Promise.resolve(work)); + }); + + afterEach(() => { + setSystemSuspended(false); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + function buildDeps( + overrides: Partial> = {}, + ): LoraRfReconnectAttemptDeps { + return { + logTag: 'test', + controller, + getParams: () => params, + getTransportType: (p) => p.type, + isBle: (p) => p.type === 'ble', + isExplicitDisconnect: () => explicitDisconnect, + isReconnecting, + generation, + attemptCounter, + deferredReconnect, + connectInFlight, + bleConnectInProgress, + scheduleAttempt, + setReconnectingUi, + setDisconnectedUi, + overlapCheck: 'beforeOpening', + disconnectIdentity: vi.fn().mockResolvedValue(undefined), + onMissingParams, + onExhausted, + runOpenAndAttach, + onAttemptError, + ...overrides, + }; + } + + async function runAttempt(overrides: Partial> = {}) { + const promise = runLoraRfReconnectAttempt(buildDeps(overrides)); + // Advance past exponential backoff (attempt 1 → 2000ms). + await vi.advanceTimersByTimeAsync(2_500); + await promise; + } + + it('clears reconnecting and calls onMissingParams when params are null', async () => { + params = null; + await runLoraRfReconnectAttempt(buildDeps()); + expect(isReconnecting.get()).toBe(false); + expect(onMissingParams).toHaveBeenCalledTimes(1); + expect(runOpenAndAttach).not.toHaveBeenCalled(); + }); + + it('returns early on explicit disconnect without opening', async () => { + explicitDisconnect = true; + await runLoraRfReconnectAttempt(buildDeps()); + expect(isReconnecting.get()).toBe(false); + expect(runOpenAndAttach).not.toHaveBeenCalled(); + expect(onExhausted).not.toHaveBeenCalled(); + }); + + it('marks exhausted and calls onExhausted when attempt budget is spent', async () => { + attemptCounter.set(5); // RF_MAX_RECONNECT_ATTEMPTS for tcp + await runLoraRfReconnectAttempt(buildDeps()); + expect(controller.isReconnecting).toBe(false); + expect(onExhausted).toHaveBeenCalledWith({ type: 'tcp' }); + expect(runOpenAndAttach).not.toHaveBeenCalled(); + }); + + it('runs open after backoff and bounds the attempt with raceWithDeadline', async () => { + await runAttempt(); + expect(setReconnectingUi).toHaveBeenCalledWith(1); + expect(runOpenAndAttach).toHaveBeenCalledTimes(1); + expect(raceWithDeadline).toHaveBeenCalledWith( + expect.any(Promise), + NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS, + expect.stringContaining('Reconnect attempt timed out after'), + ); + }); + + it('skips overlapping open before beginOpening when overlapCheck is beforeOpening', async () => { + connectInFlight.set(true); + await runAttempt(); + expect(runOpenAndAttach).not.toHaveBeenCalled(); + expect(deferredReconnect.get()).toBe(true); + expect(controller.dirty).toBe(true); + }); + + it('skips overlapping open after beginOpening when overlapCheck is afterOpening', async () => { + connectInFlight.set(true); + const beginOpeningSpy = vi.spyOn(controller, 'beginOpening'); + await runAttempt({ overlapCheck: 'afterOpening' }); + expect(beginOpeningSpy).toHaveBeenCalled(); + expect(runOpenAndAttach).not.toHaveBeenCalled(); + expect(deferredReconnect.get()).toBe(true); + }); + + it('delay abort with deferred restart schedules the next attempt', async () => { + const delayPromise = runLoraRfReconnectAttempt(buildDeps()); + // Abort delay by bumping generation + setting deferred while reconnecting stays true. + await vi.advanceTimersByTimeAsync(100); + deferredReconnect.set(true); + generation.set(generation.get() + 1); + await vi.advanceTimersByTimeAsync(2_500); + await delayPromise; + expect(scheduleAttempt).toHaveBeenCalled(); + expect(setDisconnectedUi).not.toHaveBeenCalled(); + expect(runOpenAndAttach).not.toHaveBeenCalled(); + }); + + it('delay abort without deferred clears disconnected UI when cycle cancelled', async () => { + const delayPromise = runLoraRfReconnectAttempt(buildDeps()); + await vi.advanceTimersByTimeAsync(100); + isReconnecting.set(false); + generation.set(generation.get() + 1); + await vi.advanceTimersByTimeAsync(2_500); + await delayPromise; + expect(setDisconnectedUi).toHaveBeenCalledWith({ connectionLoss: true }); + expect(runOpenAndAttach).not.toHaveBeenCalled(); + }); + + it('cancels controller and clears UI when delay returns suspended', async () => { + setSystemSuspended(true); + const delayPromise = runLoraRfReconnectAttempt(buildDeps()); + await vi.advanceTimersByTimeAsync(600); + await delayPromise; + expect(controller.isReconnecting).toBe(false); + expect(isReconnecting.get()).toBe(false); + expect(setDisconnectedUi).toHaveBeenCalledWith({ connectionLoss: true }); + expect(runOpenAndAttach).not.toHaveBeenCalled(); + }); + + it('schedules retry from onAttemptError when action is retry', async () => { + runOpenAndAttach.mockRejectedValue(new Error('open failed')); + onAttemptError.mockResolvedValue('retry'); + await runAttempt(); + expect(onAttemptError).toHaveBeenCalled(); + expect(scheduleAttempt).toHaveBeenCalled(); + }); + + it('does not schedule retry when onAttemptError returns defer', async () => { + runOpenAndAttach.mockRejectedValue(new Error('setup abort')); + onAttemptError.mockImplementation(() => { + deferredReconnect.set(true); + return Promise.resolve('defer' as const); + }); + await runAttempt(); + // finally flush still schedules once when deferred is set + expect(scheduleAttempt).toHaveBeenCalledTimes(1); + }); + + it('finally flushes deferred reconnect via scheduleAttempt (not connection-lost)', async () => { + runOpenAndAttach.mockImplementation(() => { + deferredReconnect.set(true); + return Promise.resolve(); + }); + // Keep isReconnecting true after success path that only marks deferred (adapter would + // normally clear it on success — here we simulate mid-open defer). + await runAttempt(); + expect(scheduleAttempt).toHaveBeenCalled(); + expect(connectInFlight.get()).toBe(false); + }); + + it('sets and clears bleConnectInProgress around BLE opens', async () => { + params = { type: 'ble' }; + let sawInFlightDuringOpen = false; + runOpenAndAttach.mockImplementation(() => { + sawInFlightDuringOpen = bleConnectInProgress.get(); + return Promise.resolve(); + }); + await runAttempt(); + expect(sawInFlightDuringOpen).toBe(true); + expect(bleConnectInProgress.get()).toBe(false); + }); + + it('passes attemptActive and lateTransport into runOpenAndAttach', async () => { + let activeDuringOpen = false; + runOpenAndAttach.mockImplementation((ctx: unknown) => { + activeDuringOpen = (ctx as { attemptActive: () => boolean }).attemptActive(); + return Promise.resolve(); + }); + await runAttempt(); + expect(activeDuringOpen).toBe(true); + const ctx = runOpenAndAttach.mock.calls[0]?.[0] as { + lateTransport: { cleanup: (id: string) => Promise }; + generation: number; + }; + expect(ctx.generation).toBe(1); + expect(typeof ctx.lateTransport.cleanup).toBe('function'); + }); +}); + +describe('loraRfReconnectAttempt source contracts', () => { + it('owns raceWithDeadline budget and finally schedule flush', () => { + const source = loadRendererLibSource('loraRfReconnectAttempt.ts'); + expect(source).toContain('raceWithDeadline'); + expect(source).toContain('NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS'); + expect(source).toContain('Reconnect attempt timed out after'); + expect(source).toContain('createBleReconnectTransportCleanup'); + expect(source).toContain('endAttempt'); + expect(source).toContain('scheduleAttempt()'); + expect(source).toContain('skip overlapping open'); + expect(source).toMatch( + /delayResult === 'aborted'[\s\S]*?flushDeferredOrEnd[\s\S]*?setDisconnectedUi/, + ); + }); +}); diff --git a/src/renderer/lib/loraRfReconnectAttempt.ts b/src/renderer/lib/loraRfReconnectAttempt.ts new file mode 100644 index 000000000..3e57d0be2 --- /dev/null +++ b/src/renderer/lib/loraRfReconnectAttempt.ts @@ -0,0 +1,273 @@ +/** + * Shared LoRa RF reconnect attempt runner for Meshtastic and MeshCore. + * + * Owns the common backoff / single-flight / budget / finally-flush skeleton. + * Protocol-specific open+attach/configure and exhaustion side effects stay in + * runtime adapters (TCP burst, setup-abort, serial rediscovery UI, etc.). + * + * Pair with {@link createRfReconnectController} — the controller owns schedule / + * link-lost / begin–end; this module owns one attempt body. + */ + +import { raceWithDeadline } from './bleReconnectHelper'; +import { createBleReconnectTransportCleanup } from './bleReconnectLateTransport'; +import { errLikeToLogString } from './errLikeToLogString'; +import type { RfReconnectController } from './rfReconnectController'; +import { rfMaxReconnectAttemptsForTransport } from './rfReconnectShared'; +import { delayUnlessSuspended } from './systemPowerState'; +import { NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS } from './timeConstants'; + +export type LoraRfReconnectOverlapCheck = 'beforeOpening' | 'afterOpening'; + +export type LoraRfReconnectAttemptErrorAction = 'retry' | 'defer' | 'done'; + +export interface LoraRfReconnectBoolRef { + get: () => boolean; + set: (value: boolean) => void; +} + +export interface LoraRfReconnectNumberRef { + get: () => number; + set: (value: number) => void; +} + +export interface LoraRfReconnectOpenContext { + generation: number; + isBle: boolean; + attemptActive: () => boolean; + lateTransport: ReturnType; +} + +export interface LoraRfReconnectAttemptDeps { + logTag: string; + controller: RfReconnectController; + + getParams: () => TParams | null; + /** Transport key for attempt budget (`ble` / `serial` / `tcp` / …). */ + getTransportType: (params: TParams) => string; + isBle: (params: TParams) => boolean; + + isExplicitDisconnect: () => boolean; + isReconnecting: LoraRfReconnectBoolRef; + generation: LoraRfReconnectNumberRef; + attemptCounter: LoraRfReconnectNumberRef; + deferredReconnect: LoraRfReconnectBoolRef; + connectInFlight: LoraRfReconnectBoolRef; + bleConnectInProgress?: LoraRfReconnectBoolRef; + + scheduleAttempt: () => void; + setReconnectingUi: (attempt: number) => void; + setDisconnectedUi: (opts?: { connectionLoss?: boolean }) => void; + + /** Default 32_000 (matches MeshCore / Meshtastic caps). */ + maxDelayMs?: number; + /** Meshtastic checks overlapping open before beginOpening; MeshCore after. */ + overlapCheck: LoraRfReconnectOverlapCheck; + + disconnectIdentity: (identityId: string) => Promise; + + /** Called when params are null at attempt start (Meshtastic clears richer UI state). */ + onMissingParams?: () => void; + onExhausted: (params: TParams) => Promise; + runOpenAndAttach: (ctx: LoraRfReconnectOpenContext, params: TParams) => Promise; + /** + * Protocol-specific failure handling after budget/open failure. + * Return `retry` to schedule another attempt when generation is still current. + */ + onAttemptError: ( + err: unknown, + ctx: { + params: TParams; + generation: number; + isBle: boolean; + lateTransport: ReturnType; + }, + ) => Promise; +} + +const DEFAULT_MAX_DELAY_MS = 32_000; + +function shouldAbortDelay( + deps: LoraRfReconnectAttemptDeps, + generation: number, +): boolean { + return !deps.isReconnecting.get() ? true : deps.generation.get() !== generation; +} + +function flushDeferredOrEnd( + deps: LoraRfReconnectAttemptDeps, + opts: { keepReconnecting: boolean }, +): 'flushed' | 'ended' { + if (deps.deferredReconnect.get() && deps.isReconnecting.get() && !deps.isExplicitDisconnect()) { + deps.deferredReconnect.set(false); + deps.controller.endAttempt({ keepReconnecting: true }); + deps.scheduleAttempt(); + return 'flushed'; + } + deps.controller.endAttempt({ keepReconnecting: opts.keepReconnecting }); + return 'ended'; +} + +/** + * Run one LoRa RF reconnect attempt (backoff → open → settle). + * Callers keep a thin `useCallback` that builds deps and awaits this. + */ +export async function runLoraRfReconnectAttempt( + deps: LoraRfReconnectAttemptDeps, +): Promise { + const params = deps.getParams(); + if (!params) { + deps.isReconnecting.set(false); + deps.onMissingParams?.(); + return; + } + if (deps.isExplicitDisconnect()) { + deps.isReconnecting.set(false); + return; + } + + const transportType = deps.getTransportType(params); + const maxReconnectAttempts = rfMaxReconnectAttemptsForTransport(transportType); + if (deps.attemptCounter.get() >= maxReconnectAttempts) { + deps.isReconnecting.set(false); + deps.attemptCounter.set(0); + deps.controller.markExhausted(); + await deps.onExhausted(params); + return; + } + + const generation = deps.generation.get(); + deps.attemptCounter.set(deps.attemptCounter.get() + 1); + const attemptNumber = deps.attemptCounter.get(); + deps.controller.beginAttempt(attemptNumber); + deps.setReconnectingUi(attemptNumber); + + const maxDelayMs = deps.maxDelayMs ?? DEFAULT_MAX_DELAY_MS; + const delay = Math.min(2000 * Math.pow(2, attemptNumber - 1), maxDelayMs); + console.debug( + `[${deps.logTag}] reconnect: waiting ${delay}ms before attempt ${attemptNumber}/${maxReconnectAttempts}`, + ); + const delayResult = await delayUnlessSuspended(delay, () => shouldAbortDelay(deps, generation)); + + if (delayResult === 'aborted') { + // Generation bump during backoff (or cancel) aborted this delay. If a deferred restart was + // requested, schedule the next attempt — do not leave isReconnecting true with no work + // (MeshCore #795 / n7eal TCP parity). + const flushed = flushDeferredOrEnd(deps, { + keepReconnecting: deps.isReconnecting.get(), + }); + if (flushed === 'flushed') return; + if (!deps.isReconnecting.get()) { + deps.setDisconnectedUi({ connectionLoss: true }); + } + return; + } + if (delayResult === 'suspended') { + deps.isReconnecting.set(false); + deps.controller.cancel(); + deps.setDisconnectedUi({ connectionLoss: true }); + return; + } + + if (!deps.isReconnecting.get() || deps.generation.get() !== generation) { + const flushed = flushDeferredOrEnd(deps, { + keepReconnecting: deps.isReconnecting.get(), + }); + if (flushed === 'flushed') return; + if (!deps.isReconnecting.get()) { + deps.setDisconnectedUi({ connectionLoss: true }); + } + return; + } + + const skipOverlappingOpen = (): boolean => { + if (!deps.connectInFlight.get()) return false; + console.debug(`[${deps.logTag}] reconnect: skip overlapping open (connect already in flight)`); + deps.deferredReconnect.set(true); + deps.controller.markDirty(); + return true; + }; + + if (deps.overlapCheck === 'beforeOpening' && skipOverlappingOpen()) { + return; + } + + deps.controller.beginOpening(); + + if (deps.overlapCheck === 'afterOpening' && skipOverlappingOpen()) { + return; + } + + const isBle = deps.isBle(params); + deps.connectInFlight.set(true); + if (isBle) deps.bleConnectInProgress?.set(true); + + let attemptActive = true; + const lateTransport = createBleReconnectTransportCleanup(deps.disconnectIdentity, deps.logTag); + + const runOpen = async () => { + await deps.runOpenAndAttach( + { + generation, + isBle, + attemptActive: () => attemptActive, + lateTransport, + }, + params, + ); + }; + const reconnectWork = runOpen(); + // Late loser after budget timeout — avoid unhandledRejection; cleanup is in catch / late path. + void reconnectWork.catch((e: unknown) => { + console.debug(`[${deps.logTag}] reconnectWork late reject ` + errLikeToLogString(e)); + }); + + try { + // Applied to every transport, not just BLE (constant name is historical): TCP/HTTP/serial + // reconnects used to await open+attach/configure with no ceiling. A disconnect mid-open + // defers to that attempt settling; without a deadline, a hang wedges the reconnect machine. + await raceWithDeadline( + reconnectWork, + NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS, + `Reconnect attempt timed out after ${NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS}ms`, + ); + } catch (err) { + // catch-no-log-ok protocol onAttemptError logs the failure (warn/debug) + attemptActive = false; + const action = await deps.onAttemptError(err, { + params, + generation, + isBle, + lateTransport, + }); + if ( + action === 'retry' && + !deps.deferredReconnect.get() && + deps.isReconnecting.get() && + deps.generation.get() === generation + ) { + deps.scheduleAttempt(); + } + } finally { + attemptActive = false; + deps.connectInFlight.set(false); + if (isBle) deps.bleConnectInProgress?.set(false); + if (deps.deferredReconnect.get()) { + deps.controller.markDirty(); + } + const settled = deps.controller.endAttempt({ + keepReconnecting: deps.isReconnecting.get(), + }); + if (deps.deferredReconnect.get() || settled.shouldSchedule) { + deps.deferredReconnect.set(false); + if (deps.isReconnecting.get()) { + console.debug( + `[${deps.logTag}] reconnect settled — running deferred reconnect after transport drop`, + ); + // Call attempt via schedule (coalesced) — nested connection-lost re-bumped generation + // and raced a second backoff loop with this flush (MeshCore #792 / n7eal TCP parity). + deps.scheduleAttempt(); + } + } + } +} diff --git a/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.test.ts b/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.test.ts index 582053790..6e67db8b1 100644 --- a/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.test.ts +++ b/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.test.ts @@ -131,10 +131,43 @@ describe('meshcorePathUpdatedRuntime', () => { expect.arrayContaining([expect.objectContaining({ publicKey, flags: 0 })]), ); expect(onNodes).toHaveBeenCalledWith(nodeMap); - expect(buildNodesFromContacts).toHaveBeenCalled(); + expect(buildNodesFromContacts).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ contactsFromRadio: true }), + ); expect(usePathHistoryStore.getState().records.has(nodeId)).toBe(true); }); + it('rebuildMeshcoreContactsAfterPathUpdated marks contacts on-radio even with no pending ids', async () => { + // Regression: a debounced rebuild after a live sync must not re-save contacts with + // on_radio=0. The helper signals this by passing contactsFromRadio: true to the builder. + const publicKey = makePubKey(21); + const nodeId = pubkeyToNodeId(publicKey); + const contacts = [contact({ publicKey, outPathLen: 0, advName: 'Direct' })]; + const conn = { + getContacts: vi.fn().mockResolvedValue(contacts), + } as unknown as MeshCoreConnection; + const buildNodesFromContacts = vi.fn().mockResolvedValue(new Map()); + + await rebuildMeshcoreContactsAfterPathUpdated({ + conn, + buildNodesFromContacts, + self: null, + myNodeId: 1, + previousNodes: new Map(), + pendingPathUpdateNodeIds: new Set(), + onContacts: vi.fn(), + onNodes: vi.fn(), + }); + + expect(buildNodesFromContacts).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ contactsFromRadio: true }), + ); + // No pending ids and no outPath → no path-history rows written. + expect(usePathHistoryStore.getState().records.has(nodeId)).toBe(false); + }); + it('rebuildMeshcoreContactsAfterPathUpdated logs and keeps prior nodes on failure', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const onContacts = vi.fn(); diff --git a/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.ts b/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.ts index 3a16e42ed..b50bf40d9 100644 --- a/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.ts +++ b/src/renderer/lib/meshcore/meshcorePathUpdatedRuntime.ts @@ -56,6 +56,7 @@ export interface MeshcoreContactsRebuildDeps { self?: MeshCoreSelfInfo | null; myNodeId?: number; previousNodes?: Map; + contactsFromRadio?: boolean; }, ) => Promise>; self: MeshCoreSelfInfo | null; @@ -87,6 +88,9 @@ export async function rebuildMeshcoreContactsAfterPathUpdated( self: deps.self, myNodeId: deps.myNodeId, previousNodes: deps.previousNodes, + // These contacts come from a live `getContacts` dump; preserve `on_radio=1` so a + // path-updated rebuild does not wipe on-radio state after a successful sync. + contactsFromRadio: true, }); deps.onNodes(newNodes); for (const contact of contacts) { diff --git a/src/renderer/lib/meshcoreMqttPresets.test.ts b/src/renderer/lib/meshcoreMqttPresets.test.ts index 14fc47b1a..20f269a10 100644 --- a/src/renderer/lib/meshcoreMqttPresets.test.ts +++ b/src/renderer/lib/meshcoreMqttPresets.test.ts @@ -3,11 +3,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { COLORADO_MESH_HOST, + EASTMESH_HOST, LETSMESH_HOST_EU, LETSMESH_HOST_US, + MESHATSE_HOST, + MESHCORE_CA_HOST_BACKUP, + MESHCORE_CA_HOST_PRIMARY, MESHMAPPER_HOST, + WAEV_HOST, } from './letsMeshJwt'; -import { applyMeshcoreMqttPreset, readStoredMeshcoreMqttPreset } from './meshcoreMqttPresets'; +import { + applyMeshcoreMqttPreset, + isDeviceSigningMeshcorePreset, + readStoredMeshcoreMqttPreset, + usesMeshcoreDeviceSigningMqtt, +} from './meshcoreMqttPresets'; import type { MQTTSettings } from './types'; const store = new Map(); @@ -73,6 +83,108 @@ describe('applyMeshcoreMqttPreset', () => { expect(next.topicPrefix).toBe('meshcore/test'); expect(next.password).toBe(''); }); + + it('applies Waev broker with WSS + /mqtt path on 443', () => { + const next = applyMeshcoreMqttPreset('waev', base); + expect(next.server).toBe(WAEV_HOST); + expect(WAEV_HOST).toBe('mqtt.waev.app'); + expect(next.port).toBe(443); + expect(next.useWebSocket).toBe(true); + expect(next.tlsEnabled).toBe(true); + expect(next.wsPath).toBe('/mqtt'); + expect(next.topicPrefix).toBe('meshcore/test'); + expect(next.password).toBe(''); + expect(next.username).toBe(base.username); + }); + + it('applies Meshat.se broker with WSS + /mqtt path on 443', () => { + const next = applyMeshcoreMqttPreset('meshatse', base); + expect(next.server).toBe(MESHATSE_HOST); + expect(MESHATSE_HOST).toBe('meshcore-mqtt.meshat.se'); + expect(next.port).toBe(443); + expect(next.wsPath).toBe('/mqtt'); + expect(next.tlsEnabled).toBe(true); + expect(next.password).toBe(''); + }); + + it('applies EastMesh broker with WSS + /mqtt path on 443', () => { + const next = applyMeshcoreMqttPreset('eastmesh', base); + expect(next.server).toBe(EASTMESH_HOST); + expect(EASTMESH_HOST).toBe('mqtt2.eastmesh.au'); + expect(next.port).toBe(443); + expect(next.wsPath).toBe('/mqtt'); + expect(next.tlsEnabled).toBe(true); + expect(next.password).toBe(''); + }); + + it('defaults MeshCore.CA to the primary broker host', () => { + const next = applyMeshcoreMqttPreset('meshcoreca', base); + expect(next.server).toBe(MESHCORE_CA_HOST_PRIMARY); + expect(MESHCORE_CA_HOST_PRIMARY).toBe('mqtt1.meshcore.ca'); + expect(next.port).toBe(443); + expect(next.wsPath).toBe('/mqtt'); + expect(next.password).toBe(''); + }); + + it('preserves the MeshCore.CA backup broker host when already selected', () => { + const next = applyMeshcoreMqttPreset('meshcoreca', { + ...base, + server: MESHCORE_CA_HOST_BACKUP, + }); + expect(next.server).toBe(MESHCORE_CA_HOST_BACKUP); + expect(MESHCORE_CA_HOST_BACKUP).toBe('mqtt2.meshcore.ca'); + }); + + it('restores certificate verification when switching from Ripple (tlsInsecure) to a device-signing preset', () => { + // Ripple leaves tlsInsecure=true; a device-signing preset must reset it so the JWT WSS + // connection verifies certificates again. + const fromRipple: MQTTSettings = { + ...base, + server: 'mqtt.ripplenetworks.com.au', + tlsInsecure: true, + }; + const next = applyMeshcoreMqttPreset('waev', fromRipple); + expect(next.tlsInsecure).toBe(false); + expect(next.server).toBe(WAEV_HOST); + expect(next.tlsEnabled).toBe(true); + }); +}); + +describe('usesMeshcoreDeviceSigningMqtt', () => { + it('is true for a named device-signing preset (Waev)', () => { + expect(usesMeshcoreDeviceSigningMqtt('waev', { server: WAEV_HOST })).toBe(true); + }); + + it('is true for Custom settings pointed at a device-signing host (mqtt.waev.app)', () => { + expect(usesMeshcoreDeviceSigningMqtt('custom', { server: WAEV_HOST })).toBe(true); + }); + + it('is false for Custom settings pointed at an unmatched host', () => { + expect(usesMeshcoreDeviceSigningMqtt('custom', { server: 'mqtt.example.com' })).toBe(false); + }); +}); + +describe('isDeviceSigningMeshcorePreset', () => { + it('is true for JWT device-signing presets', () => { + for (const preset of [ + 'letsmesh', + 'coloradomesh', + 'meshmapper', + 'waev', + 'meshatse', + 'meshcoreca', + 'eastmesh', + ] as const) { + expect(isDeviceSigningMeshcorePreset(preset)).toBe(true); + } + }); + + it('is false for ripple, custom, and nullish presets', () => { + expect(isDeviceSigningMeshcorePreset('ripple')).toBe(false); + expect(isDeviceSigningMeshcorePreset('custom')).toBe(false); + expect(isDeviceSigningMeshcorePreset(null)).toBe(false); + expect(isDeviceSigningMeshcorePreset(undefined)).toBe(false); + }); }); describe('readStoredMeshcoreMqttPreset', () => { @@ -89,4 +201,11 @@ describe('readStoredMeshcoreMqttPreset', () => { localStorage.setItem('mesh-client:mqttPreset:meshcore', 'coloradomesh'); expect(readStoredMeshcoreMqttPreset()).toBe('coloradomesh'); }); + + it('recognizes the newly added device-signing preset ids', () => { + for (const preset of ['waev', 'meshatse', 'meshcoreca', 'eastmesh']) { + localStorage.setItem('mesh-client:mqttPreset:meshcore', preset); + expect(readStoredMeshcoreMqttPreset()).toBe(preset); + } + }); }); diff --git a/src/renderer/lib/meshcoreMqttPresets.ts b/src/renderer/lib/meshcoreMqttPresets.ts index b5385bf5d..caaf38457 100644 --- a/src/renderer/lib/meshcoreMqttPresets.ts +++ b/src/renderer/lib/meshcoreMqttPresets.ts @@ -1,12 +1,27 @@ import { COLORADO_MESH_HOST, + EASTMESH_HOST, + isLetsMeshSettings, LETSMESH_HOST_EU, LETSMESH_HOST_US, + MESHATSE_HOST, + MESHCORE_CA_HOST_BACKUP, + MESHCORE_CA_HOST_PRIMARY, MESHMAPPER_HOST, + WAEV_HOST, } from './letsMeshJwt'; import type { MQTTSettings } from './types'; -export type MeshcoreMqttPreset = 'letsmesh' | 'coloradomesh' | 'meshmapper' | 'ripple' | 'custom'; +export type MeshcoreMqttPreset = + | 'letsmesh' + | 'coloradomesh' + | 'meshmapper' + | 'waev' + | 'meshatse' + | 'meshcoreca' + | 'eastmesh' + | 'ripple' + | 'custom'; export const MESHCORE_MQTT_PRESET_STORAGE_KEY = 'mesh-client:mqttPreset:meshcore'; @@ -14,9 +29,50 @@ const KNOWN_MESHCORE_MQTT_PRESETS = new Set([ 'letsmesh', 'coloradomesh', 'meshmapper', + 'waev', + 'meshatse', + 'meshcoreca', + 'eastmesh', 'ripple', ]); +/** + * Presets that authenticate with a MeshCore device-signed JWT (WSS + TLS on 443). + * These share the LetsMesh connect flow (identity hint, deviation warning, token minting). + * + * Single source of truth: also drives IATA topic scoping ({@link isIataScopedMeshcoreMqtt}) and the + * startup settings reconcile, so those never drift from the connect/deviation flow. + */ +export const DEVICE_SIGNING_MESHCORE_PRESETS: ReadonlySet = new Set([ + 'letsmesh', + 'coloradomesh', + 'meshmapper', + 'waev', + 'meshatse', + 'meshcoreca', + 'eastmesh', +]); + +/** True when the preset uses MeshCore device-signed JWT auth (WSS/TLS/443). */ +export function isDeviceSigningMeshcorePreset( + preset: MeshcoreMqttPreset | null | undefined, +): boolean { + return !!preset && DEVICE_SIGNING_MESHCORE_PRESETS.has(preset); +} + +/** + * True when a MeshCore MQTT connection should use the device-signing JWT flow: either a named + * device-signing preset is selected, or the configured server is a device-signing broker host. + * The host branch keeps Custom (or hand-tuned) settings pointed at a known JWT broker consistent + * between the manual Connect button and startup auto-launch. + */ +export function usesMeshcoreDeviceSigningMqtt( + preset: MeshcoreMqttPreset | null | undefined, + settings: Pick, +): boolean { + return isDeviceSigningMeshcorePreset(preset) || isLetsMeshSettings(settings.server); +} + export function readStoredMeshcoreMqttPreset(): MeshcoreMqttPreset { const saved = localStorage.getItem(MESHCORE_MQTT_PRESET_STORAGE_KEY); // No key yet → new install defaults to LetsMesh (settings seeded by migrations). @@ -27,6 +83,27 @@ export function readStoredMeshcoreMqttPreset(): MeshcoreMqttPreset { return 'custom'; } +/** + * Common field block for a device-signing broker (WSS + TLS on 443, JWT auth). + * Defaults the topic prefix to `meshcore/test`; callers override it per preset (e.g. Colorado DEN). + * + * `tlsInsecure` is explicitly reset to `false` so switching from Ripple (which enables it) restores + * certificate verification instead of leaking an insecure TLS setting into a JWT broker connection. + */ +function deviceSigningWssFields(server: string, wsPath: '/ws' | '/mqtt'): Partial { + return { + server, + port: 443, + topicPrefix: 'meshcore/test', + useWebSocket: true, + tlsEnabled: true, + tlsInsecure: false, + wsPath, + keepalive: 30, + password: '', + }; +} + /** Preset-owned MQTT fields (Connection tab preset buttons). */ export function meshcoreMqttPresetFields( preset: MeshcoreMqttPreset, @@ -38,39 +115,25 @@ export function meshcoreMqttPresetFields( prev.server === LETSMESH_HOST_EU || prev.server === LETSMESH_HOST_US ? prev.server : LETSMESH_HOST_US; - return { - server, - port: 443, - topicPrefix: 'meshcore/test', - useWebSocket: true, - tlsEnabled: true, - wsPath: '/ws', - keepalive: 30, - password: '', - }; + return deviceSigningWssFields(server, '/ws'); } case 'coloradomesh': - return { - server: COLORADO_MESH_HOST, - port: 443, - topicPrefix: 'meshcore/DEN', - useWebSocket: true, - tlsEnabled: true, - wsPath: '/ws', - keepalive: 30, - password: '', - }; + return { ...deviceSigningWssFields(COLORADO_MESH_HOST, '/ws'), topicPrefix: 'meshcore/DEN' }; case 'meshmapper': - return { - server: MESHMAPPER_HOST, - port: 443, - topicPrefix: 'meshcore/test', - useWebSocket: true, - tlsEnabled: true, - wsPath: '/ws', - keepalive: 30, - password: '', - }; + return deviceSigningWssFields(MESHMAPPER_HOST, '/ws'); + case 'waev': + return deviceSigningWssFields(WAEV_HOST, '/mqtt'); + case 'meshatse': + return deviceSigningWssFields(MESHATSE_HOST, '/mqtt'); + case 'meshcoreca': { + const server = + prev.server === MESHCORE_CA_HOST_BACKUP || prev.server === MESHCORE_CA_HOST_PRIMARY + ? prev.server + : MESHCORE_CA_HOST_PRIMARY; + return deviceSigningWssFields(server, '/mqtt'); + } + case 'eastmesh': + return deviceSigningWssFields(EASTMESH_HOST, '/mqtt'); case 'ripple': return { server: 'mqtt.ripplenetworks.com.au', diff --git a/src/renderer/lib/meshcoreMqttTopicPrefix.test.ts b/src/renderer/lib/meshcoreMqttTopicPrefix.test.ts index 3a94a2b79..094264435 100644 --- a/src/renderer/lib/meshcoreMqttTopicPrefix.test.ts +++ b/src/renderer/lib/meshcoreMqttTopicPrefix.test.ts @@ -64,6 +64,13 @@ describe('isIataScopedMeshcoreMqtt', () => { expect(isIataScopedMeshcoreMqtt('custom', { server: MESHMAPPER_HOST })).toBe(true); }); + it('is true for the newly added device-signing presets', () => { + expect(isIataScopedMeshcoreMqtt('waev', { server: '' })).toBe(true); + expect(isIataScopedMeshcoreMqtt('meshatse', { server: '' })).toBe(true); + expect(isIataScopedMeshcoreMqtt('meshcoreca', { server: '' })).toBe(true); + expect(isIataScopedMeshcoreMqtt('eastmesh', { server: '' })).toBe(true); + }); + it('is false for Ripple and arbitrary custom brokers', () => { expect(isIataScopedMeshcoreMqtt('ripple', { server: 'mqtt.ripplenetworks.com.au' })).toBe( false, diff --git a/src/renderer/lib/meshcoreMqttTopicPrefix.ts b/src/renderer/lib/meshcoreMqttTopicPrefix.ts index f6c52681b..1ebe62542 100644 --- a/src/renderer/lib/meshcoreMqttTopicPrefix.ts +++ b/src/renderer/lib/meshcoreMqttTopicPrefix.ts @@ -1,5 +1,5 @@ import { isLetsMeshSettings } from './letsMeshJwt'; -import type { MeshcoreMqttPreset } from './meshcoreMqttPresets'; +import { DEVICE_SIGNING_MESHCORE_PRESETS, type MeshcoreMqttPreset } from './meshcoreMqttPresets'; import type { MQTTSettings } from './types'; /** i18n key used when an IATA-scoped MeshCore topic prefix is malformed. */ @@ -19,14 +19,12 @@ export interface MeshcoreIataTopicPrefixParseErr { export type MeshcoreIataTopicPrefixParseResult = MeshcoreIataTopicPrefixParseOk | MeshcoreIataTopicPrefixParseErr; -const IATA_SCOPED_PRESETS = new Set(['letsmesh', 'coloradomesh', 'meshmapper']); - /** Presets / device-signing hosts that expect `meshcore/{IATA}` or `meshcore/test`. */ export function isIataScopedMeshcoreMqtt( preset: MeshcoreMqttPreset | null | undefined, settings: Pick | null | undefined, ): boolean { - if (preset && IATA_SCOPED_PRESETS.has(preset)) return true; + if (preset && DEVICE_SIGNING_MESHCORE_PRESETS.has(preset)) return true; const server = settings?.server.trim() ?? ''; return server.length > 0 && isLetsMeshSettings(server); } diff --git a/src/renderer/lib/mqttAutoLaunch.test.ts b/src/renderer/lib/mqttAutoLaunch.test.ts index 099cccef1..ef7e89445 100644 --- a/src/renderer/lib/mqttAutoLaunch.test.ts +++ b/src/renderer/lib/mqttAutoLaunch.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { COLORADO_MQTT_REGION_ACK_KEY } from './connectionPanelStorageMigrations'; -import { MESHCORE_ENC_PK_KEY, MESHCORE_IDENTITY_STORAGE_KEY } from './letsMeshJwt'; +import { MESHCORE_ENC_PK_KEY, MESHCORE_IDENTITY_STORAGE_KEY, WAEV_HOST } from './letsMeshJwt'; import { MESHTASTIC_MQTT_SETTINGS_KEY } from './meshtasticMqttSettingsStorage'; import { tryAutoLaunchMqtt } from './mqttAutoLaunch'; @@ -167,4 +167,50 @@ describe('shouldAutoLaunchMeshcoreMqttAtStartup', () => { const { shouldAutoLaunchMeshcoreMqttAtStartup } = await import('./mqttAutoLaunch'); expect(shouldAutoLaunchMeshcoreMqttAtStartup()).toBe(false); }); + + it('defers a Waev preset on the imported key (device-signing via preset)', async () => { + const { shouldAutoLaunchMeshcoreMqttAtStartup } = await import('./mqttAutoLaunch'); + localStorage.setItem(COLORADO_MQTT_REGION_ACK_KEY, '1'); + localStorage.setItem('mesh-client:mqttPreset:meshcore', 'waev'); + localStorage.setItem( + MESHCORE_KEY, + JSON.stringify({ + server: WAEV_HOST, + port: 443, + autoLaunch: true, + useWebSocket: true, + tlsEnabled: true, + wsPath: '/mqtt', + }), + ); + // No password and no identity key → deferred, not treated as manual-password broker. + expect(shouldAutoLaunchMeshcoreMqttAtStartup()).toBe(false); + + localStorage.setItem(MESHCORE_IDENTITY_STORAGE_KEY, JSON.stringify({ public_key: [1, 2] })); + localStorage.setItem(MESHCORE_ENC_PK_KEY, 'enc'); + expect(shouldAutoLaunchMeshcoreMqttAtStartup()).toBe(true); + }); + + it('defers Custom settings pointed at a device-signing host on the imported key', async () => { + const { shouldAutoLaunchMeshcoreMqttAtStartup } = await import('./mqttAutoLaunch'); + localStorage.setItem(COLORADO_MQTT_REGION_ACK_KEY, '1'); + localStorage.setItem('mesh-client:mqttPreset:meshcore', 'custom'); + localStorage.setItem( + MESHCORE_KEY, + JSON.stringify({ + server: WAEV_HOST, + port: 443, + autoLaunch: true, + useWebSocket: true, + tlsEnabled: true, + wsPath: '/mqtt', + }), + ); + // Host is device-signing → gate on the imported key, not a password. + expect(shouldAutoLaunchMeshcoreMqttAtStartup()).toBe(false); + + localStorage.setItem(MESHCORE_IDENTITY_STORAGE_KEY, JSON.stringify({ public_key: [1, 2] })); + localStorage.setItem(MESHCORE_ENC_PK_KEY, 'enc'); + expect(shouldAutoLaunchMeshcoreMqttAtStartup()).toBe(true); + }); }); diff --git a/src/renderer/lib/mqttAutoLaunch.ts b/src/renderer/lib/mqttAutoLaunch.ts index 636fbf13f..dcb8abcae 100644 --- a/src/renderer/lib/mqttAutoLaunch.ts +++ b/src/renderer/lib/mqttAutoLaunch.ts @@ -6,12 +6,15 @@ import { } from './letsMeshConnectionGuards'; import { generateLetsMeshAuthToken, - isLetsMeshSettings, letsMeshMqttUsernameFromIdentity, meshcoreIdentityHasPrivateKey, readMeshcoreIdentityAsync, } from './letsMeshJwt'; -import { readStoredMeshcoreMqttPreset } from './meshcoreMqttPresets'; +import { + type MeshcoreMqttPreset, + readStoredMeshcoreMqttPreset, + usesMeshcoreDeviceSigningMqtt, +} from './meshcoreMqttPresets'; import { readMeshcoreMqttSettingsFromStorage } from './meshcoreMqttSettingsStorage'; import { prepareMeshcoreIataMqttTopicPrefix } from './meshcoreMqttTopicPrefix'; import { readMeshtasticMqttSettingsFromStorage } from './meshtasticMqttSettingsStorage'; @@ -27,7 +30,10 @@ export function shouldAutoLaunchMeshcoreMqttAtStartup(): boolean { const settings = readMeshcoreMqttSettingsFromStorage(); if (!settings.autoLaunch) return false; if (meshcoreMqttNeedsColoradoRegionAck()) return false; - if (isLetsMeshSettings(settings.server)) { + // Device-signing gate follows the shared predicate (named preset OR device-signing host), so a + // Waev/EastMesh preset with a stale server still defers on the imported key rather than a password. + const preset = readStoredMeshcoreMqttPreset(); + if (usesMeshcoreDeviceSigningMqtt(preset, settings)) { return meshcoreIdentityHasPrivateKey(); } return Boolean(settings.password.trim()); @@ -55,11 +61,11 @@ export async function tryAutoLaunchMqtt(prot: MeshProtocol): Promise { mqttTransportProtocol: prot === 'meshcore' ? 'meshcore' : 'meshtastic', }; + const meshcorePreset: MeshcoreMqttPreset | null = + prot === 'meshcore' ? readStoredMeshcoreMqttPreset() : null; + if (prot === 'meshcore') { - const iataPrepared = prepareMeshcoreIataMqttTopicPrefix( - readStoredMeshcoreMqttPreset(), - connectSettings, - ); + const iataPrepared = prepareMeshcoreIataMqttTopicPrefix(meshcorePreset, connectSettings); if (!iataPrepared.ok) { console.warn( '[App] MQTT auto-launch skipped: invalid MeshCore topic prefix (need meshcore/{IATA} or meshcore/test)', @@ -69,7 +75,7 @@ export async function tryAutoLaunchMqtt(prot: MeshProtocol): Promise { connectSettings.topicPrefix = iataPrepared.topicPrefix; } - if (prot === 'meshcore' && isLetsMeshSettings(connectSettings.server)) { + if (prot === 'meshcore' && usesMeshcoreDeviceSigningMqtt(meshcorePreset, connectSettings)) { const presetErr = validateLetsMeshPresetConnect(connectSettings); if (presetErr) { console.warn('[App] MQTT auto-launch skipped: ' + errLikeToLogString(presetErr)); diff --git a/src/renderer/lib/protocolRfAutoConnectGate.ts b/src/renderer/lib/protocolRfAutoConnectGate.ts index 56fa63da0..d3716ae99 100644 --- a/src/renderer/lib/protocolRfAutoConnectGate.ts +++ b/src/renderer/lib/protocolRfAutoConnectGate.ts @@ -2,8 +2,7 @@ import type { MeshProtocol } from '@/renderer/lib/types'; /** * Cancels deferred ProtocolAutoConnectCoordinator BLE/serial auto-connect when the user - * starts a manual Connect (or Cancel) on ConnectionPanel. Panel-local autoConnectCancelRef - * is a no-op while suppressMountAutoConnect is set — this gate is the coordinator path. + * starts a manual Connect (or Cancel / Reconnect) on ConnectionPanel. */ const cancelledByProtocol = new Map(); diff --git a/src/renderer/lib/reticulum/reticulumGamesMetadata.test.ts b/src/renderer/lib/reticulum/reticulumGamesMetadata.test.ts index 3d09ce004..699d09865 100644 --- a/src/renderer/lib/reticulum/reticulumGamesMetadata.test.ts +++ b/src/renderer/lib/reticulum/reticulumGamesMetadata.test.ts @@ -4,6 +4,7 @@ import { gamesDrawOfferedBy, isGamesDrawOfferFromOpponent, isGamesDrawOfferFromSelf, + isGamesWinForSelf, } from './reticulumGamesMetadata'; describe('gamesDrawOfferedBy', () => { @@ -108,3 +109,57 @@ describe('isGamesDrawOfferFromSelf / isGamesDrawOfferFromOpponent', () => { expect(isGamesDrawOfferFromOpponent(session)).toBe(opponent); }); }); + +describe('isGamesWinForSelf', () => { + it.each([ + { + name: 'completed local win', + session: { + identity_id: 'me', + status: 'completed', + metadata: { terminal: 'win', winner: 'me' }, + }, + expected: true, + }, + { + name: 'completed opponent win', + session: { + identity_id: 'me', + status: 'completed', + metadata: { terminal: 'win', winner: 'peer' }, + }, + expected: false, + }, + { + name: 'completed draw', + session: { + identity_id: 'me', + status: 'completed', + metadata: { terminal: 'draw', winner: '' }, + }, + expected: false, + }, + { + name: 'still active even if winner set', + session: { identity_id: 'me', status: 'active', metadata: { terminal: 'win', winner: 'me' } }, + expected: false, + }, + { + name: 'terminal not win', + session: { identity_id: 'me', status: 'completed', metadata: { terminal: '', winner: 'me' } }, + expected: false, + }, + { + name: 'empty identity cannot win', + session: { identity_id: '', status: 'completed', metadata: { terminal: 'win', winner: '' } }, + expected: false, + }, + { + name: 'metadata undefined', + session: { identity_id: 'me', status: 'completed' }, + expected: false, + }, + ])('$name', ({ session, expected }) => { + expect(isGamesWinForSelf(session)).toBe(expected); + }); +}); diff --git a/src/renderer/lib/reticulum/reticulumGamesMetadata.ts b/src/renderer/lib/reticulum/reticulumGamesMetadata.ts index d0da2b04a..e9aca8d86 100644 --- a/src/renderer/lib/reticulum/reticulumGamesMetadata.ts +++ b/src/renderer/lib/reticulum/reticulumGamesMetadata.ts @@ -65,3 +65,19 @@ export function isGamesDrawOfferFromOpponent(session: { }): boolean { return gamesMetaBool(session.metadata, 'draw_offered') && !isGamesDrawOfferFromSelf(session); } + +/** + * True when this session is a completed game the local player won — the gate for + * a one-shot win celebration. Draws, losses, resign-as-loss, and still-active + * games return false. + */ +export function isGamesWinForSelf(session: { + identity_id: string; + status: string; + metadata?: Record; +}): boolean { + if (session.status !== 'completed') return false; + if (gamesMetaStr(session.metadata, 'terminal') !== 'win') return false; + const winner = gamesMetaStr(session.metadata, 'winner'); + return Boolean(winner && session.identity_id && winner === session.identity_id); +} diff --git a/src/renderer/lib/reticulum/reticulumGamesSession.test.ts b/src/renderer/lib/reticulum/reticulumGamesSession.test.ts index 27c82d781..4f7ffa0f9 100644 --- a/src/renderer/lib/reticulum/reticulumGamesSession.test.ts +++ b/src/renderer/lib/reticulum/reticulumGamesSession.test.ts @@ -9,9 +9,11 @@ vi.mock('@/renderer/components/Toast', () => ({ pushAppToast: vi.fn(), })); +import { pushAppToast } from '@/renderer/components/Toast'; import { useReticulumGamesStore } from '@/renderer/stores/reticulumGamesStore'; import { + isGamesSessionExpiredReason, markGamesSessionRead, openReticulumGameSession, sendGamesAction, @@ -152,4 +154,48 @@ describe('sendGamesAction optimistic rollback', () => { expect(useReticulumGamesStore.getState().sessions[0].metadata.board).toBe('_________'); expect(useReticulumGamesStore.getState().optimisticBackup.s1).toBeUndefined(); }); + + it('flips the local session to expired and shows a friendly toast on session_expired', async () => { + useReticulumGamesStore.getState().upsertSession(makeSession({ status: 'active' })); + sendAction.mockResolvedValue({ ok: false, error: 'session_expired' }); + + const ok = await sendGamesAction({ + destHash: 'a'.repeat(32), + appId: 'ttt', + command: 'resign', + sessionId: 's1', + }); + + expect(ok).toBe(false); + expect(useReticulumGamesStore.getState().sessions[0].status).toBe('expired'); + expect(pushAppToast).toHaveBeenCalledWith('gamesPanel.errors.sessionExpired', 'error'); + }); + + it('recognizes the legacy dispatch_error session expired wrapper', async () => { + useReticulumGamesStore.getState().upsertSession(makeSession({ status: 'active' })); + sendAction.mockResolvedValue({ + ok: false, + error: 'dispatch_error: session expired: b986f24180168d89', + }); + + const ok = await sendGamesAction({ + destHash: 'a'.repeat(32), + appId: 'ttt', + command: 'resign', + sessionId: 's1', + }); + + expect(ok).toBe(false); + expect(useReticulumGamesStore.getState().sessions[0].status).toBe('expired'); + }); +}); + +describe('isGamesSessionExpiredReason', () => { + it('matches the stable code and legacy wrapper, ignoring unrelated errors', () => { + expect(isGamesSessionExpiredReason('session_expired')).toBe(true); + expect(isGamesSessionExpiredReason('dispatch_error: session expired: abc')).toBe(true); + expect(isGamesSessionExpiredReason('SESSION_EXPIRED')).toBe(true); + expect(isGamesSessionExpiredReason('send_failed')).toBe(false); + expect(isGamesSessionExpiredReason(undefined)).toBe(false); + }); }); diff --git a/src/renderer/lib/reticulum/reticulumGamesSession.ts b/src/renderer/lib/reticulum/reticulumGamesSession.ts index 64aecfbe1..7afc98af9 100644 --- a/src/renderer/lib/reticulum/reticulumGamesSession.ts +++ b/src/renderer/lib/reticulum/reticulumGamesSession.ts @@ -10,6 +10,18 @@ export function normalizeGamesDestHash(hash: string): string | null { return DEST_HASH_RE.test(v) ? v : null; } +/** + * True when a failed action reason means the LRGP session passed its idle TTL + * (~24h) and the protocol can no longer act on it. Matches the sidecar's stable + * `session_expired` code as well as the older `dispatch_error: session expired` + * wrapper so pre-fix sidecars are still recognized. + */ +export function isGamesSessionExpiredReason(reason: string | undefined): boolean { + if (!reason) return false; + const r = reason.toLowerCase(); + return r.includes('session_expired') || r.includes('session expired'); +} + export async function refreshGamesStatus(): Promise { try { const status = await window.electronAPI.reticulum.games.getStatus(); @@ -78,9 +90,24 @@ export async function sendGamesAction(opts: SendGamesActionOpts): Promise24h); the sidecar has flipped its stored + // status to expired. Mirror that locally so the resign/draw controls + // disappear immediately and the only remaining action is delete. + if (sessionId) { + const latest = useReticulumGamesStore.getState(); + const session = latest.sessions.find((row) => row.session_id === sessionId); + if (session && session.status !== 'expired') { + latest.upsertSession({ ...session, status: 'expired' }); + } + } + pushAppToast(i18n.t('gamesPanel.errors.sessionExpired'), 'error'); + return false; + } pushAppToast( i18n.t('gamesPanel.errors.actionFailed', { - reason: result.error ?? result.reason ?? i18n.t('gamesPanel.errors.unknownReason'), + reason: reason ?? i18n.t('gamesPanel.errors.unknownReason'), }), 'error', ); diff --git a/src/renderer/lib/sourceContractTestHelpers.ts b/src/renderer/lib/sourceContractTestHelpers.ts index a8daf508f..20a8d4e82 100644 --- a/src/renderer/lib/sourceContractTestHelpers.ts +++ b/src/renderer/lib/sourceContractTestHelpers.ts @@ -4,12 +4,18 @@ import { join } from 'node:path'; import { expect } from 'vitest'; const RUNTIME_DIR = join(import.meta.dirname, '../runtime'); +const RENDERER_LIB_DIR = join(import.meta.dirname); /** Load a `src/renderer/runtime/*` source file for contract tests. */ export function loadRuntimeSource(filename: string): string { return readFileSync(join(RUNTIME_DIR, filename), 'utf-8'); } +/** Load a `src/renderer/lib/*` source file for contract tests. */ +export function loadRendererLibSource(filename: string): string { + return readFileSync(join(RENDERER_LIB_DIR, filename), 'utf-8'); +} + /** Returns the inner text of a `{ ... }` block starting at `openBraceIndex`. */ export function extractBalancedBlock(source: string, openBraceIndex: number): string { let depth = 0; diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 3b23518d0..40a731ea0 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -910,8 +910,6 @@ "pairingFailed": "Párování se nezdařilo: {{msg}}", "pinPairingFailed": "Párování PIN se nezdařilo: {{msg}}", "pinAcceptedNext": "PIN přijat. Párování dokončeno. Stiskněte Připojit a dokončete připojení výběrem zařízení.", - "autoConnectTimeout": "Vypršel časový limit automatického připojení.", - "autoConnectFailed": "Automatické připojení se nezdařilo", "connectionFailed": "Připojení se nezdařilo" }, "cancelMqttConnect": "Zrušit", @@ -924,8 +922,6 @@ "mqttTlsEnabled": "Povolit TLS (mqtts / wss)", "mqttTlsInsecure": "Povolit nezabezpečený TLS (self-signed certifikát). Vypnuto ve výchozím nastavení — povolte pouze v případě, že váš makléř používá neveřejnou CA.", "channelPsksPrivateUplinkNote": "Kanálový uplink je šifrován AES-PSK na /2/e/ (žádné zrcadlo JSON pro soukromé klíče). Monitory zprostředkovatelů mohou stále zaznamenávat chyby dešifrování pro provoz PKI/DM mezi jinými uzly — to je normální a nesouvisí s kanály PSK.", - "sharedBleWithMeshtastic": "Toto zařízení Bluetooth je již připojeno přes Meshtastic. Chcete-li jej použít, přepněte na kartu Meshtastic, nebo si pro MeshCore vyberte jiné rádio nebo transport.", - "sharedBleWithMeshtasticHint": "Jedno fyzické rádio nemůže současně pořádat relace Meshtastic a MeshCore Bluetooth.", "reticulumStackTitle": "Zásobník Reticulum", "reticulumStackHint": "Spustí lokální sidecar Reticulum/LXMF (rozhraní TCP a Auto).", "reticulumStartStack": "Spustit zásobník", @@ -1261,19 +1257,27 @@ "coloradomesh": "Colorado Mesh", "meshmapper": "MeshMapper", "ripple": "Ripple Networks", - "custom": "Zvyk" + "custom": "Zvyk", + "waev": "Waev", + "meshatse": "Meshat.se", + "meshcoreca": "MeshCore.CA", + "eastmesh": "EastMesh" }, "meshcorePresetDeviation": { "letsmesh": "LetsMesh potřebuje WebSocket na portu 443 a server mqtt-us-v1.letsmesh.net nebo mqtt-eu-v1.letsmesh.net. Použijte Region (US/EU), nebo přepněte na Custom pro ostatní brokery.", "coloradomesh": "Colorado Mesh potřebuje WebSocket na portu 443 s povoleným TLS, server mqtt.meshcore.coloradomesh.org, cestu /ws. Resetujte předvolbu nebo přepněte na Vlastní pro ostatní brokery.", - "meshmapper": "MeshMapper potřebuje WebSocket na portu 443 a server mqtt.meshmapper.net. Resetujte předvolbu nebo přepněte na Vlastní pro ostatní brokery." + "meshmapper": "MeshMapper potřebuje WebSocket na portu 443 a server mqtt.meshmapper.net. Resetujte předvolbu nebo přepněte na Vlastní pro ostatní brokery.", + "waev": "Waev potřebuje WebSocket na portu 443 s povoleným TLS, server mqtt.waev.app. Resetujte předvolbu nebo přepněte na možnost Vlastní pro ostatní makléře.", + "meshatse": "Meshat.se potřebuje WebSocket na portu 443 s povoleným TLS, server meshcore-mqtt.meshat.se. Resetujte předvolbu nebo přepněte na možnost Vlastní pro ostatní makléře.", + "meshcoreca": "MeshCore.CA potřebuje WebSocket na portu 443 s povoleným TLS, serverem mqtt1.meshcore.ca nebo mqtt2.meshcore.ca. Použijte Broker (Primární/Zálohování) nebo přepněte na Vlastní pro ostatní makléře.", + "eastmesh": "EastMesh potřebuje WebSocket na portu 443 s povoleným TLS, server mqtt2.eastmesh.au. Resetujte předvolbu nebo přepněte na možnost Vlastní pro ostatní makléře." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Auth token (formát meshcore-decoder) bude vygenerován, když se připojíte. Uživatelské jméno je v1_ plus váš 64místný veřejný klíč (hex). Publikum JWT odpovídá názvu hostitele serveru.", "noPrivateKey": "Žádná úplná identita – importujte svou konfiguraci MeshCore do panelu Rádio (veřejné a soukromé klíče) nebo vložte uživatelské jméno (v1_) a token ručně. Publikum JWT v tokenu musí odpovídat názvu hostitele serveru.", "publicKeyMissing": "V identitě chybí veřejný klíč. Importujte svůj JSON konfigurace MeshCore na panelu Rádio (musí obsahovat veřejný a soukromý klíč) nebo vložte token zprostředkovatele do pole hesla.", - "usernameBuildFailed": "Uživatelské jméno LetsMesh se nepodařilo vytvořit. Importujte svůj konfigurační JSON MeshCore do panelu Rádio nebo vložte uživatelské jméno (v1_) a token ručně.", - "noIdentity": "Nebyla nalezena žádná identita zařízení. Importujte svůj JSON konfigurace MeshCore na panelu Rádio nebo vložte uživatelské jméno a token ručně." + "noIdentity": "Nebyla nalezena žádná identita zařízení. Importujte svůj JSON konfigurace MeshCore na panelu Rádio nebo vložte uživatelské jméno a token ručně.", + "usernameBuildFailed": "Nelze vytvořit uživatelské jméno MeshCore MQTT. Importujte konfiguraci MeshCore JSON na panelu Rádio nebo vložte uživatelské jméno (v1_) a token ručně." }, "meshcorePacketLogger": { "label": "Záznamník paketů – po připojení k rádiu a MQTT přepošle souhrny paketů RX do {{topic}} ve stejném tvaru JSON jako meshcoretomqtt. Ve výchozím nastavení vypnuto; povolit pouze v případě, že máte v úmyslu sdílet slyšený letový provoz s analyzátorem paketů." @@ -1343,9 +1347,6 @@ "disableShareAria": "Vypněte možnost Sdílet instanci a restartujte zásobník", "disableFailed": "Sdílení instance se nezdařilo vypnout." }, - "letsMeshRequiresWebSocket": "LetsMesh vyžaduje WebSocket transport.", - "letsMeshRequiresPort": "LetsMesh vyžaduje port {{port}}.", - "letsMeshKnownBrokersOnly": "Přednastavení LetsMesh /MeshMapper podporuje pouze známé makléře podepisující zařízení. Použít vlastní pro ostatní makléře.", "letsMeshUsernameV1Hex": "Uživatelské jméno musí být v1_ následované 64 hexadecimálními znaky (veřejný klíč).", "bleWeakSignalWarning": "Signál Bluetooth je slabý ({{rssi}} dBm). Přibližte se pro stabilní připojení.", "bleRssiDbm": "{{rssi}} dBm", @@ -1354,7 +1355,18 @@ "linkQuality": "Kvalita spoje", "linkQualityMs": "{{ms}} ms", "linkQualityUnavailable": "—", - "signalUnavailableWebBluetooth": "Nedostupné (webový Bluetooth)" + "signalUnavailableWebBluetooth": "Nedostupné (webový Bluetooth)", + "meshcoreCaBroker": "Makléř MeshCore.CA", + "meshcoreCaPrimary": "Primární", + "meshcoreCaBackup": "Záloha", + "broker": "Makléř", + "letsMeshRequiresTls": "Tato předvolba makléře vyžaduje TLS (wss). Povolit TLS nebo přepnout na vlastní.", + "letsMeshRequiresWsPath": "Tato předvolba makléře vyžaduje cestu WebSocket {{wsPath}}. Resetujte předvolbu nebo přepněte na vlastní.", + "letsMeshRegionUs": "U.S.A.", + "letsMeshRegionEu": "EU", + "letsMeshRequiresWebSocket": "Tato předvolba makléře vyžaduje přenos WebSocket (wss).", + "letsMeshRequiresPort": "Tato předvolba makléře vyžaduje port {{port}} (WebSocket TLS).", + "letsMeshKnownBrokersOnly": "Tato předvolba podporuje pouze známé veřejné makléře MeshCore MQTT. Přepnout na vlastní pro ostatní makléře." }, "contactGroupsModal": { "failedLoadMembers": "Nepodařilo se načíst členy: {{message}}", @@ -5325,7 +5337,8 @@ "actionFailed": "Akce se nezdařila: {{reason}}", "unknownReason": "Neznámý důvod", "resendFailed": "Opětovné odeslání se nezdařilo.", - "deleteFailed": "Odstranění relace se nezdařilo." + "deleteFailed": "Odstranění relace se nezdařilo.", + "sessionExpired": "Tato hra vypršela po období nečinnosti a již ji nelze hrát. Použijte Smazat relaci pro její odstranění." }, "deleteConfirmTitle": "Smazat relaci?", "deleteConfirmMessage": "Odebrat tuto herní relaci ze seznamu? Tuto akci nelze vrátit zpět.", @@ -5339,6 +5352,7 @@ "propagated": "Doručená na Vyzvedni.to a připravená k výdeji", "failed": "Je třeba to zkusit znovu" }, - "drawOfferWaitingBanner": "Nabídka byla odeslána. Čeká se na soupeře..." + "drawOfferWaitingBanner": "Nabídka byla odeslána. Čeká se na soupeře...", + "idleExpiryNotice": "Platnost her vyprší po nečinnosti a poté je lze pouze smazat. Nezodpovězené výzvy: 24 hodin (všechny hry). Aktivní hry: tic-tac-toe po 24 hodinách, šachy a čtyři v řadě po 7 dnech." } } diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 8243aecf2..6adf83726 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -907,8 +907,6 @@ "pairingFailed": "Kopplung fehlgeschlagen: {{msg}}", "pinPairingFailed": "PIN-Kopplung fehlgeschlagen: {{msg}}", "pinAcceptedNext": "PIN akzeptiert. Kopplung abgeschlossen. Drücken Sie auf „Verbinden“ und wählen Sie Ihr Gerät aus, um die Verbindung abzuschließen.", - "autoConnectTimeout": "Zeitüberschreitung bei der automatischen Verbindung.", - "autoConnectFailed": "Automatische Verbindung fehlgeschlagen", "connectionFailed": "Verbindung fehlgeschlagen" }, "cancelMqttConnect": "Stornieren", @@ -921,8 +919,6 @@ "mqttTlsEnabled": "TLS aktivieren (mqtts / wss)", "mqttTlsInsecure": "Unsichere TLS (selbstsigniertes Zertifikat) zulassen. Standardmäßig deaktiviert — nur aktivieren, wenn Ihr Broker eine nicht öffentliche CA verwendet.", "channelPsksPrivateUplinkNote": "Kanal-Uplink ist AES-PSK-verschlüsselt auf /2/e/ (kein JSON-Spiegel für private Schlüssel). Broker-Monitore können immer noch Fehler bei der Entschlüsselung des PKI/DM-Verkehrs zwischen anderen Knoten protokollieren — das ist normal und hat nichts mit Kanal-PSKs zu tun.", - "sharedBleWithMeshtastic": "Dieses Bluetooth-Gerät ist bereits über Meshtastic verbunden. Wechseln Sie zur Registerkarte Meshtastic, um sie zu verwenden, oder wählen Sie ein anderes Radio oder einen anderen Transport für MeshCore.", - "sharedBleWithMeshtasticHint": "Ein physisches Funkgerät kann keine separaten Meshtastic- und MeshCore-Bluetooth-Sitzungen gleichzeitig durchführen.", "reticulumStackTitle": "Reticulum-Stack", "reticulumStackHint": "Startet den lokalen Reticulum/LXMF-Sidecar (TCP- und Auto-Schnittstellen).", "reticulumStartStack": "Stack starten", @@ -1258,19 +1254,27 @@ "coloradomesh": "Colorado Mesh", "meshmapper": "MeshMapper", "ripple": "Ripple Networks", - "custom": "Brauch" + "custom": "Brauch", + "waev": "Waev", + "meshatse": "Meshat.se", + "meshcoreca": "MeshCore.CA", + "eastmesh": "EastMesh" }, "meshcorePresetDeviation": { "letsmesh": "LetsMesh benötigt WebSocket auf Port 443 und den Server mqtt-us-v1.letsmesh.net oder mqtt-eu-v1.letsmesh.net. Verwenden Sie die Region (USA/EU) oder wechseln Sie für andere Broker zu „Benutzerdefiniert“.", "coloradomesh": "Colorado Mesh benötigt WebSocket auf Port 443 mit aktiviertem TLS, Server mqtt.meshcore.coloradomesh.org, Pfad /ws. Setzen Sie die Voreinstellung zurück oder wechseln Sie für andere Broker zu „Benutzerdefiniert“.", - "meshmapper": "MeshMapper benötigt WebSocket auf Port 443 und den Server mqtt.meshmapper.net. Setzen Sie die Voreinstellung zurück oder wechseln Sie für andere Broker zu „Benutzerdefiniert“." + "meshmapper": "MeshMapper benötigt WebSocket auf Port 443 und den Server mqtt.meshmapper.net. Setzen Sie die Voreinstellung zurück oder wechseln Sie für andere Broker zu „Benutzerdefiniert“.", + "waev": "Waev benötigt WebSocket auf Port 443 mit aktiviertem TLS, Server mqtt.waev.app. Setzen Sie die Voreinstellung zurück oder wechseln Sie zu Benutzerdefiniert für andere Broker.", + "meshatse": "Meshat.se benötigt WebSocket auf Port 443 mit aktiviertem TLS, Server meshcore-mqtt.meshat.se. Setzen Sie die Voreinstellung zurück oder wechseln Sie zu Benutzerdefiniert für andere Broker.", + "meshcoreca": "MeshCore.CA benötigt WebSocket auf Port 443 mit aktiviertem TLS, Server mqtt1.meshcore.ca oder mqtt2.meshcore.ca. Verwenden Sie Broker (Primär/Backup) oder wechseln Sie für andere Broker zu Benutzerdefiniert.", + "eastmesh": "EastMesh benötigt WebSocket auf Port 443 mit aktiviertem TLS, Server mqtt2.eastmesh.au. Setzen Sie die Voreinstellung zurück oder wechseln Sie zu Benutzerdefiniert für andere Broker." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Auth-Token (Meshcore-Decoder-Format) wird generiert, wenn Sie eine Verbindung herstellen. Der Benutzername ist v1_ plus Ihr 64-stelliger öffentlicher Schlüssel (hex). Die JWT-Zielgruppe entspricht dem Server-Hostnamen.", "noPrivateKey": "Keine vollständige Identität – importieren Sie Ihre MeshCore-Konfiguration im Radio-Panel (öffentliche und private Schlüssel) oder fügen Sie Benutzernamen (v1_<öffentlicher Schlüssel>) und Token manuell ein. Die JWT-Zielgruppe im Token muss mit dem Server-Hostnamen übereinstimmen.", "publicKeyMissing": "Öffentlicher Schlüssel fehlt in der Identität. Importieren Sie Ihre MeshCore-Konfigurations-JSON im Radio-Panel (muss öffentliche und private Schlüssel enthalten) oder fügen Sie ein Broker-Token in das Passwortfeld ein.", - "usernameBuildFailed": "Der LetsMesh-Benutzername konnte nicht erstellt werden. Importieren Sie Ihre MeshCore-Konfigurations-JSON in das Radio-Bedienfeld oder fügen Sie den Benutzernamen (v1_<öffentlicher Schlüssel>) und das Token manuell ein.", - "noIdentity": "Keine Geräteidentität gefunden. Importieren Sie Ihre MeshCore-Konfigurations-JSON in das Radio-Bedienfeld oder fügen Sie Benutzernamen und Token manuell ein." + "noIdentity": "Keine Geräteidentität gefunden. Importieren Sie Ihre MeshCore-Konfigurations-JSON in das Radio-Bedienfeld oder fügen Sie Benutzernamen und Token manuell ein.", + "usernameBuildFailed": "Der MeshCore MQTT-Benutzername konnte nicht erstellt werden. Importieren Sie Ihre MeshCore-Konfiguration json in das Radio-Panel oder fügen Sie den Benutzernamen (v1_) und das Token manuell ein." }, "meshcorePacketLogger": { "label": "Paketlogger – wenn Sie mit Ihrem Radio und MQTT verbunden sind, leiten Sie RX-Paketzusammenfassungen an {{topic}} in derselben JSON-Form wie meshcoretomqtt weiter. Standardmäßig deaktiviert; Aktivieren Sie diese Funktion nur, wenn Sie den abgehörten Flugverkehr mit einem Paketanalysator teilen möchten." @@ -1341,9 +1345,6 @@ "disableShareAria": "Deaktivieren Sie die Share-Instanz und starten Sie den Stack neu", "disableFailed": "Fehler beim Deaktivieren der Share-Instanz." }, - "letsMeshRequiresWebSocket": "LetsMesh erfordert WebSocket-Transport.", - "letsMeshRequiresPort": "LetsMesh erfordert Port {{port}}.", - "letsMeshKnownBrokersOnly": "Die LetsMesh /MeshMapper-Voreinstellung unterstützt nur bekannte Gerätesignatur-Broker. Verwenden Sie Custom für andere Broker.", "letsMeshUsernameV1Hex": "Der Benutzername muss v1_ gefolgt von 64 Hexadezimalzeichen (öffentlicher Schlüssel) sein.", "bleWeakSignalWarning": "Bluetooth-Signal ist schwach ({{rssi}} dBm). Nähern Sie sich für eine stabile Verbindung.", "bleRssiDbm": "{{rssi}} dBm", @@ -1352,7 +1353,18 @@ "linkQuality": "Verbindungsqualität", "linkQualityMs": "{{ms}} ms", "linkQualityUnavailable": "—", - "signalUnavailableWebBluetooth": "Nicht verfügbar (Web-Bluetooth)" + "signalUnavailableWebBluetooth": "Nicht verfügbar (Web-Bluetooth)", + "meshcoreCaBroker": "MeshCore.CA Broker", + "meshcoreCaPrimary": "Hauptversicherter", + "meshcoreCaBackup": "Backup", + "broker": "Makler", + "letsMeshRequiresTls": "Diese Broker-Voreinstellung erfordert TLS (wss). Aktivieren Sie TLS oder wechseln Sie zu Benutzerdefiniert.", + "letsMeshRequiresWsPath": "Diese Broker-Voreinstellung erfordert WebSocket-Pfad {{wsPath}}. Setzen Sie die Voreinstellung zurück oder wechseln Sie zu Benutzerdefiniert.", + "letsMeshRegionUs": "USA", + "letsMeshRegionEu": "EU", + "letsMeshRequiresWebSocket": "Diese Broker-Voreinstellung erfordert WebSocket-Transport (wss).", + "letsMeshRequiresPort": "Diese Broker-Voreinstellung erfordert Port {{port}} (WebSocket TLS).", + "letsMeshKnownBrokersOnly": "Diese Voreinstellung unterstützt nur bekannte öffentliche MeshCore MQTT-Broker. Wechseln Sie für andere Broker zu Benutzerdefiniert." }, "contactGroupsModal": { "failedLoadMembers": "Mitglieder konnten nicht geladen werden: {{message}}", @@ -5323,7 +5335,8 @@ "actionFailed": "Aktion fehlgeschlagen: {{reason}}", "unknownReason": "Unbekannter Grund", "resendFailed": "Erneutes Senden fehlgeschlagen.", - "deleteFailed": "Die Recycling-Sitzung konnte nicht gelöscht werden." + "deleteFailed": "Die Recycling-Sitzung konnte nicht gelöscht werden.", + "sessionExpired": "Dieses Spiel ist nach einer Zeit der Inaktivität abgelaufen und kann nicht mehr gespielt werden. Verwenden Sie Sitzung löschen, um sie zu entfernen." }, "deleteConfirmTitle": "Löschen sitzung?", "deleteConfirmMessage": "Diese Spielsitzung von Ihrer Liste entfernen? Dies kann nicht rückgängig gemacht werden.", @@ -5337,6 +5350,7 @@ "propagated": "Gespeichert", "failed": "Erneuter Versuch erforderlich" }, - "drawOfferWaitingBanner": "Angebot ziehen gesendet. Warten auf Gegner..." + "drawOfferWaitingBanner": "Angebot ziehen gesendet. Warten auf Gegner...", + "idleExpiryNotice": "Spiele laufen nach Inaktivität ab und können dann nur noch gelöscht werden. Unbeantwortete Herausforderungen: 24 Stunden (alle Spiele). Aktive Partien: Tic-Tac-Toe nach 24 Stunden, Schach und Four-in-a-row nach 7 Tagen." } } diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 810ce8c13..d039d4928 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -806,9 +806,11 @@ "connectionPanel": { "maxReconnectAttempts": "Max MQTT reconnect attempts", "letsMeshRegion": "LetsMesh region", - "letsMeshRequiresWebSocket": "LetsMesh requires WebSocket transport.", - "letsMeshRequiresPort": "LetsMesh requires port {{port}}.", - "letsMeshKnownBrokersOnly": "LetsMesh / MeshMapper preset only supports known device-signing brokers. Use Custom for other brokers.", + "letsMeshRequiresWebSocket": "This broker preset requires WebSocket transport (wss).", + "letsMeshRequiresPort": "This broker preset requires port {{port}} (WebSocket TLS).", + "letsMeshKnownBrokersOnly": "This preset only supports known public MeshCore MQTT brokers. Switch to Custom for other brokers.", + "letsMeshRequiresTls": "This broker preset requires TLS (wss). Enable TLS or switch to Custom.", + "letsMeshRequiresWsPath": "This broker preset requires WebSocket path {{wsPath}}. Reset the preset or switch to Custom.", "letsMeshUsernameV1Hex": "Username must be v1_ followed by 64 hex characters (public key).", "ripplePresetConfirm": "The Ripple preset uses shared community credentials and disables TLS certificate verification. Only use this on a trusted network. Continue?", "coloradoPresetConfirm": "Colorado Mesh MQTT is for Colorado-area mesh users. The preset publishes under meshcore/DEN (Denver IATA). If you are not in Colorado, use LetsMesh or MeshMapper instead. Continue?", @@ -857,6 +859,8 @@ "quit": "Quit", "networkPreset": "Network Preset", "region": "Region", + "letsMeshRegionUs": "US", + "letsMeshRegionEu": "EU", "port": "Port", "useWebSocket": "Use WebSocket transport", "wsRequired": "(required for port 443)", @@ -880,7 +884,7 @@ "meshtastic": "Both protocols allow 1–{{max}}. Saved with settings; disconnect and Connect again so the running session uses the new value." }, "meshtasticPreset": { - "officialPlain": "MQTT :1883", + "officialPlain": "Official", "liam": "Liam's", "custom": "Custom" }, @@ -1261,8 +1265,6 @@ "serialDevice": "Serial device", "wifiDevice": "WiFi device", "reconnect": "Reconnect", - "sharedBleWithMeshtastic": "This Bluetooth device is already connected via Meshtastic. Switch to the Meshtastic tab to use it, or choose a different radio or transport for MeshCore.", - "sharedBleWithMeshtasticHint": "One physical radio cannot hold separate Meshtastic and MeshCore Bluetooth sessions at the same time.", "forgetDevice": "Forget this device", "bluetooth": "Bluetooth", "usbSerial": "USB Serial", @@ -1284,19 +1286,31 @@ "letsmesh": "LetsMesh", "coloradomesh": "Colorado Mesh", "meshmapper": "MeshMapper", + "waev": "Waev", + "meshatse": "Meshat.se", + "meshcoreca": "MeshCore.CA", + "eastmesh": "EastMesh", "ripple": "Ripple Networks", "custom": "Custom" }, + "meshcoreCaBroker": "MeshCore.CA broker", + "meshcoreCaPrimary": "Primary", + "meshcoreCaBackup": "Backup", + "broker": "Broker", "meshcorePresetDeviation": { "letsmesh": "LetsMesh needs WebSocket on port 443 and server mqtt-us-v1.letsmesh.net or mqtt-eu-v1.letsmesh.net. Use Region (US/EU), or switch to Custom for other brokers.", "coloradomesh": "Colorado Mesh needs WebSocket on port 443 with TLS enabled, server mqtt.meshcore.coloradomesh.org, path /ws. Reset the preset or switch to Custom for other brokers.", - "meshmapper": "MeshMapper needs WebSocket on port 443 and server mqtt.meshmapper.net. Reset the preset or switch to Custom for other brokers." + "meshmapper": "MeshMapper needs WebSocket on port 443 and server mqtt.meshmapper.net. Reset the preset or switch to Custom for other brokers.", + "waev": "Waev needs WebSocket on port 443 with TLS enabled, server mqtt.waev.app, path /mqtt. Reset the preset or switch to Custom for other brokers.", + "meshatse": "Meshat.se needs WebSocket on port 443 with TLS enabled, server meshcore-mqtt.meshat.se, path /mqtt. Reset the preset or switch to Custom for other brokers.", + "meshcoreca": "MeshCore.CA needs WebSocket on port 443 with TLS enabled, server mqtt1.meshcore.ca or mqtt2.meshcore.ca, path /mqtt. Use Broker (Primary/Backup), or switch to Custom for other brokers.", + "eastmesh": "EastMesh needs WebSocket on port 443 with TLS enabled, server mqtt2.eastmesh.au, path /mqtt. Reset the preset or switch to Custom for other brokers." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Auth token (meshcore-decoder format) will be generated when you connect. Username is v1_ plus your 64-character public key (hex). JWT audience matches the Server hostname.", "noPrivateKey": "No full identity — import your MeshCore config in the Radio panel (public and private keys), or paste username (v1_) and token manually. JWT audience in the token must match the Server hostname.", "publicKeyMissing": "Public key missing from identity. Import your MeshCore config JSON in the Radio panel (must include public and private keys), or paste a broker token in the password field.", - "usernameBuildFailed": "Could not build LetsMesh username. Import your MeshCore config JSON in the Radio panel, or paste username (v1_) and token manually.", + "usernameBuildFailed": "Could not build the MeshCore MQTT username. Import your MeshCore config JSON in the Radio panel, or paste username (v1_) and token manually.", "noIdentity": "No device identity found. Import your MeshCore config JSON in the Radio panel, or paste username and token manually." }, "meshcorePacketLogger": { @@ -1393,8 +1407,6 @@ "pairingFailed": "Pairing failed: {{msg}}", "pinPairingFailed": "PIN pairing failed: {{msg}}", "pinAcceptedNext": "PIN accepted. Pairing completed. Press Connect and select your device to finish connecting.", - "autoConnectTimeout": "Auto-connect timed out.", - "autoConnectFailed": "Auto-connect failed", "connectionFailed": "Connection failed" } }, @@ -1939,6 +1951,7 @@ "selectAppAria": "Select game", "sendChallengeAria": "Send challenge", "sendChallenge": "Send challenge", + "idleExpiryNotice": "Games expire after inactivity and can then only be deleted. Unanswered challenges: 24 hours (all games). Active games: tic-tac-toe after 24 hours, chess and four-in-a-row after 7 days.", "selectSessionPrompt": "Select a game session to play.", "opponentLabel": "Opponent: {{peer}}", "acceptAria": "Accept challenge", @@ -2027,7 +2040,8 @@ "actionFailed": "Action failed: {{reason}}", "unknownReason": "unknown reason", "resendFailed": "Resend failed.", - "deleteFailed": "Failed to delete session." + "deleteFailed": "Failed to delete session.", + "sessionExpired": "This game expired after a period of inactivity and can no longer be played. Use Delete session to remove it." } }, "identityVault": { diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index dfe90d8b5..4f17c368f 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -909,8 +909,6 @@ "pairingFailed": "Error de emparejamiento: {{msg}}", "pinPairingFailed": "Error al emparejar el PIN: {{msg}}", "pinAcceptedNext": "PIN aceptado. Emparejamiento completado. Presione Conectar y seleccione su dispositivo para finalizar la conexión.", - "autoConnectTimeout": "Se agotó el tiempo de espera de la conexión automática.", - "autoConnectFailed": "Falló la conexión automática", "connectionFailed": "La conexión falló" }, "cancelMqttConnect": "Cancelar", @@ -923,8 +921,6 @@ "mqttTlsEnabled": "Habilitar TLS (mqtts / wss)", "mqttTlsInsecure": "Permitir TLS (certificado autofirmado) inseguro. Desactivado de forma predeterminada: solo habilítalo si tu bróker utiliza una CA no pública.", "channelPsksPrivateUplinkNote": "El enlace ascendente del canal está cifrado AES-PSK en/2/e/ (sin espejo JSON para claves privadas). Los monitores de bróker aún pueden registrar fallas de descifrado para el tráfico PKI/DM entre otros nodos, lo que es normal y no está relacionado con los PSK de canal aquí.", - "sharedBleWithMeshtastic": "Este dispositivo Bluetooth ya está conectado a través de Meshtastic. Cambia a la pestaña Meshtastic para usarla, o elige una radio o transporte diferente para MeshCore.", - "sharedBleWithMeshtasticHint": "Una radio física no puede mantener sesiones separadas de Meshtastic y MeshCore Bluetooth al mismo tiempo.", "reticulumStackTitle": "Pila Reticulum", "reticulumStackHint": "Inicia el sidecar local Reticulum/LXMF (interfaces TCP y Auto).", "reticulumStartStack": "Iniciar pila", @@ -1260,19 +1256,27 @@ "coloradomesh": "Colorado Mesh", "meshmapper": "MeshMapper", "ripple": "Ripple Networks", - "custom": "Costumbre" + "custom": "Costumbre", + "waev": "Waev", + "meshatse": "Meshat.se", + "meshcoreca": "MeshCore.CA", + "eastmesh": "EastMesh" }, "meshcorePresetDeviation": { "letsmesh": "LetsMesh necesita WebSocket en el puerto 443 y el servidor mqtt-us-v1.letsmesh.net o mqtt-eu-v1.letsmesh.net. Utilice Región (EE. UU./UE) o cambie a Personalizado para otros corredores.", "coloradomesh": "Colorado Mesh necesita WebSocket en el puerto 443 con TLS habilitado, servidor mqtt.meshcore.coloradomesh.org, ruta /ws. Restablezca el ajuste preestablecido o cambie a Personalizado para otros corredores.", - "meshmapper": "MeshMapper necesita WebSocket en el puerto 443 y el servidor mqtt.meshmapper.net. Restablezca el ajuste preestablecido o cambie a Personalizado para otros corredores." + "meshmapper": "MeshMapper necesita WebSocket en el puerto 443 y el servidor mqtt.meshmapper.net. Restablezca el ajuste preestablecido o cambie a Personalizado para otros corredores.", + "waev": "Waev necesita WebSocket en el puerto 443 con TLS habilitado, servidor mqtt.waev.app. Restablezca el ajuste preestablecido o cambie a Personalizado para otros brókers.", + "meshatse": "Meshat.se necesita WebSocket en el puerto 443 con TLS habilitado, servidor meshcore-mqtt.meshat.se. Restablezca el ajuste preestablecido o cambie a Personalizado para otros brókers.", + "meshcoreca": "MeshCore.CA necesita WebSocket en el puerto 443 con TLS habilitado, servidor mqtt1.meshcore.ca o mqtt2.meshcore.ca. Utilice Bróker (Primario/Copia de seguridad) o cambie a Personalizado para otros brókers.", + "eastmesh": "EastMesh necesita WebSocket en el puerto 443 con TLS habilitado, servidor mqtt2.eastmesh.au. Restablezca el ajuste preestablecido o cambie a Personalizado para otros brókers." }, "meshcoreMqttIdentity": { "hasPrivateKey": "El token de autenticación (formato meshcore-decoder) se generará cuando se conecte. El nombre de usuario es v1_ más su clave pública de 64 caracteres (hexadecimal). La audiencia de JWT coincide con el nombre de host del servidor.", "noPrivateKey": "Sin identidad completa: importe su configuración de MeshCore en el panel Radio (claves públicas y privadas) o pegue el nombre de usuario (v1_) y el token manualmente. La audiencia JWT en el token debe coincidir con el nombre de host del servidor.", "publicKeyMissing": "Falta clave pública en la identidad. Importe su JSON de configuración de MeshCore en el panel Radio (debe incluir claves públicas y privadas) o pegue un token de corredor en el campo de contraseña.", - "usernameBuildFailed": "No se pudo crear el nombre de usuario de LetsMesh. Importe su JSON de configuración de MeshCore en el panel Radio o pegue el nombre de usuario (v1_) y el token manualmente.", - "noIdentity": "No se encontró ninguna identidad del dispositivo. Importe su JSON de configuración de MeshCore en el panel Radio o pegue el nombre de usuario y el token manualmente." + "noIdentity": "No se encontró ninguna identidad del dispositivo. Importe su JSON de configuración de MeshCore en el panel Radio o pegue el nombre de usuario y el token manualmente.", + "usernameBuildFailed": "No se ha podido crear el nombre de usuario MQTT de MeshCore. Importe su JSON de configuración de MeshCore en el panel de Radio, o pegue el nombre de usuario (v1_) y el token manualmente." }, "meshcorePacketLogger": { "label": "Registrador de paquetes: cuando esté conectado a su radio y MQTT, reenvíe resúmenes de paquetes RX a {{topic}} en la misma forma JSON que meshcoretomqtt. Desactivado de forma predeterminada; habilítelo solo si tiene la intención de compartir el tráfico aéreo escuchado con un analizador de paquetes." @@ -1341,9 +1345,6 @@ "disableShareAria": "Desactivar la instancia Compartir y reiniciar la pila", "disableFailed": "No se ha podido desactivar la instancia Compartir." }, - "letsMeshRequiresWebSocket": "LetsMesh requiere transporte WebSocket.", - "letsMeshRequiresPort": "LetsMesh requiere el puerto {{port}}.", - "letsMeshKnownBrokersOnly": "El ajuste preestablecido de LetsMesh/MeshMapper solo admite brókers de firma de dispositivos conocidos. Utilice Personalizado para otros brókers.", "letsMeshUsernameV1Hex": "El nombre de usuario debe ser v1_ seguido de 64 caracteres hexadecimales (clave pública).", "bleWeakSignalWarning": "La señal Bluetooth es débil ({{rssi}} dBm). Acérquese para una conexión estable.", "bleRssiDbm": "{{rssi}} dBm", @@ -1352,7 +1353,18 @@ "linkQuality": "Calidad de enlace", "linkQualityMs": "{{ms}} ms", "linkQualityUnavailable": "—", - "signalUnavailableWebBluetooth": "No disponible (Bluetooth web)" + "signalUnavailableWebBluetooth": "No disponible (Bluetooth web)", + "meshcoreCaBroker": "Bróker MeshCore.CA", + "meshcoreCaPrimary": "Principal", + "meshcoreCaBackup": "Respaldo", + "broker": "Corredor de Seguros", + "letsMeshRequiresTls": "Este ajuste preestablecido del bróker requiere TLS (wss). Habilite TLS o cambie a Personalizado.", + "letsMeshRequiresWsPath": "Este valor predefinido de bróker requiere la ruta de WebSocket {{wsPath}}. Restablezca el ajuste preestablecido o cambie a Personalizado.", + "letsMeshRegionUs": "US", + "letsMeshRegionEu": "EU", + "letsMeshRequiresWebSocket": "Este preajuste de bróker requiere transporte WebSocket (wss).", + "letsMeshRequiresPort": "Este ajuste preestablecido del bróker requiere el puerto {{port}} (WebSocket TLS).", + "letsMeshKnownBrokersOnly": "Este ajuste preestablecido solo admite brókers MQTT MeshCore públicos conocidos. Cambie a Personalizado para otros brókers." }, "contactGroupsModal": { "failedLoadMembers": "No se han podido cargar los miembros: {{message}}", @@ -5323,7 +5335,8 @@ "actionFailed": "Acción fallida: {{reason}}", "unknownReason": "razón desconocida", "resendFailed": "Fallo en el reenvío", - "deleteFailed": "Error al eliminar la sesión." + "deleteFailed": "Error al eliminar la sesión.", + "sessionExpired": "Este juego ha caducado tras un periodo de inactividad y ya no se puede jugar. Utilice Eliminar sesión para eliminarla." }, "deleteConfirmTitle": "¿Eliminar sesión?", "deleteConfirmMessage": "¿Quieres eliminar esta sesión de juego de tu lista? Esto no se puede deshacer.", @@ -5337,6 +5350,7 @@ "propagated": "Almacenado", "failed": "Se necesita volver a intentarlo" }, - "drawOfferWaitingBanner": "Oferta de sorteo enviada. Esperando al oponente..." + "drawOfferWaitingBanner": "Oferta de sorteo enviada. Esperando al oponente...", + "idleExpiryNotice": "Los juegos caducan después de la inactividad y solo se pueden eliminar. Desafíos sin respuesta: 24 horas (todos los juegos). Partidas activas: tic-tac-toe después de 24 horas, ajedrez y cuatro en fila después de 7 días." } } diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 5168ce51f..dd2037bb4 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -908,8 +908,6 @@ "pairingFailed": "Échec du couplage : {{msg}}", "pinPairingFailed": "Échec de l'association du code PIN : {{msg}}", "pinAcceptedNext": "Code PIN accepté. Appairage terminé. Appuyez sur Connecter et sélectionnez votre appareil pour terminer la connexion.", - "autoConnectTimeout": "La connexion automatique a expiré.", - "autoConnectFailed": "Échec de la connexion automatique", "connectionFailed": "La connexion a échoué" }, "cancelMqttConnect": "Annuler", @@ -922,8 +920,6 @@ "mqttTlsEnabled": "Activer TLS (mqtts / wss)", "mqttTlsInsecure": "Autoriser les TLS non sécurisés (certificat auto-signé). Désactivé par défaut — activer uniquement si votre courtier utilise une autorité de certification non publique.", "channelPsksPrivateUplinkNote": "La liaison montante du canal est chiffrée AES-PSK sur /2/e/ (pas de miroir JSON pour les clés privées). Les moniteurs de courtier peuvent toujours enregistrer les échecs de décryptage du trafic PKI/DM entre d'autres nœuds — c'est normal et sans rapport avec les PSK de canal ici.", - "sharedBleWithMeshtastic": "Cet appareil Bluetooth est déjà connecté via Meshtastic. Passez à l'onglet Meshtastic pour l'utiliser, ou choisissez une radio ou un transport différent pour MeshCore.", - "sharedBleWithMeshtasticHint": "Une radio physique ne peut pas tenir des sessions Bluetooth Meshtastic et MeshCore séparées en même temps.", "reticulumStackTitle": "Pile Reticulum", "reticulumStackHint": "Démarre le sidecar local Reticulum/LXMF (interfaces TCP et Auto).", "reticulumStartStack": "Démarrer la pile", @@ -1259,19 +1255,27 @@ "coloradomesh": "Colorado Mesh", "meshmapper": "MeshMapper", "ripple": "Ripple Networks", - "custom": "Coutume" + "custom": "Coutume", + "waev": "Waev", + "meshatse": "Meshat.se", + "meshcoreca": "MeshCore.CA", + "eastmesh": "EastMesh" }, "meshcorePresetDeviation": { "letsmesh": "LetsMesh a besoin de WebSocket sur le port 443 et du serveur mqtt-us-v1.letsmesh.net ou mqtt-eu-v1.letsmesh.net. Utilisez Région (États-Unis/UE) ou passez à Personnalisé pour les autres courtiers.", "coloradomesh": "Colorado Mesh a besoin de WebSocket sur le port 443 avec TLS activé, serveur mqtt.meshcore.coloradomesh.org, chemin /ws. Réinitialisez le préréglage ou passez à Personnalisé pour les autres courtiers.", - "meshmapper": "MeshMapper a besoin de WebSocket sur le port 443 et du serveur mqtt.meshmapper.net. Réinitialisez le préréglage ou passez à Personnalisé pour les autres courtiers." + "meshmapper": "MeshMapper a besoin de WebSocket sur le port 443 et du serveur mqtt.meshmapper.net. Réinitialisez le préréglage ou passez à Personnalisé pour les autres courtiers.", + "waev": "Waev a besoin de WebSocket sur le port 443 avec TLS activé, serveur mqtt.waev.app. Réinitialisez le préréglage ou passez à Personnalisé pour les autres courtiers.", + "meshatse": "Meshat.se nécessite WebSocket sur le port 443 avec TLS activé, serveur meshcore-mqtt.meshat.se. Réinitialisez le préréglage ou passez à Personnalisé pour les autres courtiers.", + "meshcoreca": "MeshCore.CA a besoin de WebSocket sur le port 443 avec TLS activé, serveur mqtt1.meshcore.ca ou mqtt2.meshcore.ca. Utilisez Courtier (Principal/Sauvegarde) ou passez à Personnalisé pour les autres courtiers.", + "eastmesh": "EastMesh a besoin de WebSocket sur le port 443 avec TLS activé, serveur mqtt2.eastmesh.au. Réinitialisez le préréglage ou passez à Personnalisé pour les autres courtiers." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Le jeton d'authentification (format meshcore-decoder) sera généré lors de votre connexion. Le nom d'utilisateur est v1_ plus votre clé publique de 64 caractères (hexadécimal). L'audience JWT correspond au nom d'hôte du serveur.", "noPrivateKey": "Pas d'identité complète : importez votre configuration MeshCore dans le panneau Radio (clés publiques et privées) ou collez le nom d'utilisateur (v1_) et le jeton manuellement. L'audience JWT dans le jeton doit correspondre au nom d'hôte du serveur.", "publicKeyMissing": "Clé publique manquante dans l'identité. Importez votre JSON de configuration MeshCore dans le panneau Radio (doit inclure des clés publiques et privées) ou collez un jeton de courtier dans le champ du mot de passe.", - "usernameBuildFailed": "Impossible de créer le nom d'utilisateur LetsMesh. Importez votre configuration JSON MeshCore dans le panneau Radio ou collez le nom d'utilisateur (v1_) et le jeton manuellement.", - "noIdentity": "Aucune identité d'appareil trouvée. Importez votre configuration JSON MeshCore dans le panneau Radio ou collez le nom d'utilisateur et le jeton manuellement." + "noIdentity": "Aucune identité d'appareil trouvée. Importez votre configuration JSON MeshCore dans le panneau Radio ou collez le nom d'utilisateur et le jeton manuellement.", + "usernameBuildFailed": "Impossible de construire le nom d'utilisateur MeshCore MQTT. Importez votre configuration MeshCore JSON dans le panneau Radio ou collez manuellement le nom d'utilisateur (v1_) et le jeton." }, "meshcorePacketLogger": { "label": "Enregistreur de paquets — lorsqu'il est connecté à votre radio et à MQTT, transférez les résumés des paquets RX à {{topic}} dans la même forme JSON que meshcoretomqtt. Désactivé par défaut ; activez-la uniquement si vous avez l'intention de partager le trafic aérien entendu avec un analyseur de paquets." @@ -1341,9 +1345,6 @@ "disableShareAria": "Désactiver l'instance de partage et redémarrer la pile", "disableFailed": "Impossible de désactiver l'instance de partage." }, - "letsMeshRequiresWebSocket": "LetsMesh nécessite le transport WebSocket.", - "letsMeshRequiresPort": "LetsMesh nécessite le port {{port}}.", - "letsMeshKnownBrokersOnly": "Le préréglage LetsMesh/ MeshMapper ne prend en charge que les courtiers de signature d'appareils connus. Utilisez Personnalisé pour les autres courtiers.", "letsMeshUsernameV1Hex": "Le nom d'utilisateur doit être v1_ suivi de 64 caractères hexadécimaux (clé publique).", "bleWeakSignalWarning": "Le signal Bluetooth est faible ({{rssi}} dBm). Rapprochez-vous pour une connexion stable.", "bleRssiDbm": "{{rssi}} dBm", @@ -1352,7 +1353,18 @@ "linkQuality": "Qualité du lien", "linkQualityMs": "{{ms}} ms", "linkQualityUnavailable": "—", - "signalUnavailableWebBluetooth": "Non disponible (Web Bluetooth)" + "signalUnavailableWebBluetooth": "Non disponible (Web Bluetooth)", + "meshcoreCaBroker": "Courtier MeshCore.CA", + "meshcoreCaPrimary": "Principal bénéficiaire", + "meshcoreCaBackup": "Sauvegarde", + "broker": "Mandant /", + "letsMeshRequiresTls": "Ce préréglage de courtier nécessite TLS (wss). Activez TLS ou passez à Personnalisé.", + "letsMeshRequiresWsPath": "Ce préréglage de courtier nécessite le chemin WebSocket {{wsPath}}. Réinitialisez le préréglage ou passez à Personnalisé.", + "letsMeshRegionUs": "États-Unis", + "letsMeshRegionEu": "UE", + "letsMeshRequiresWebSocket": "Ce préréglage de courtier nécessite le transport WebSocket (wss).", + "letsMeshRequiresPort": "Ce préréglage de courtier nécessite le port {{port}} (WebSocket TLS).", + "letsMeshKnownBrokersOnly": "Ce préréglage ne prend en charge que les courtiers MeshCore MQTT publics connus. Passez à Personnalisé pour les autres courtiers." }, "contactGroupsModal": { "failedLoadMembers": "Échec du chargement des membres : {{message}}", @@ -5323,7 +5335,8 @@ "actionFailed": "Échec de l'action : {{reason}}", "unknownReason": "raison inconnue", "resendFailed": "Échec du renvoi.", - "deleteFailed": "Échec de la suppression de la session." + "deleteFailed": "Échec de la suppression de la session.", + "sessionExpired": "Ce jeu a expiré après une période d'inactivité et ne peut plus être joué. Utilisez Supprimer la session pour la supprimer." }, "deleteConfirmTitle": "Supprimer la période ?", "deleteConfirmMessage": "Supprimer cette session de jeu de votre liste ? Cela ne peut pas être annulé.", @@ -5337,6 +5350,7 @@ "propagated": "emmagasiné, rangé, conservé, entreposé, stocké", "failed": "Réessayer nécessaire" }, - "drawOfferWaitingBanner": "Offre de tirage envoyée. En attente de l'adversaire…" + "drawOfferWaitingBanner": "Offre de tirage envoyée. En attente de l'adversaire…", + "idleExpiryNotice": "Les jeux expirent après l'inactivité et ne peuvent alors être supprimés. Défis sans réponse : 24 heures (tous les jeux). Jeux actifs : tic-tac-toe après 24 heures, échecs et quatre dans une rangée après 7 jours." } } diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index ae5cbb462..23e70000c 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -909,8 +909,6 @@ "pairingFailed": "Gagal memasangkan: {{msg}}", "pinPairingFailed": "Gagal memasangkan PIN: {{msg}}", "pinAcceptedNext": "PIN diterima. Penyandingan selesai. Tekan Hubungkan dan pilih perangkat Anda untuk menyelesaikan koneksi.", - "autoConnectTimeout": "Waktu sambungan otomatis habis.", - "autoConnectFailed": "Sambungan otomatis gagal", "connectionFailed": "Koneksi gagal" }, "cancelMqttConnect": "Membatalkan", @@ -923,8 +921,6 @@ "mqttTlsEnabled": "Aktifkan TLS (mqtts / wss)", "mqttTlsInsecure": "Izinkan TLS yang tidak aman (sertifikat yang ditandatangani sendiri). Tidak aktif secara default — hanya aktifkan jika broker Anda menggunakan CA non - publik.", "channelPsksPrivateUplinkNote": "Saluran uplink dienkripsi AES - PSK pada /2/e/ (tidak ada cermin JSON untuk kunci pribadi). Monitor broker mungkin masih mencatat kegagalan dekripsi untuk lalu lintas PKI/DM di antara node lain — yang normal dan tidak terkait dengan saluran PSK di sini.", - "sharedBleWithMeshtastic": "Perangkat Bluetooth ini sudah terhubung melalui Meshtastic. Beralih ke tab Meshtastic untuk menggunakannya, atau pilih radio atau transport yang berbeda untuk MeshCore.", - "sharedBleWithMeshtasticHint": "Satu radio fisik tidak dapat mengadakan sesi Meshtastic dan MeshCore Bluetooth secara bersamaan.", "reticulumStackTitle": "Stack Reticulum", "reticulumStackHint": "Memulai sidecar Reticulum/LXMF lokal (antarmuka TCP dan Auto).", "reticulumStartStack": "Mulai stack", @@ -1260,19 +1256,27 @@ "coloradomesh": "Colorado Mesh", "meshmapper": "MeshMapper", "ripple": "Ripple Networks", - "custom": "Kebiasaan" + "custom": "Kebiasaan", + "waev": "Waev", + "meshatse": "Meshat.se", + "meshcoreca": "MeshCore.CA", + "eastmesh": "EastMesh" }, "meshcorePresetDeviation": { "letsmesh": "LetsMesh memerlukan WebSocket pada port 443 dan server mqtt-us-v1.letsmesh.net atau mqtt-eu-v1.letsmesh.net. Gunakan Wilayah (AS/UE), atau beralih ke Kustom untuk broker lain.", "coloradomesh": "Colorado Mesh memerlukan WebSocket pada port 443 dengan TLS diaktifkan, server mqtt.meshcore.coloradomesh.org, jalur /ws. Reset preset atau alihkan ke Custom untuk broker lain.", - "meshmapper": "MeshMapper membutuhkan WebSocket pada port 443 dan server mqtt.meshmapper.net. Reset preset atau alihkan ke Custom untuk broker lain." + "meshmapper": "MeshMapper membutuhkan WebSocket pada port 443 dan server mqtt.meshmapper.net. Reset preset atau alihkan ke Custom untuk broker lain.", + "waev": "Waev membutuhkan WebSocket di port 443 dengan TLS diaktifkan, server mqtt.waev.app. Atur ulang preset atau beralih ke Custom untuk broker lain.", + "meshatse": "Meshat.se membutuhkan WebSocket pada port 443 dengan TLS diaktifkan, server meshcore-mqtt.meshat.se. Atur ulang preset atau beralih ke Custom untuk broker lain.", + "meshcoreca": "MeshCore.CA membutuhkan WebSocket pada port 443 dengan TLS diaktifkan, server mqtt1.meshcore.ca atau mqtt2.meshcore.ca. Gunakan Broker (Primer/Cadangan), atau beralih ke Custom untuk broker lain.", + "eastmesh": "EastMesh membutuhkan WebSocket pada port 443 dengan TLS diaktifkan, server mqtt2.eastmesh.au. Atur ulang preset atau beralih ke Custom untuk broker lain." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Token autentikasi (format meshcore-decoder) akan dibuat saat Anda terhubung. Nama pengguna adalah v1_ ditambah kunci publik 64 karakter Anda (hex). Audiens JWT cocok dengan nama host Server.", "noPrivateKey": "Tidak ada identitas lengkap — impor konfigurasi MeshCore Anda di panel Radio (kunci publik dan pribadi), atau tempelkan nama pengguna (v1_) dan beri token secara manual. Audiens JWT dalam token harus cocok dengan nama host Server.", "publicKeyMissing": "Kunci publik hilang dari identitas. Impor JSON konfigurasi MeshCore Anda di panel Radio (harus menyertakan kunci publik dan pribadi), atau tempelkan token broker di bidang kata sandi.", - "usernameBuildFailed": "Tidak dapat membuat nama pengguna LetsMesh. Impor JSON konfigurasi MeshCore Anda di panel Radio, atau tempelkan nama pengguna (v1_) dan beri token secara manual.", - "noIdentity": "Tidak ada identitas perangkat yang ditemukan. Impor JSON konfigurasi MeshCore Anda di panel Radio, atau tempelkan nama pengguna dan token secara manual." + "noIdentity": "Tidak ada identitas perangkat yang ditemukan. Impor JSON konfigurasi MeshCore Anda di panel Radio, atau tempelkan nama pengguna dan token secara manual.", + "usernameBuildFailed": "Tidak dapat membuat nama pengguna MQTT MeshCore. Impor JSON konfigurasi MeshCore Anda di panel Radio, atau tempel nama pengguna (v1_) dan token secara manual." }, "meshcorePacketLogger": { "label": "Pencatat paket — saat terhubung ke radio dan MQTT Anda, teruskan ringkasan paket RX ke {{topic}} dalam bentuk JSON yang sama dengan meshcoretomqtt. Mati secara default; hanya aktifkan jika Anda ingin berbagi lalu lintas udara yang didengar dengan penganalisis paket." @@ -1341,9 +1345,6 @@ "disableShareAria": "Nonaktifkan Bagikan instance dan mulai ulang tumpukan", "disableFailed": "Gagal menonaktifkan Bagikan instance." }, - "letsMeshRequiresWebSocket": "LetsMesh membutuhkan transportasi WebSocket.", - "letsMeshRequiresPort": "LetsMesh membutuhkan port {{port}}.", - "letsMeshKnownBrokersOnly": "Preset LetsMesh/ MeshMapper hanya mendukung broker penandatanganan perangkat yang dikenal. Gunakan Custom untuk broker lain.", "letsMeshUsernameV1Hex": "Nama pengguna harus v1_disusul oleh 64 karakter hex (kunci publik).", "bleWeakSignalWarning": "Sinyal Bluetooth lemah ({{rssi}} dBm). Bergerak lebih dekat untuk koneksi yang stabil.", "bleRssiDbm": "{{rssi}} dBm", @@ -1352,7 +1353,18 @@ "linkQuality": "Kualitas tautan", "linkQualityMs": "{{ms}} ms", "linkQualityUnavailable": "—", - "signalUnavailableWebBluetooth": "Tidak tersedia (Bluetooth Web)" + "signalUnavailableWebBluetooth": "Tidak tersedia (Bluetooth Web)", + "meshcoreCaBroker": "Broker MeshCore.CA", + "meshcoreCaPrimary": "Primer", + "meshcoreCaBackup": "Cadangan", + "broker": "Broker", + "letsMeshRequiresTls": "Prasetel broker ini membutuhkan TLS (wss). Aktifkan TLS atau beralih ke Kustom.", + "letsMeshRequiresWsPath": "Prasetel broker ini membutuhkan jalur WebSocket {{wsPath}}. Atur ulang preset atau beralih ke Kustom.", + "letsMeshRegionUs": "AS", + "letsMeshRegionEu": "UE", + "letsMeshRequiresWebSocket": "Prasetel broker ini membutuhkan transportasi WebSocket (wss).", + "letsMeshRequiresPort": "Prasetel broker ini membutuhkan port {{port}} (WebSocket TLS).", + "letsMeshKnownBrokersOnly": "Preset ini hanya mendukung broker MQTT MeshCore publik yang dikenal. Beralih ke Custom untuk broker lain." }, "contactGroupsModal": { "failedLoadMembers": "Gagal memuat anggota: {{message}}", @@ -5323,7 +5335,8 @@ "actionFailed": "Tindakan gagal: {{reason}}", "unknownReason": "Alasan tidak dikenal", "resendFailed": "Pengiriman ulang gagal.", - "deleteFailed": "Gagal menghapus sesi." + "deleteFailed": "Gagal menghapus sesi.", + "sessionExpired": "Game ini kedaluwarsa setelah periode tidak aktif dan tidak dapat dimainkan lagi. Gunakan Hapus sesi untuk menghapusnya." }, "deleteConfirmTitle": "Hapus sesi?", "deleteConfirmMessage": "Hapus sesi permainan ini dari daftar Anda? Ini tidak dapat diurungkan.", @@ -5337,6 +5350,7 @@ "propagated": "Disimpan", "failed": "Coba lagi yang diperlukan" }, - "drawOfferWaitingBanner": "Penawaran undian terkirim. Menunggu lawan…" + "drawOfferWaitingBanner": "Penawaran undian terkirim. Menunggu lawan…", + "idleExpiryNotice": "Game kedaluwarsa setelah tidak aktif dan kemudian hanya dapat dihapus. Tantangan yang belum terjawab: 24 jam (semua game). Permainan aktif: tic - tac - toe setelah 24 jam, catur dan four - in - a - row setelah 7 hari." } } diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index d9eb9d150..69e230d5e 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -907,8 +907,6 @@ "pairingFailed": "Associazione non riuscita: {{msg}}", "pinPairingFailed": "Associazione PIN non riuscita: {{msg}}", "pinAcceptedNext": "PIN accettato. Associazione completata. Premi Connetti e seleziona il tuo dispositivo per completare la connessione.", - "autoConnectTimeout": "Connessione automatica scaduta.", - "autoConnectFailed": "Connessione automatica non riuscita", "connectionFailed": "Connessione non riuscita" }, "cancelMqttConnect": "Cancellare", @@ -921,8 +919,6 @@ "mqttTlsEnabled": "Abilita TLS (mqtts / wss)", "mqttTlsInsecure": "Consenti TLS non sicuro (certificato autofirmato). Disattivato per impostazione predefinita: abilita solo se il tuo broker utilizza una CA non pubblica.", "channelPsksPrivateUplinkNote": "L'uplink del canale è crittografato AES-PSK su /2/e/ (nessun mirror JSON per le chiavi private). I monitor dei broker possono ancora registrare errori di decrittografia per il traffico PKI/DM tra altri nodi, il che è normale e non correlato ai PSK del canale qui.", - "sharedBleWithMeshtastic": "Questo dispositivo Bluetooth è già connesso tramite Meshtastic. Passa alla scheda Meshtastic per usarla, oppure scegli una radio o un mezzo di trasporto diverso per MeshCore.", - "sharedBleWithMeshtasticHint": "Una radio fisica non può contenere sessioni Bluetooth Meshtastic e MeshCore separate contemporaneamente.", "reticulumStackTitle": "Stack Reticulum", "reticulumStackHint": "Avvia il sidecar locale Reticulum/LXMF (interfacce TCP e Auto).", "reticulumStartStack": "Avvia stack", @@ -1258,19 +1254,27 @@ "coloradomesh": "Colorado Mesh", "meshmapper": "MeshMapper", "ripple": "Ripple Networks", - "custom": "Costume" + "custom": "Costume", + "waev": "Waev", + "meshatse": "Meshat.se", + "meshcoreca": "MeshCore.CA", + "eastmesh": "EastMesh" }, "meshcorePresetDeviation": { "letsmesh": "LetsMesh necessita di WebSocket sulla porta 443 e del server mqtt-us-v1.letsmesh.net o mqtt-eu-v1.letsmesh.net. Utilizza la regione (USA/UE) o passa a Personalizzato per altri broker.", "coloradomesh": "Colorado Mesh necessita di WebSocket sulla porta 443 con TLS abilitato, server mqtt.meshcore.coloradomesh.org, percorso /ws. Ripristina la preimpostazione o passa a Personalizzato per altri broker.", - "meshmapper": "MeshMapper necessita di WebSocket sulla porta 443 e sul server mqtt.meshmapper.net. Ripristina la preimpostazione o passa a Personalizzato per altri broker." + "meshmapper": "MeshMapper necessita di WebSocket sulla porta 443 e sul server mqtt.meshmapper.net. Ripristina la preimpostazione o passa a Personalizzato per altri broker.", + "waev": "Waev ha bisogno di WebSocket sulla porta 443 con TLS abilitato, server mqtt.waev.app. Reimposta il preset o passa a Personalizzato per altri broker.", + "meshatse": "Meshat.se ha bisogno di WebSocket sulla porta 443 con TLS abilitato, server meshcore-mqtt.meshat.se. Reimposta il preset o passa a Personalizzato per altri broker.", + "meshcoreca": "MeshCore.CA ha bisogno di WebSocket sulla porta 443 con TLS abilitato, server mqtt1.meshcore.ca o mqtt2.meshcore.ca. Usa Broker (Primario/Backup) o passa a Personalizzato per altri broker.", + "eastmesh": "EastMesh ha bisogno di WebSocket sulla porta 443 con TLS abilitato, server mqtt2.eastmesh.au. Reimposta il preset o passa a Personalizzato per altri broker." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Il token di autenticazione (formato decodificatore meshcore) verrà generato quando ti connetti. Il nome utente è v1_ più la chiave pubblica di 64 caratteri (esadecimale). Il pubblico JWT corrisponde al nome host del server.", "noPrivateKey": "Nessuna identità completa: importa la tua configurazione MeshCore nel pannello Radio (chiavi pubbliche e private) o incolla il nome utente (v1_) e il token manualmente. Il pubblico JWT nel token deve corrispondere al nome host del server.", "publicKeyMissing": "Chiave pubblica mancante dall'identità. Importa il JSON di configurazione MeshCore nel pannello Radio (deve includere chiavi pubbliche e private) o incolla un token broker nel campo della password.", - "usernameBuildFailed": "Impossibile creare il nome utente LetsMesh. Importa il JSON di configurazione MeshCore nel pannello Radio oppure incolla il nome utente (v1_) e il token manualmente.", - "noIdentity": "Nessuna identità del dispositivo trovata. Importa il JSON di configurazione MeshCore nel pannello Radio oppure incolla manualmente nome utente e token." + "noIdentity": "Nessuna identità del dispositivo trovata. Importa il JSON di configurazione MeshCore nel pannello Radio oppure incolla manualmente nome utente e token.", + "usernameBuildFailed": "Impossibile creare il nome utente MQTT MeshCore. Importa il tuo JSON di configurazione MeshCore nel pannello Radio o incolla manualmente il nome utente (v1_) e il token." }, "meshcorePacketLogger": { "label": "Registratore di pacchetti: quando sei connesso alla radio e a MQTT, inoltra i riepiloghi dei pacchetti RX a {{topic}} nella stessa forma JSON di meshcoretomqtt. Disattivato per impostazione predefinita; abilitalo solo se intendi condividere il traffico aereo ascoltato con un analizzatore di pacchetti." @@ -1341,9 +1345,6 @@ "disableShareAria": "Disattiva l'istanza di condivisione e riavvia lo stack", "disableFailed": "Impossibile disattivare l'istanza di condivisione." }, - "letsMeshRequiresWebSocket": "LetsMesh richiede il trasporto WebSocket.", - "letsMeshRequiresPort": "LetsMesh richiede la porta {{port}}.", - "letsMeshKnownBrokersOnly": "Il preset LetsMesh / MeshMapper supporta solo broker noti per la firma del dispositivo. Usa Custom per altri broker.", "letsMeshUsernameV1Hex": "Il nome utente deve essere v1_ seguito da 64 caratteri esadecimali (chiave pubblica).", "bleWeakSignalWarning": "Il segnale Bluetooth è debole ({{rssi}} dBm). Avvicinati per una connessione stabile.", "bleRssiDbm": "{{rssi}} dBm", @@ -1352,7 +1353,18 @@ "linkQuality": "Qualità del collegamento", "linkQualityMs": "{{ms}} ms", "linkQualityUnavailable": "—", - "signalUnavailableWebBluetooth": "Non disponibile (Web Bluetooth)" + "signalUnavailableWebBluetooth": "Non disponibile (Web Bluetooth)", + "meshcoreCaBroker": "MeshCore.CA broker", + "meshcoreCaPrimary": "Primario", + "meshcoreCaBackup": "Backup", + "broker": "Intermediario", + "letsMeshRequiresTls": "Questo preimpostato broker richiede TLS (wss). Abilita TLS o passa a Personalizzato.", + "letsMeshRequiresWsPath": "Questo preimpostato broker richiede il percorso WebSocket {{wsPath}}. Reimpostare il preset o passare a Personalizzato.", + "letsMeshRegionUs": "US", + "letsMeshRegionEu": "EU", + "letsMeshRequiresWebSocket": "Questo preimpostato broker richiede il trasporto WebSocket (wss).", + "letsMeshRequiresPort": "Questo broker preimpostato richiede la porta {{port}} (WebSocket TLS).", + "letsMeshKnownBrokersOnly": "Questa preimpostazione supporta solo broker MeshCore MQTT pubblici noti. Passa a Personalizzato per altri broker." }, "contactGroupsModal": { "failedLoadMembers": "Impossibile caricare i membri: {{message}}", @@ -5323,7 +5335,8 @@ "actionFailed": "Azione non riuscita: {{reason}}", "unknownReason": "Motivo sconosciuto", "resendFailed": "Nuovo invio del codice non riuscito", - "deleteFailed": "Sessione non eliminata" + "deleteFailed": "Sessione non eliminata", + "sessionExpired": "Questo gioco è scaduto dopo un periodo di inattività e non può più essere giocato. Usa Elimina sessione per rimuoverlo." }, "deleteConfirmTitle": "Cancellare una sessione", "deleteConfirmMessage": "Vuoi rimuovere questa sessione di gioco dalla tua lista? Questa operazione non può essere annullata.", @@ -5337,6 +5350,7 @@ "propagated": "Memorizzato", "failed": "Nuovo tentativo necessario" }, - "drawOfferWaitingBanner": "Offerta di pareggio inviata. In attesa dell'avversario..." + "drawOfferWaitingBanner": "Offerta di pareggio inviata. In attesa dell'avversario...", + "idleExpiryNotice": "I giochi scadono dopo l'inattività e possono quindi essere eliminati. Sfide senza risposta: 24 ore (tutte le partite). Giochi attivi: tic-tac-toe dopo 24 ore, scacchi e quattro in fila dopo 7 giorni." } } diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 0f24a9f3f..09f4a0b3e 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -909,8 +909,6 @@ "pairingFailed": "ペアリングに失敗しました: {{msg}}", "pinPairingFailed": "PIN のペアリングに失敗しました: {{msg}}", "pinAcceptedNext": "PIN が受け入れられました。ペアリング完了。 「接続」を押してデバイスを選択し、接続を完了します。", - "autoConnectTimeout": "自動接続がタイムアウトしました。", - "autoConnectFailed": "自動接続に失敗しました", "connectionFailed": "接続に失敗しました" }, "cancelMqttConnect": "キャンセル", @@ -923,8 +921,6 @@ "mqttTlsEnabled": "TLSを有効にする(mqtts/wss)", "mqttTlsInsecure": "安全でないTLS (自己署名証明書)を許可します。デフォルトでオフ—ブローカーが非公開CAを使用している場合にのみ有効にします。", "channelPsksPrivateUplinkNote": "チャネルアップリンクは/2/e/でAES - PSK暗号化されています(秘密鍵のJSONミラーはありません)。ブローカーモニターは、他のノード間のPKI/DMトラフィックの復号化失敗を記録することがあります。これは正常であり、ここではチャネルPSKとは無関係です。", - "sharedBleWithMeshtastic": "このBluetoothデバイスはすでにMeshtastic経由で接続されています。Meshtasticタブに切り替えて使用するか、MeshCoreの別のラジオまたはトランスポートを選択します。", - "sharedBleWithMeshtasticHint": "1つの物理ラジオは、MeshtasticとMeshCore Bluetoothセッションを同時に開催することはできません。", "reticulumStackTitle": "Reticulumスタック", "reticulumStackHint": "ローカルのReticulum/LXMFサイドカー(TCPおよびAutoインターフェース)を起動します。", "reticulumStartStack": "スタックを開始", @@ -1260,19 +1256,27 @@ "coloradomesh": "Colorado Mesh", "meshmapper": "MeshMapper", "ripple": "Ripple Networks", - "custom": "カスタム" + "custom": "カスタム", + "waev": "Waev", + "meshatse": "Meshat.se", + "meshcoreca": "MeshCore.CA", + "eastmesh": "イーストメッシュ" }, "meshcorePresetDeviation": { "letsmesh": "LetsMesh には、ポート 443 とサーバー mqtt-us-v1.letsmesh.net または mqtt-eu-v1.letsmesh.net 上の WebSocket が必要です。地域 (US/EU) を使用するか、他のブローカーの場合はカスタムに切り替えてください。", "coloradomesh": "Colorado Mesh には、TLS が有効になっているポート 443、サーバー mqtt.meshcore.coloradomesh.org、パス /ws の WebSocket が必要です。プリセットをリセットするか、他のブローカーの場合はカスタムに切り替えます。", - "meshmapper": "MeshMapper には、ポート 443 とサーバー mqtt.meshmapper.net 上の WebSocket が必要です。プリセットをリセットするか、他のブローカーの場合はカスタムに切り替えます。" + "meshmapper": "MeshMapper には、ポート 443 とサーバー mqtt.meshmapper.net 上の WebSocket が必要です。プリセットをリセットするか、他のブローカーの場合はカスタムに切り替えます。", + "waev": "Waevは、TLSが有効なポート443、サーバーmqtt.waev.appにWebSocketを必要とします。プリセットをリセットするか、他のブローカーの場合はカスタムに切り替えます。", + "meshatse": "Meshat.seは、TLSが有効なポート443、サーバーmeshcore-mqtt.meshat.seにWebSocketを必要とします。プリセットをリセットするか、他のブローカーの場合はカスタムに切り替えます。", + "meshcoreca": "MeshCore.CAは、TLSが有効なポート443、サーバーmqtt1.meshcore.caまたはmqtt2.meshcore.caにWebSocketを必要とします。ブローカー(プライマリ/バックアップ)を使用するか、他のブローカーの場合はカスタムに切り替えます。", + "eastmesh": "EastMeshは、TLSが有効なポート443、サーバーmqtt2.eastmesh.auにWebSocketを必要とします。プリセットをリセットするか、他のブローカーの場合はカスタムに切り替えます。" }, "meshcoreMqttIdentity": { "hasPrivateKey": "接続時に認証トークン(meshcore-decoder形式)が生成されます。ユーザー名は v1_ に 64 文字の公開キー (16 進数) を加えたものです。 JWT オーディエンスはサーバーのホスト名と一致します。", "noPrivateKey": "完全な ID なし — MeshCore 設定を Radio パネル (公開キーと秘密キー) にインポートするか、ユーザー名 (v1_<公開キー>) とトークンを手動で貼り付けます。トークン内の JWT オーディエンスはサーバーのホスト名と一致する必要があります。", "publicKeyMissing": "ID に公開キーがありません。 [Radio] パネルに MeshCore 構成 JSON をインポートするか (公開キーと秘密キーを含める必要があります)、パスワード フィールドにブローカー トークンを貼り付けます。", - "usernameBuildFailed": "LetsMesh ユーザー名を構築できませんでした。 MeshCore 設定 JSON を [Radio] パネルにインポートするか、ユーザー名 (v1_) とトークンを手動で貼り付けます。", - "noIdentity": "デバイス ID が見つかりません。 MeshCore 構成 JSON を Radio パネルにインポートするか、ユーザー名とトークンを手動で貼り付けます。" + "noIdentity": "デバイス ID が見つかりません。 MeshCore 構成 JSON を Radio パネルにインポートするか、ユーザー名とトークンを手動で貼り付けます。", + "usernameBuildFailed": "MeshCore MQTTユーザー名を作成できませんでした。Radioパネルで MeshCore 設定JSONをインポートするか、ユーザー名(v1_)とトークンを手動で貼り付けてください。" }, "meshcorePacketLogger": { "label": "パケット ロガー — 無線および MQTT に接続すると、RX パケットの概要を Meshcoretomqtt と同じ JSON 形式で {{topic}} に転送します。デフォルトではオフです。聞いた航空交通量をパケット アナライザーと共有する場合にのみ有効にします。" @@ -1341,9 +1345,6 @@ "disableShareAria": "共有インスタンスをオフにして、スタックを再起動します", "disableFailed": "共有インスタンスをオフにできませんでした。" }, - "letsMeshRequiresWebSocket": "LetsMeshにはWebSocketトランスポートが必要です。", - "letsMeshRequiresPort": "LetsMeshにはポート{{port}}が必要です。", - "letsMeshKnownBrokersOnly": "LetsMesh/MeshMapperプリセットは、既知のデバイス署名ブローカーのみをサポートします。他のブローカーにはカスタムを使用します。", "letsMeshUsernameV1Hex": "ユーザー名はv 1_の後に64文字の16進数文字(公開鍵)でなければなりません。", "bleWeakSignalWarning": "Bluetooth信号が弱い( {{rssi}} dBm )。安定した接続のために近づいてください。", "bleRssiDbm": "{{rssi}} dBm", @@ -1352,7 +1353,18 @@ "linkQuality": "リンクの品質", "linkQualityMs": "{{ms}}ミリ秒", "linkQualityUnavailable": "—", - "signalUnavailableWebBluetooth": "利用できません( Web Bluetooth )" + "signalUnavailableWebBluetooth": "利用できません( Web Bluetooth )", + "meshcoreCaBroker": "MeshCore.CAブローカー", + "meshcoreCaPrimary": "第一", + "meshcoreCaBackup": "バックアップ", + "broker": "ブローカー", + "letsMeshRequiresTls": "このブローカープリセットにはTLS ( wss )が必要です。TLSを有効にするか、カスタムに切り替えます。", + "letsMeshRequiresWsPath": "このブローカープリセットには、WebSocketパス{{wsPath}}が必要です。プリセットをリセットするか、カスタムに切り替えます。", + "letsMeshRegionUs": "US", + "letsMeshRegionEu": "EU", + "letsMeshRequiresWebSocket": "このブローカープリセットには、WebSocketトランスポート( WSS )が必要です。", + "letsMeshRequiresPort": "このブローカープリセットにはポート{{port}} (WebSocket TLS)が必要です。", + "letsMeshKnownBrokersOnly": "このプリセットは、既知のパブリックMeshCore MQTTブローカーのみをサポートします。他のブローカーの場合はカスタムに切り替えます。" }, "contactGroupsModal": { "failedLoadMembers": "メンバーのロードに失敗しました: {{message}}", @@ -5323,7 +5335,8 @@ "actionFailed": "アクションに失敗しました: {{reason}}", "unknownReason": "不明な理由", "resendFailed": "再送に失敗しました。", - "deleteFailed": "セッションを削除できませんでした。" + "deleteFailed": "セッションを削除できませんでした。", + "sessionExpired": "このゲームはしばらくの非アクティブ状態の後に期限切れとなり、プレイできなくなりました。削除セッションを使用して削除します。" }, "deleteConfirmTitle": "セッションを削除", "deleteConfirmMessage": "このゲームセッションをリストから削除しますか?これは元に戻せません。", @@ -5337,6 +5350,7 @@ "propagated": "格納されています", "failed": "再試行が必要です" }, - "drawOfferWaitingBanner": "抽選オファーが送信されました。対戦相手を待っています…" + "drawOfferWaitingBanner": "抽選オファーが送信されました。対戦相手を待っています…", + "idleExpiryNotice": "ゲームは非アクティブの後に期限切れになり、削除することしかできません。未回答の課題: 24時間(すべてのゲーム)。アクティブなゲーム: 24時間後のチックタックトー、7日後のチェスと4 - in - a - row。" } } diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index fdbd8dbb8..40880182c 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -909,8 +909,6 @@ "pairingFailed": "페어링 실패: {{msg}}", "pinPairingFailed": "PIN 페어링 실패: {{msg}}", "pinAcceptedNext": "PIN이 승인되었습니다. 페어링이 완료되었습니다. 연결을 누르고 장치를 선택하여 연결을 완료하세요.", - "autoConnectTimeout": "자동 연결 시간이 초과되었습니다.", - "autoConnectFailed": "자동 연결 실패", "connectionFailed": "연결 실패" }, "cancelMqttConnect": "취소", @@ -923,8 +921,6 @@ "mqttTlsEnabled": "TLS 활성화 (mqtts/wss)", "mqttTlsInsecure": "안전하지 않은 TLS (자체 서명된 인증서) 를 허용합니다. 기본적으로 사용 안 함 — 브로커가 비공개 CA를 사용하는 경우에만 사용하도록 설정합니다.", "channelPsksPrivateUplinkNote": "채널 업링크는/2/e/(개인 키용 JSON 미러 없음) 에서 AES-PSK로 암호화됩니다. 브로커 모니터는 여전히 다른 노드 간의 PKI/DM 트래픽에 대한 암호 해독 실패를 기록할 수 있습니다. 이는 정상이며 여기서는 채널 PSK와 관련이 없습니다.", - "sharedBleWithMeshtastic": "이 블루투스 장치는 이미 Meshtastic을 통해 연결되어 있습니다. Meshtastic 탭으로 전환하여 사용하거나 MeshCore에 대해 다른 라디오 또는 전송을 선택하십시오.", - "sharedBleWithMeshtasticHint": "하나의 물리적 라디오는 별도의 Meshtastic 및 MeshCore Bluetooth 세션을 동시에 보유할 수 없습니다.", "reticulumStackTitle": "Reticulum 스택", "reticulumStackHint": "로컬 Reticulum/LXMF 사이드카(TCP 및 Auto 인터페이스)를 시작합니다.", "reticulumStartStack": "스택 시작", @@ -1260,19 +1256,27 @@ "coloradomesh": "Colorado Mesh", "meshmapper": "MeshMapper", "ripple": "Ripple Networks", - "custom": "관습" + "custom": "관습", + "waev": "와에브", + "meshatse": "Meshat.se", + "meshcoreca": "MeshCore.CA", + "eastmesh": "EastMesh" }, "meshcorePresetDeviation": { "letsmesh": "LetsMesh에는 포트 443 및 서버 mqtt-us-v1.letsmesh.net 또는 mqtt-eu-v1.letsmesh.net에 WebSocket이 필요합니다. 지역(US/EU)을 사용하거나 다른 브로커의 경우 사용자 정의로 전환하세요.", "coloradomesh": "Colorado Mesh는 TLS가 활성화된 포트 443, 서버 mqtt.meshcore.coloradomesh.org, 경로 /ws에 WebSocket이 필요합니다. 다른 브로커의 경우 사전 설정을 재설정하거나 사용자 정의로 전환하세요.", - "meshmapper": "MeshMapper에는 포트 443 및 서버 mqtt.meshmapper.net에 WebSocket이 필요합니다. 다른 브로커의 경우 사전 설정을 재설정하거나 사용자 정의로 전환하세요." + "meshmapper": "MeshMapper에는 포트 443 및 서버 mqtt.meshmapper.net에 WebSocket이 필요합니다. 다른 브로커의 경우 사전 설정을 재설정하거나 사용자 정의로 전환하세요.", + "waev": "Waev는 TLS가 활성화된 mqtt.waev.app 서버가 있는 포트 443에서 WebSocket이 필요합니다. 사전 설정을 재설정하거나 다른 브로커의 경우 사용자 지정으로 전환하십시오.", + "meshatse": "Meshat.se에는 TLS가 활성화된 서버 meshcore-mqtt.meshat.se가 있는 포트 443의 WebSocket이 필요합니다. 사전 설정을 재설정하거나 다른 브로커의 경우 사용자 지정으로 전환하십시오.", + "meshcoreca": "MeshCore.CA는 TLS가 활성화된 포트 443, 서버 mqtt1.meshcore.ca 또는 mqtt2.meshcore.ca에서 WebSocket이 필요합니다. 브로커 (기본/백업) 를 사용하거나 다른 브로커의 경우 사용자 지정으로 전환하십시오.", + "eastmesh": "EastMesh는 TLS가 활성화된 mqtt2.eastmesh.au 서버가 있는 포트 443에서 WebSocket이 필요합니다. 사전 설정을 재설정하거나 다른 브로커의 경우 사용자 지정으로 전환하십시오." }, "meshcoreMqttIdentity": { "hasPrivateKey": "연결 시 인증 토큰(meshcore-decoder 형식)이 생성됩니다. 사용자 이름은 v1_에 64자 공개 키(16진수)를 더한 것입니다. JWT 대상은 서버 호스트 이름과 일치합니다.", "noPrivateKey": "전체 ID 없음 - 라디오 패널(공개 및 개인 키)에서 MeshCore 구성을 가져오거나 사용자 이름(v1_<공개 키>)과 토큰을 수동으로 붙여넣습니다. 토큰의 JWT 대상은 서버 호스트 이름과 일치해야 합니다.", "publicKeyMissing": "ID에서 공개 키가 누락되었습니다. 라디오 패널에서 MeshCore 구성 JSON을 가져오거나(공개 및 개인 키를 포함해야 함) 비밀번호 필드에 브로커 토큰을 붙여넣습니다.", - "usernameBuildFailed": "LetsMesh 사용자 이름을 만들 수 없습니다. 라디오 패널에서 MeshCore 구성 JSON을 가져오거나 사용자 이름(v1_<공개 키>)과 토큰을 수동으로 붙여넣습니다.", - "noIdentity": "장치 ID를 찾을 수 없습니다. 라디오 패널에서 MeshCore 구성 JSON을 가져오거나 사용자 이름과 토큰을 수동으로 붙여넣습니다." + "noIdentity": "장치 ID를 찾을 수 없습니다. 라디오 패널에서 MeshCore 구성 JSON을 가져오거나 사용자 이름과 토큰을 수동으로 붙여넣습니다.", + "usernameBuildFailed": "MeshCore MQTT 사용자 이름을 빌드할 수 없습니다. MeshCore 구성 JSON을 라디오 패널에 가져오거나 사용자 이름 (v1_) 및 토큰을 수동으로 붙여넣습니다." }, "meshcorePacketLogger": { "label": "패킷 로거 — 라디오 및 MQTT에 연결되면 RX 패킷 요약을 meshcoretomqtt와 동일한 JSON 형태로 {{topic}}에 전달합니다. 기본적으로 꺼져 있습니다. 수신된 항공 트래픽을 패킷 분석기와 공유하려는 경우에만 활성화하십시오." @@ -1341,9 +1345,6 @@ "disableShareAria": "인스턴스 공유 끄고 스택 다시 시작", "disableFailed": "회차 공유 기능을 끄지 못했습니다." }, - "letsMeshRequiresWebSocket": "LetsMesh는 WebSocket 전송이 필요합니다.", - "letsMeshRequiresPort": "LetsMesh에는 {{port}} 포트가 필요합니다.", - "letsMeshKnownBrokersOnly": "LetsMesh/MeshMapper 사전 설정은 알려진 장치 서명 브로커만 지원합니다. 다른 브로커에 대해 사용자 정의를 사용하십시오.", "letsMeshUsernameV1Hex": "사용자 이름은 v1_뒤에 64 16진수 문자 (공개 키) 이어야 합니다.", "bleWeakSignalWarning": "블루투스 신호가 약합니다 ({{rssi}} dBm). 안정적인 연결을 위해 더 가까이 이동하세요.", "bleRssiDbm": "{{rssi}} dBm", @@ -1352,7 +1353,18 @@ "linkQuality": "연결 품질", "linkQualityMs": "{{ms}} ms", "linkQualityUnavailable": "—", - "signalUnavailableWebBluetooth": "사용할 수 없음 (웹 블루투스)" + "signalUnavailableWebBluetooth": "사용할 수 없음 (웹 블루투스)", + "meshcoreCaBroker": "MeshCore.CA 브로커", + "meshcoreCaPrimary": "1차", + "meshcoreCaBackup": "백업", + "broker": "중개인", + "letsMeshRequiresTls": "이 브로커 사전 설정에는 TLS (wss) 가 필요합니다. TLS를 활성화하거나 사용자 지정으로 전환합니다.", + "letsMeshRequiresWsPath": "이 브로커 사전 설정에는 WebSocket 경로 {{wsPath}} 이 필요합니다. 사전 설정을 재설정하거나 사용자 지정으로 전환합니다.", + "letsMeshRegionUs": "미국", + "letsMeshRegionEu": "EU", + "letsMeshRequiresWebSocket": "이 브로커 사전 설정에는 WebSocket 전송 (wss) 이 필요합니다.", + "letsMeshRequiresPort": "이 브로커 사전 설정에는 포트 {{port}} (WebSocket TLS) 이 필요합니다.", + "letsMeshKnownBrokersOnly": "이 사전 설정은 알려진 공용 MeshCore MQTT 브로커만 지원합니다. 다른 브로커의 경우 사용자 지정으로 전환합니다." }, "contactGroupsModal": { "failedLoadMembers": "멤버를 로드하지 못했습니다: {{message}}", @@ -5323,7 +5335,8 @@ "actionFailed": "조치 실패: {{reason}}", "unknownReason": "알 수 없는 이유", "resendFailed": "재전송에 실패했습니다.", - "deleteFailed": "세션을 삭제하지 못했습니다." + "deleteFailed": "세션을 삭제하지 못했습니다.", + "sessionExpired": "이 게임은 일정 기간 동안 활동이 없으면 만료되어 더 이상 플레이할 수 없습니다. 삭제 세션을 사용하여 삭제합니다." }, "deleteConfirmTitle": "세션 삭제", "deleteConfirmMessage": "목록에서 이 게임 세션을 제거하시겠습니까? 이 작업은 취소할 수 없습니다.", @@ -5337,6 +5350,7 @@ "propagated": "저장량", "failed": "다시 시도 필요" }, - "drawOfferWaitingBanner": "무승부 오퍼가 전송되었습니다. 상대를 기다리는 중..." + "drawOfferWaitingBanner": "무승부 오퍼가 전송되었습니다. 상대를 기다리는 중...", + "idleExpiryNotice": "게임은 비활성 후에 만료되며 삭제할 수 있습니다. 답이 없는 챌린지: 24시간 (모든 게임). 진행 중인 게임: 24시간 후 틱택토, 7일 후 체스 및 포인어로우." } } diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 65f54d2e7..8ded062b5 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -908,8 +908,6 @@ "pairingFailed": "Koppelen mislukt: {{msg}}", "pinPairingFailed": "PIN-koppeling mislukt: {{msg}}", "pinAcceptedNext": "Pincode geaccepteerd. Koppelen voltooid. Druk op Verbinden en selecteer uw apparaat om de verbinding te voltooien.", - "autoConnectTimeout": "Er is een time-out opgetreden voor automatisch verbinden.", - "autoConnectFailed": "Automatisch verbinden mislukt", "connectionFailed": "Verbinding mislukt" }, "cancelMqttConnect": "Annuleren", @@ -922,8 +920,6 @@ "mqttTlsEnabled": "TLS (mqtts/ wss) inschakelen", "mqttTlsInsecure": "Sta onveilige TLS (zelfondertekend certificaat) toe. Standaard uitgeschakeld — alleen inschakelen als uw makelaar een niet-openbare CA gebruikt.", "channelPsksPrivateUplinkNote": "Kanaaluplink is AES-PSK-gecodeerd op /2/e/ (geen JSON-spiegel voor privésleutels). Broker-monitoren kunnen nog steeds ontsleutelingsfouten registreren voor PKI/DM-verkeer tussen andere knooppunten — dat is normaal en niet gerelateerd aan PSK's hier.", - "sharedBleWithMeshtastic": "Dit Bluetooth-apparaat is al verbonden via Meshtastic. Schakel over naar het tabblad Meshtastic om het te gebruiken, of kies een andere radio of transport voor MeshCore.", - "sharedBleWithMeshtasticHint": "Eén fysieke radio kan niet tegelijkertijd afzonderlijke Meshtastic- en MeshCore Bluetooth-sessies houden.", "reticulumStackTitle": "Reticulum-stack", "reticulumStackHint": "Start de lokale Reticulum/LXMF-sidecar (TCP- en Auto-interfaces).", "reticulumStartStack": "Stack starten", @@ -1259,19 +1255,27 @@ "coloradomesh": "Colorado Mesh", "meshmapper": "MeshMapper", "ripple": "Ripple Networks", - "custom": "Aangepast" + "custom": "Aangepast", + "waev": "Waev", + "meshatse": "Meshat.se", + "meshcoreca": "MeshCore.CA", + "eastmesh": "EastMesh" }, "meshcorePresetDeviation": { "letsmesh": "LetsMesh heeft WebSocket op poort 443 en server mqtt-us-v1.letsmesh.net of mqtt-eu-v1.letsmesh.net nodig. Gebruik Regio (VS/EU) of schakel over naar Aangepast voor andere makelaars.", "coloradomesh": "Colorado Mesh heeft WebSocket nodig op poort 443 met TLS ingeschakeld, server mqtt.meshcore.coloradomesh.org, pad /ws. Reset de preset of schakel over naar Aangepast voor andere makelaars.", - "meshmapper": "MeshMapper heeft WebSocket op poort 443 en server mqtt.meshmapper.net nodig. Reset de preset of schakel over naar Aangepast voor andere makelaars." + "meshmapper": "MeshMapper heeft WebSocket op poort 443 en server mqtt.meshmapper.net nodig. Reset de preset of schakel over naar Aangepast voor andere makelaars.", + "waev": "Waev heeft WebSocket nodig op poort 443 met TLS ingeschakeld, server mqtt.waev.app. Reset de voorinstelling of schakel over naar Aangepast voor andere makelaars.", + "meshatse": "Meshat.se heeft WebSocket nodig op poort 443 met TLS ingeschakeld, server meshcore-mqtt.meshat.se. Reset de voorinstelling of schakel over naar Aangepast voor andere makelaars.", + "meshcoreca": "MeshCore.CA heeft WebSocket nodig op poort 443 met TLS ingeschakeld, server mqtt1.meshcore.ca of mqtt2.meshcore.ca. Gebruik Makelaar (Primair/Back-up) of schakel over naar Aangepast voor andere makelaars.", + "eastmesh": "EastMesh heeft WebSocket nodig op poort 443 met TLS ingeschakeld, server mqtt2.eastmesh.au. Reset de voorinstelling of schakel over naar Aangepast voor andere makelaars." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Auth-token (meshcore-decoder-indeling) wordt gegenereerd wanneer u verbinding maakt. De gebruikersnaam is v1_ plus uw openbare sleutel van 64 tekens (hexadecimaal). JWT-doelgroep komt overeen met de hostnaam van de server.", "noPrivateKey": "Geen volledige identiteit: importeer uw MeshCore-configuratie in het Radio-paneel (openbare en privésleutels), of plak de gebruikersnaam (v1_) en token handmatig. JWT-doelgroep in het token moet overeenkomen met de hostnaam van de server.", "publicKeyMissing": "Openbare sleutel ontbreekt in identiteit. Importeer uw MeshCore-configuratie-JSON in het Radio-paneel (moet openbare en privésleutels bevatten) of plak een makelaartoken in het wachtwoordveld.", - "usernameBuildFailed": "Kon LetsMesh-gebruikersnaam niet opbouwen. Importeer uw MeshCore-configuratie-JSON in het Radio-paneel, of plak de gebruikersnaam (v1_) en het token handmatig.", - "noIdentity": "Geen apparaatidentiteit gevonden. Importeer uw MeshCore-configuratie-JSON in het Radio-paneel, of plak de gebruikersnaam en het token handmatig." + "noIdentity": "Geen apparaatidentiteit gevonden. Importeer uw MeshCore-configuratie-JSON in het Radio-paneel, of plak de gebruikersnaam en het token handmatig.", + "usernameBuildFailed": "Kon de MeshCore MQTT-gebruikersnaam niet aanmaken. Importeer uw MeshCore config JSON in het radiopaneel of plak gebruikersnaam (v1_) en token handmatig." }, "meshcorePacketLogger": { "label": "Pakketlogger: wanneer verbonden met uw radio en MQTT, stuurt u RX-pakketsamenvattingen door naar {{topic}} in dezelfde JSON-vorm als meshcoretomqtt. Standaard uitgeschakeld; alleen inschakelen als u van plan bent om gehoord luchtverkeer te delen met een pakketanalysator." @@ -1341,9 +1345,6 @@ "disableShareAria": "Schakel de instantie Delen uit en start de stapel opnieuw", "disableFailed": "Kan de instantie Delen niet uitschakelen." }, - "letsMeshRequiresWebSocket": "LetsMesh vereist WebSocket transport.", - "letsMeshRequiresPort": "LetsMesh vereist poort {{port}}.", - "letsMeshKnownBrokersOnly": "LetsMesh / MeshMapper preset ondersteunt alleen bekende apparaatondertekenende makelaars. Gebruik Aangepast voor andere makelaars.", "letsMeshUsernameV1Hex": "Gebruikersnaam moet v1_zijn, gevolgd door 64 hexadecimale tekens (openbare sleutel).", "bleWeakSignalWarning": "Bluetooth-signaal is zwak ({{rssi}} dBm). Kom dichterbij voor een stabiele verbinding.", "bleRssiDbm": "{{rssi}} dBm", @@ -1352,7 +1353,18 @@ "linkQuality": "Verbindingskwaliteit", "linkQualityMs": "{{ms}} ms", "linkQualityUnavailable": "—", - "signalUnavailableWebBluetooth": "Niet beschikbaar (Web Bluetooth)" + "signalUnavailableWebBluetooth": "Niet beschikbaar (Web Bluetooth)", + "meshcoreCaBroker": "MeshCore.CA-makelaar", + "meshcoreCaPrimary": "Primair", + "meshcoreCaBackup": "Back-up", + "broker": "Makelaar:", + "letsMeshRequiresTls": "Deze makelaarspreset vereist TLS (wss). Schakel TLS in of schakel over naar Aangepast.", + "letsMeshRequiresWsPath": "Deze makelaarspreset vereist WebSocket-pad {{wsPath}}. Reset de voorinstelling of schakel over naar Aangepast.", + "letsMeshRegionUs": "US", + "letsMeshRegionEu": "EU", + "letsMeshRequiresWebSocket": "Deze broker preset vereist WebSocket transport (wss).", + "letsMeshRequiresPort": "Deze makelaarspreset vereist poort {{port}} (WebSocket TLS).", + "letsMeshKnownBrokersOnly": "Deze preset ondersteunt alleen bekende openbare MeshCore MQTT-makelaars. Schakel over naar Aangepast voor andere makelaars." }, "contactGroupsModal": { "failedLoadMembers": "Kan leden niet laden: {{message}}", @@ -5323,7 +5335,8 @@ "actionFailed": "Actie mislukt: {{reason}}", "unknownReason": "onbekende reden", "resendFailed": "Opnieuw verzenden mislukt.", - "deleteFailed": "Kan sessie niet verwijderen." + "deleteFailed": "Kan sessie niet verwijderen.", + "sessionExpired": "Dit spel is verlopen na een periode van inactiviteit en kan niet meer worden gespeeld. Gebruik Sessie verwijderen om het te verwijderen." }, "deleteConfirmTitle": "Moment verwijderen?", "deleteConfirmMessage": "Deze spelsessie uit je lijst verwijderen? Dit kan niet ongedaan worden gemaakt.", @@ -5337,6 +5350,7 @@ "propagated": "Opgeslagen", "failed": "Opnieuw proberen nodig" }, - "drawOfferWaitingBanner": "Tekenaanbod verzonden. Wachten op tegenstander..." + "drawOfferWaitingBanner": "Tekenaanbod verzonden. Wachten op tegenstander...", + "idleExpiryNotice": "Games verlopen na inactiviteit en kunnen dan alleen worden verwijderd. Onbeantwoorde uitdagingen: 24 uur (alle spellen). Actieve spellen: tic-tac-toe na 24 uur, schaken en four-in-a-row na 7 dagen." } } diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 184026a5c..d04fa6ad8 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -911,8 +911,6 @@ "pairingFailed": "Parowanie nie powiodło się: {{msg}}", "pinPairingFailed": "Parowanie PIN-u nie powiodło się: {{msg}}", "pinAcceptedNext": "PIN zaakceptowany. Parowanie zakończone. Naciśnij Połącz i wybierz urządzenie, aby zakończyć połączenie.", - "autoConnectTimeout": "Upłynął limit czasu automatycznego łączenia.", - "autoConnectFailed": "Automatyczne połączenie nie powiodło się", "connectionFailed": "Połączenie nie powiodło się" }, "cancelMqttConnect": "Anulować", @@ -925,8 +923,6 @@ "mqttTlsEnabled": "Włącz TLS (mqtts / wss)", "mqttTlsInsecure": "Zezwalaj na niezabezpieczony TLS (certyfikat z podpisem własnym). Domyślnie wyłączone — włącz tylko wtedy, gdy broker korzysta z niepublicznego urzędu certyfikacji.", "channelPsksPrivateUplinkNote": "Kanał uplink jest AES-PSK zaszyfrowany na /2/e/ (brak lustra JSON dla kluczy prywatnych). Monitory brokerów mogą nadal rejestrować błędy deszyfrowania dla ruchu PKI/DM między innymi węzłami — jest to normalne i niezwiązane z kanałami PSK.", - "sharedBleWithMeshtastic": "To urządzenie Bluetooth jest już połączone przez Meshtastic. Przejdź do zakładki Meshtastic, aby go użyć, lub wybierz inne radio lub transport dla MeshCore.", - "sharedBleWithMeshtasticHint": "Jedno fizyczne radio nie może pomieścić oddzielnych sesji Bluetooth Meshtastic i MeshCore w tym samym czasie.", "reticulumStackTitle": "Stos Reticulum", "reticulumStackHint": "Uruchamia lokalny sidecar Reticulum/LXMF (interfejsy TCP i Auto).", "reticulumStartStack": "Uruchom stos", @@ -1262,19 +1258,27 @@ "coloradomesh": "Colorado Mesh", "meshmapper": "MeshMapper", "ripple": "Ripple Networks", - "custom": "Zwyczaj" + "custom": "Zwyczaj", + "waev": "Waev", + "meshatse": "Meshat.se", + "meshcoreca": "MeshCore.CA", + "eastmesh": "EastMesh" }, "meshcorePresetDeviation": { "letsmesh": "LetsMesh potrzebuje WebSocket na porcie 443 i serwerze mqtt-us-v1.letsmesh.net lub mqtt-eu-v1.letsmesh.net. Użyj regionu (USA/UE) lub przejdź na opcję Niestandardowy dla innych brokerów.", "coloradomesh": "Colorado Mesh potrzebuje protokołu WebSocket na porcie 443 z włączoną obsługą TLS, serwer mqtt.meshcore.coloradomesh.org, ścieżka /ws. Zresetuj ustawienie wstępne lub przejdź na opcję Niestandardową dla innych brokerów.", - "meshmapper": "MeshMapper potrzebuje protokołu WebSocket na porcie 443 i serwerze mqtt.meshmapper.net. Zresetuj ustawienie wstępne lub przejdź na opcję Niestandardową dla innych brokerów." + "meshmapper": "MeshMapper potrzebuje protokołu WebSocket na porcie 443 i serwerze mqtt.meshmapper.net. Zresetuj ustawienie wstępne lub przejdź na opcję Niestandardową dla innych brokerów.", + "waev": "Waev potrzebuje WebSocket na porcie 443 z włączonym TLS, serwer mqtt.waev.app. Zresetuj ustawienie wstępne lub przełącz na niestandardowe dla innych brokerów.", + "meshatse": "Meshat.se potrzebuje WebSocket na porcie 443 z włączonym TLS, server meshcore-mqtt.meshat.se. Zresetuj ustawienie wstępne lub przełącz na niestandardowe dla innych brokerów.", + "meshcoreca": "MeshCore.CA potrzebuje WebSocket na porcie 443 z włączonym TLS, serwerem mqtt1.meshcore.ca lub mqtt2.meshcore.ca. Użyj brokera (głównego/zapasowego) lub przełącz się na niestandardowy dla innych brokerów.", + "eastmesh": "EastMesh potrzebuje WebSocket na porcie 443 z włączonym TLS, serwer mqtt2.eastmesh.au. Zresetuj ustawienie wstępne lub przełącz na niestandardowe dla innych brokerów." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Token autoryzacji (format dekodera meshcore) zostanie wygenerowany po połączeniu. Nazwa użytkownika to v1_ plus Twój 64-znakowy klucz publiczny (szesnastkowo). Lista odbiorców JWT odpowiada nazwie hosta serwera.", "noPrivateKey": "Brak pełnej tożsamości — zaimportuj konfigurację MeshCore do panelu Radio (klucze publiczne i prywatne) lub wklej ręcznie nazwę użytkownika (v1_) i token. Lista odbiorców JWT w tokenie musi być zgodna z nazwą hosta serwera.", "publicKeyMissing": "Brak klucza publicznego w tożsamości. Zaimportuj JSON konfiguracji MeshCore do panelu Radio (musi zawierać klucze publiczne i prywatne) lub wklej token brokera w polu hasła.", - "usernameBuildFailed": "Nie można utworzyć nazwy użytkownika LetsMesh. Zaimportuj JSON konfiguracji MeshCore do panelu Radio lub wklej ręcznie nazwę użytkownika (v1_) i token.", - "noIdentity": "Nie znaleziono tożsamości urządzenia. Zaimportuj JSON konfiguracji MeshCore do panelu Radio lub wklej ręcznie nazwę użytkownika i token." + "noIdentity": "Nie znaleziono tożsamości urządzenia. Zaimportuj JSON konfiguracji MeshCore do panelu Radio lub wklej ręcznie nazwę użytkownika i token.", + "usernameBuildFailed": "Nie można zbudować nazwy użytkownika MeshCore MQTT. Zaimportuj MeshCore config JSON do panelu radia lub wklej nazwę użytkownika (v1_) i token ręcznie." }, "meshcorePacketLogger": { "label": "Rejestrator pakietów — po podłączeniu do radia i MQTT przesyłaj podsumowania pakietów RX do {{topic}} w tym samym kształcie JSON co meshcoretomqtt. Domyślnie wyłączone; włącz tylko wtedy, gdy zamierzasz udostępniać odsłuchiwany ruch lotniczy analizatorowi pakietów." @@ -1345,9 +1349,6 @@ "disableShareAria": "Wyłącz opcję Udostępnij instancję i uruchom ponownie stos", "disableFailed": "Nie udało się wyłączyć instancji udostępniania." }, - "letsMeshRequiresWebSocket": "LetsMesh wymaga transportu WebSocket.", - "letsMeshRequiresPort": "LetsMesh wymaga portu {{port}}.", - "letsMeshKnownBrokersOnly": "Ustawienie wstępne LetsMesh /MeshMapper obsługuje tylko znane brokery podpisujące urządzenia. Użyj Custom dla innych brokerów.", "letsMeshUsernameV1Hex": "Nazwa użytkownika musi składać się z v1_oraz 64 znaków szesnastkowych (klucz publiczny).", "bleWeakSignalWarning": "Sygnał Bluetooth jest słaby ({{rssi}} dBm). Podejdź bliżej, aby uzyskać stabilne połączenie.", "bleRssiDbm": "{{rssi}} dBm", @@ -1356,7 +1357,18 @@ "linkQuality": "Jakość odnośnika", "linkQualityMs": "{{ms}} ms", "linkQualityUnavailable": "—", - "signalUnavailableWebBluetooth": "Niedostępne (Bluetooth internetowy)" + "signalUnavailableWebBluetooth": "Niedostępne (Bluetooth internetowy)", + "meshcoreCaBroker": "Broker MeshCore.CA", + "meshcoreCaPrimary": "Podstawowe", + "meshcoreCaBackup": "Backup", + "broker": "Pośrednik", + "letsMeshRequiresTls": "To ustawienie brokera wymaga TLS (wss). Włącz TLS lub przełącz na niestandardowy.", + "letsMeshRequiresWsPath": "To ustawienie wstępne brokera wymaga ścieżki WebSocket {{wsPath}}. Zresetuj ustawienie wstępne lub przełącz na niestandardowe.", + "letsMeshRegionUs": "USA", + "letsMeshRegionEu": "UE", + "letsMeshRequiresWebSocket": "To ustawienie wstępne brokera wymaga transportu WebSocket (wss).", + "letsMeshRequiresPort": "To ustawienie wstępne brokera wymaga portu {{port}} (WebSocket TLS).", + "letsMeshKnownBrokersOnly": "To ustawienie wstępne obsługuje tylko znanych publicznych brokerów MeshCore MQTT. Przełącz na Niestandardowe dla innych brokerów." }, "contactGroupsModal": { "failedLoadMembers": "Nie udało się załadować członków: {{message}}", @@ -5327,7 +5339,8 @@ "actionFailed": "Działanie nie powiodło się: {{reason}}", "unknownReason": "nieznanych powodów", "resendFailed": "Ponowne wysłanie nie powiodło się.", - "deleteFailed": "Nie udało się usunąć sesji." + "deleteFailed": "Nie udało się usunąć sesji.", + "sessionExpired": "Ta gra wygasła po okresie bezczynności i nie można już w nią grać. Użyj przycisku Usuń sesję, aby ją usunąć." }, "deleteConfirmTitle": "Usuń sesję", "deleteConfirmMessage": "Usunąć tę sesję gry z listy? Tej czynności nie można cofnąć.", @@ -5341,6 +5354,7 @@ "propagated": "Przechowywane", "failed": "Wymagana ponowna próba" }, - "drawOfferWaitingBanner": "Oferta losowania wysłana. Oczekiwanie na przeciwnika…" + "drawOfferWaitingBanner": "Oferta losowania wysłana. Oczekiwanie na przeciwnika…", + "idleExpiryNotice": "Gry wygasają po bezczynności i można je usunąć tylko wtedy. Wyzwania bez odpowiedzi: 24 godziny (wszystkie gry). Aktywne gry: kółko i krzyżyk po 24 godzinach, szachy i cztery w rzędzie po 7 dniach." } } diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 3f16787cb..b484c681a 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -909,8 +909,6 @@ "pairingFailed": "Falha no emparelhamento: {{msg}}", "pinPairingFailed": "Falha no emparelhamento do PIN: {{msg}}", "pinAcceptedNext": "PIN aceito. Emparelhamento concluído. Pressione Conectar e selecione seu dispositivo para finalizar a conexão.", - "autoConnectTimeout": "A conexão automática expirou.", - "autoConnectFailed": "Falha na conexão automática", "connectionFailed": "Falha na conexão" }, "cancelMqttConnect": "Cancelar", @@ -923,8 +921,6 @@ "mqttTlsEnabled": "Ativar TLS (mqtts / wss)", "mqttTlsInsecure": "Permitir TLS (certificado autoassinado) inseguro. Desativado por padrão — ative apenas se a sua corretora usar uma autoridade de certificação não pública.", "channelPsksPrivateUplinkNote": "O uplink do canal é AES-PSK criptografado em /2/e/ (sem espelho JSON para chaves privadas). Os monitores do corretor ainda podem registrar falhas de descriptografia para tráfego PKI/DM entre outros nós — isso é normal e não está relacionado aos PSKs do canal aqui.", - "sharedBleWithMeshtastic": "Este dispositivo Bluetooth já está conectado via Meshtastic. Mude para a guia Meshtastic para usá-lo ou escolha um rádio ou transporte diferente para o MeshCore.", - "sharedBleWithMeshtasticHint": "Um rádio físico não pode conter sessões separadas de Meshtastic e MeshCore Bluetooth ao mesmo tempo.", "reticulumStackTitle": "Pilha Reticulum", "reticulumStackHint": "Inicia o sidecar local Reticulum/LXMF (interfaces TCP e Auto).", "reticulumStartStack": "Iniciar pilha", @@ -1260,19 +1256,27 @@ "coloradomesh": "Colorado Mesh", "meshmapper": "MeshMapper", "ripple": "Ripple Networks", - "custom": "Personalizado" + "custom": "Personalizado", + "waev": "Waev", + "meshatse": "Meshat.se", + "meshcoreca": "MeshCore.CA", + "eastmesh": "EastMesh" }, "meshcorePresetDeviation": { "letsmesh": "LetsMesh precisa de WebSocket na porta 443 e servidor mqtt-us-v1.letsmesh.net ou mqtt-eu-v1.letsmesh.net. Use Região (EUA/UE) ou mude para Personalizado para outros corretores.", "coloradomesh": "Colorado Mesh precisa de WebSocket na porta 443 com TLS habilitado, servidor mqtt.meshcore.coloradomesh.org, caminho /ws. Redefina a predefinição ou mude para Personalizado para outros corretores.", - "meshmapper": "MeshMapper precisa de WebSocket na porta 443 e servidor mqtt.meshmapper.net. Redefina a predefinição ou mude para Personalizado para outros corretores." + "meshmapper": "MeshMapper precisa de WebSocket na porta 443 e servidor mqtt.meshmapper.net. Redefina a predefinição ou mude para Personalizado para outros corretores.", + "waev": "Waev precisa de WebSocket na porta 443 com TLS ativado, servidor mqtt.waev.app. Redefina a predefinição ou mude para Personalizado para outras corretoras.", + "meshatse": "Meshat.se precisa de WebSocket na porta 443 com TLS ativado, servidor meshcore-mqtt.meshat.se. Redefina a predefinição ou mude para Personalizado para outras corretoras.", + "meshcoreca": "MeshCore.CA precisa de WebSocket na porta 443 com TLS ativado, servidor mqtt1.meshcore.ca ou mqtt2.meshcore.ca. Use o Corretor (Principal/Backup) ou mude para Personalizado para outros corretores.", + "eastmesh": "O EastMesh precisa de WebSocket na porta 443 com TLS ativado, servidor mqtt2.eastmesh.au. Redefina a predefinição ou mude para Personalizado para outras corretoras." }, "meshcoreMqttIdentity": { "hasPrivateKey": "O token de autenticação (formato de decodificador meshcore) será gerado quando você se conectar. O nome de usuário é v1_ mais sua chave pública de 64 caracteres (hex). O público JWT corresponde ao nome do host do servidor.", "noPrivateKey": "Sem identidade completa — importe sua configuração do MeshCore no painel Radio (chaves públicas e privadas) ou cole o nome de usuário (v1_) e o token manualmente. O público JWT no token deve corresponder ao nome do host do servidor.", "publicKeyMissing": "Chave pública ausente na identidade. Importe seu JSON de configuração do MeshCore no painel Radio (deve incluir chaves públicas e privadas) ou cole um token do corretor no campo de senha.", - "usernameBuildFailed": "Não foi possível criar o nome de usuário LetsMesh. Importe seu JSON de configuração do MeshCore no painel Rádio ou cole o nome de usuário (v1_) e o token manualmente.", - "noIdentity": "Nenhuma identidade de dispositivo encontrada. Importe seu JSON de configuração do MeshCore no painel Rádio ou cole o nome de usuário e o token manualmente." + "noIdentity": "Nenhuma identidade de dispositivo encontrada. Importe seu JSON de configuração do MeshCore no painel Rádio ou cole o nome de usuário e o token manualmente.", + "usernameBuildFailed": "Não foi possível criar o nome de usuário MeshCore MQTT. Importe seu JSON de configuração do MeshCore no painel Rádio ou cole o nome de usuário (v1_) e o token manualmente." }, "meshcorePacketLogger": { "label": "Registrador de pacotes - quando conectado ao seu rádio e MQTT, encaminhe resumos de pacotes RX para {{topic}} no mesmo formato JSON do meshcoretomqtt. Desativado por padrão; habilite apenas se você pretende compartilhar o tráfego aéreo ouvido com um analisador de pacotes." @@ -1341,9 +1345,6 @@ "disableShareAria": "Desativar a instância Compartilhar e reiniciar a pilha", "disableFailed": "Falha ao desativar a instância Compartilhar." }, - "letsMeshRequiresWebSocket": "O LetsMesh requer transporte WebSocket.", - "letsMeshRequiresPort": "O LetsMesh requer a porta {{port}}.", - "letsMeshKnownBrokersOnly": "A predefinição LetsMesh /MeshMapper suporta apenas corretores de assinatura de dispositivos conhecidos. Use o Custom para outras corretoras.", "letsMeshUsernameV1Hex": "O nome de usuário deve ser v1_ seguido por 64 caracteres hexadecimais (chave pública).", "bleWeakSignalWarning": "O sinal Bluetooth é fraco ({{rssi}} dBm). Aproxime-se para uma conexão estável.", "bleRssiDbm": "{{rssi}} dBm", @@ -1352,7 +1353,18 @@ "linkQuality": "Qualidade da ligação", "linkQualityMs": "{{ms}} ms", "linkQualityUnavailable": "—", - "signalUnavailableWebBluetooth": "Indisponível (Web Bluetooth)" + "signalUnavailableWebBluetooth": "Indisponível (Web Bluetooth)", + "meshcoreCaBroker": "Corretora MeshCore.CA", + "meshcoreCaPrimary": "Principal", + "meshcoreCaBackup": "Backup", + "broker": "Corretora", + "letsMeshRequiresTls": "Esta predefinição de corretor requer TLS (wss). Ative o TLS ou mude para Personalizado.", + "letsMeshRequiresWsPath": "Esta predefinição de corretor requer o caminho WebSocket {{wsPath}}. Redefina a predefinição ou mude para Personalizado.", + "letsMeshRegionUs": "EUA", + "letsMeshRegionEu": "UE", + "letsMeshRequiresWebSocket": "Esta predefinição de corretor requer transporte WebSocket (wss).", + "letsMeshRequiresPort": "Esta predefinição de corretor requer a porta {{port}} (WebSocket TLS).", + "letsMeshKnownBrokersOnly": "Esta predefinição suporta apenas corretoras públicas conhecidas do MeshCore MQTT. Mude para Personalizado para outras corretoras." }, "contactGroupsModal": { "failedLoadMembers": "Falha ao carregar membros: {{message}}", @@ -5323,7 +5335,8 @@ "actionFailed": "Falha na ação: {{reason}}", "unknownReason": "razão desconhecida.", "resendFailed": "O reenvio falhou", - "deleteFailed": "Falha ao excluir sessão." + "deleteFailed": "Falha ao excluir sessão.", + "sessionExpired": "Este jogo expirou após um período de inatividade e não pode mais ser jogado. Use Excluir sessão para removê-la." }, "deleteConfirmTitle": "Excluir sessão?", "deleteConfirmMessage": "Remover esta sessão de jogo da sua lista? Isso não pode ser desfeito.", @@ -5337,6 +5350,7 @@ "propagated": "Armazenada", "failed": "Tentativa necessária" }, - "drawOfferWaitingBanner": "Oferta de empate enviada. Aguardando oponente..." + "drawOfferWaitingBanner": "Oferta de empate enviada. Aguardando oponente...", + "idleExpiryNotice": "Os jogos expiram após a inatividade e só podem ser excluídos. Desafios não respondidos: 24 horas (todos os jogos). Jogos ativos: tic-tac-toe após 24 horas, xadrez e four-in-a-row após 7 dias." } } diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 51a6c2827..af5b58285 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -911,8 +911,6 @@ "pairingFailed": "Не удалось выполнить сопряжение: {{msg}}", "pinPairingFailed": "Не удалось выполнить сопряжение PIN-кода: {{msg}}.", "pinAcceptedNext": "PIN-код принят. Сопряжение завершено. Нажмите «Подключиться» и выберите свое устройство, чтобы завершить подключение.", - "autoConnectTimeout": "Время автоматического подключения истекло.", - "autoConnectFailed": "Автоподключение не удалось", "connectionFailed": "Соединение не удалось" }, "cancelMqttConnect": "Отмена", @@ -925,8 +923,6 @@ "mqttTlsEnabled": "Включить TLS (mqtts / wss)", "mqttTlsInsecure": "Разрешить небезопасный TLS (самозаверяющий сертификат). По умолчанию выключено — включено только в том случае, если ваш брокер использует непубличный центр сертификации.", "channelPsksPrivateUplinkNote": "Канал восходящей связи зашифрован AES-PSK на /2/e/ (нет зеркала JSON для закрытых ключей). Мониторы брокеров все еще могут регистрировать сбои расшифровки для трафика PKI/DM между другими узлами — это нормально и не связано с канальными PSK здесь.", - "sharedBleWithMeshtastic": "Это устройство Bluetooth уже подключено через Meshtastic. Переключитесь на вкладку Meshtastic, чтобы использовать ее, или выберите другое радио или транспорт для MeshCore.", - "sharedBleWithMeshtasticHint": "Одно физическое радио не может одновременно проводить отдельные сеансы Bluetooth Meshtastic и MeshCore.", "reticulumStackTitle": "Стек Reticulum", "reticulumStackHint": "Запустите локальный sidecar Reticulum/LXMF перед созданием идентичности или добавлением интерфейсов.", "reticulumStartStack": "Запустить стек", @@ -1262,19 +1258,27 @@ "coloradomesh": "Colorado Mesh", "meshmapper": "MeshMapper", "ripple": "Ripple Networks", - "custom": "Обычай" + "custom": "Обычай", + "waev": "Ваев", + "meshatse": "Meshat.se", + "meshcoreca": "MeshCore.CA", + "eastmesh": "EastMesh" }, "meshcorePresetDeviation": { "letsmesh": "LetsMesh нужен WebSocket на порту 443 и сервер mqtt-us-v1.letsmesh.net или mqtt-eu-v1.letsmesh.net. Используйте регион (США/ЕС) или переключитесь на Custom для других брокеров.", "coloradomesh": "Colorado Mesh требуется WebSocket на порту 443 с включенным TLS, сервер mqtt.meshcore.coloradomesh.org, путь /ws. Сбросьте предустановку или переключитесь на Custom для других брокеров.", - "meshmapper": "MeshMapper нуждается в WebSocket на порту 443 и сервере mqtt.meshmapper.net. Сбросьте предустановку или переключитесь на Custom для других брокеров." + "meshmapper": "MeshMapper нуждается в WebSocket на порту 443 и сервере mqtt.meshmapper.net. Сбросьте предустановку или переключитесь на Custom для других брокеров.", + "waev": "Waev нужен WebSocket на порту 443 с включенным TLS, сервер mqtt.waev.app. Сбросьте предустановку или переключитесь на Custom для других брокеров.", + "meshatse": "Meshat.se требуется WebSocket на порту 443 с включенным TLS, сервер meshcore-mqtt.meshat.se. Сбросьте предустановку или переключитесь на Custom для других брокеров.", + "meshcoreca": "MeshCore.CA требуется WebSocket на порту 443 с включенным TLS, сервер mqtt1.meshcore.ca или mqtt2.meshcore.ca. Используйте Broker (Primary/Backup) или переключитесь на Custom для других брокеров.", + "eastmesh": "EastMesh требуется WebSocket на порту 443 с включенным TLS, сервер mqtt2.eastmesh.au. Сбросьте предустановку или переключитесь на Custom для других брокеров." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Токен аутентификации (формат Meshcore-декодера) будет сгенерирован при подключении. Имя пользователя — v1_ плюс ваш 64-значный открытый ключ (шестнадцатеричный). Аудитория JWT соответствует имени хоста Сервера.", "noPrivateKey": "Нет полной идентификации — импортируйте конфигурацию MeshCore на панель Radio (открытый и закрытый ключи) или вставьте имя пользователя (v1_<открытый ключ>) и токен вручную. Аудитория JWT в токене должна соответствовать имени хоста Сервера.", "publicKeyMissing": "Открытый ключ отсутствует в удостоверении личности. Импортируйте конфигурацию MeshCore в формате JSON на панель Radio (должна включать открытый и закрытый ключи) или вставьте токен брокера в поле пароля.", - "usernameBuildFailed": "Не удалось создать имя пользователя LetsMesh. Импортируйте конфигурацию MeshCore в формате JSON на панель Radio или вставьте имя пользователя (v1_<открытый ключ>) и токен вручную.", - "noIdentity": "Идентификатор устройства не найден. Импортируйте конфигурацию MeshCore в формате JSON на панель «Радио» или вставьте имя пользователя и токен вручную." + "noIdentity": "Идентификатор устройства не найден. Импортируйте конфигурацию MeshCore в формате JSON на панель «Радио» или вставьте имя пользователя и токен вручную.", + "usernameBuildFailed": "Не удалось создать имя пользователя MeshCore MQTT. Импортируйте конфигурацию MeshCore JSON на панели Radio или вставьте имя пользователя (v1_) и токен вручную." }, "meshcorePacketLogger": { "label": "Регистратор пакетов — при подключении к вашему радиомодулю и MQTT пересылайте сводные данные RX-пакетов на {{topic}} в той же форме JSON, что и meshcoretomqtt. По умолчанию выключено; Включайте только в том случае, если вы собираетесь передавать прослушиваемый воздушный трафик анализатору пакетов." @@ -1343,9 +1347,6 @@ "disableShareAria": "Отключите «Поделиться экземпляром» и перезапустите стек", "disableFailed": "Не удалось отключить экземпляр Share." }, - "letsMeshRequiresWebSocket": "Для LetsMesh требуется транспорт WebSocket.", - "letsMeshRequiresPort": "LetsMesh требует порт {{port}}.", - "letsMeshKnownBrokersOnly": "Предустановка LetsMesh /MeshMapper поддерживает только известные брокеры подписи устройств. Используйте Custom для других брокеров.", "letsMeshUsernameV1Hex": "Имя пользователя должно быть v1_, за которым должны следовать 64 шестнадцатеричных символа (открытый ключ).", "bleWeakSignalWarning": "Сигнал Bluetooth слабый ({{rssi}} dBm). Переместитесь ближе для стабильного соединения.", "bleRssiDbm": "{{rssi}} дБм", @@ -1354,7 +1355,18 @@ "linkQuality": "Качество связи:", "linkQualityMs": "{{ms}} мс", "linkQualityUnavailable": "—", - "signalUnavailableWebBluetooth": "Недоступно (веб-Bluetooth)" + "signalUnavailableWebBluetooth": "Недоступно (веб-Bluetooth)", + "meshcoreCaBroker": "Брокер MeshCore.CA", + "meshcoreCaPrimary": "Первичные", + "meshcoreCaBackup": "Резервное копирование", + "broker": "Брокер", + "letsMeshRequiresTls": "Для этой предустановки брокера требуется TLS (wss). Включите TLS или переключитесь на Custom.", + "letsMeshRequiresWsPath": "Для этой предустановки брокера требуется путь WebSocket {{wsPath}}. Сбросьте предустановку или переключитесь на Custom (Пользовательский).", + "letsMeshRegionUs": "США", + "letsMeshRegionEu": "ЕС", + "letsMeshRequiresWebSocket": "Для этой предустановки брокера требуется транспорт WebSocket (wss).", + "letsMeshRequiresPort": "Для этой предустановки брокера требуется порт {{port}} (WebSocket TLS).", + "letsMeshKnownBrokersOnly": "Эта предустановка поддерживает только известные общедоступные брокеры MeshCore MQTT. Переключитесь на Custom для других брокеров." }, "contactGroupsModal": { "failedLoadMembers": "Не удалось загрузить участников: {{message}}.", @@ -5325,7 +5337,8 @@ "actionFailed": "Действие не выполнено: {{reason}}", "unknownReason": "неизвесная причина", "resendFailed": "Повторная отправка не удалась.", - "deleteFailed": "Не удалось удалить сеанс." + "deleteFailed": "Не удалось удалить сеанс.", + "sessionExpired": "Эта игра истекла после периода бездействия и больше не может быть воспроизведена. Используйте Delete session (Удалить сеанс), чтобы удалить его." }, "deleteConfirmTitle": "Удалить сессию", "deleteConfirmMessage": "Удалить эту игровую сессию из списка? Это действие нельзя отменить.", @@ -5339,6 +5352,7 @@ "propagated": "Хранится", "failed": "Требуется повторить попытку" }, - "drawOfferWaitingBanner": "Предложение о розыгрыше отправлено. Ожидание оппонента…" + "drawOfferWaitingBanner": "Предложение о розыгрыше отправлено. Ожидание оппонента…", + "idleExpiryNotice": "Игры истекают после неактивности и могут быть удалены только после этого. Испытания без ответа: 24 часа (все игры). Активные партии: крестики-нолики через 24 часа, шахматы и четыре в ряд через 7 дней." } } diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index cdb9b080d..5fc5a1339 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -909,8 +909,6 @@ "pairingFailed": "Eşleştirme başarısız oldu: {{msg}}", "pinPairingFailed": "PIN eşleştirme başarısız oldu: {{msg}}", "pinAcceptedNext": "PIN kabul edildi. Eşleştirme tamamlandı. Bağlan'a basın ve bağlantıyı tamamlamak için cihazınızı seçin.", - "autoConnectTimeout": "Otomatik bağlantı zaman aşımına uğradı.", - "autoConnectFailed": "Otomatik bağlantı başarısız oldu", "connectionFailed": "Bağlantı başarısız oldu" }, "cancelMqttConnect": "İptal etmek", @@ -923,8 +921,6 @@ "mqttTlsEnabled": "TLS'yi etkinleştir (mqtts / wss)", "mqttTlsInsecure": "Güvensiz TLS'ye (kendinden imzalı sertifika) izin verin. Varsayılan olarak kapalı — yalnızca brokeriniz halka açık olmayan bir CA kullanıyorsa etkinleştirin.", "channelPsksPrivateUplinkNote": "Kanal uplink/2/e/üzerinde şifrelenmiş AES - PSK'dır (özel anahtarlar için JSON aynası yoktur). Broker monitörleri, diğer düğümler arasındaki PKI/DM trafiği için hala şifre çözme hatalarını günlüğe kaydedebilir — bu normaldir ve buradaki kanal PSK'larıyla ilgisi yoktur.", - "sharedBleWithMeshtastic": "Bu Bluetooth cihazı Meshtastic üzerinden zaten bağlı. Kullanmak için Meshtastic sekmesine geçin veya MeshCore için farklı bir radyo veya aktarım seçin.", - "sharedBleWithMeshtasticHint": "Bir fiziksel radyo, Meshtastic ve MeshCore Bluetooth oturumlarını aynı anda ayrı ayrı tutamaz.", "reticulumStackTitle": "Reticulum yığını", "reticulumStackHint": "Yerel Reticulum/LXMF sidecarını başlatır (TCP ve Auto arayüzleri).", "reticulumStartStack": "Yığını başlat", @@ -1260,19 +1256,27 @@ "coloradomesh": "Colorado Mesh", "meshmapper": "MeshMapper", "ripple": "Ripple Networks", - "custom": "Gelenek" + "custom": "Gelenek", + "waev": "Waev", + "meshatse": "Meshat.se", + "meshcoreca": "MeshCore.CA", + "eastmesh": "EastMesh" }, "meshcorePresetDeviation": { "letsmesh": "LetsMesh'in 443 numaralı bağlantı noktasında WebSocket'e ve mqtt-us-v1.letsmesh.net veya mqtt-eu-v1.letsmesh.net sunucusuna ihtiyacı vardır. Bölge'yi (ABD/AB) kullanın veya diğer aracılar için Özel'e geçin.", "coloradomesh": "Colorado Mesh'in TLS etkinleştirilmiş 443 numaralı bağlantı noktasında WebSocket'e ihtiyacı var, sunucu mqtt.meshcore.coloradomesh.org, yol /ws. Ön ayarı sıfırlayın veya diğer aracılar için Özel'e geçin.", - "meshmapper": "MeshMapper'ın 443 numaralı bağlantı noktasında WebSocket'e ve mqtt.meshmapper.net sunucusuna ihtiyacı vardır. Ön ayarı sıfırlayın veya diğer aracılar için Özel'e geçin." + "meshmapper": "MeshMapper'ın 443 numaralı bağlantı noktasında WebSocket'e ve mqtt.meshmapper.net sunucusuna ihtiyacı vardır. Ön ayarı sıfırlayın veya diğer aracılar için Özel'e geçin.", + "waev": "Waev, TLS etkin, sunucu mqtt.waev.app ile 443 numaralı bağlantı noktasında WebSocket'e ihtiyaç duyar. Ön ayarı sıfırlayın veya diğer aracılar için Özel'e geçin.", + "meshatse": "Meshat.se, TLS etkinleştirilmiş, sunucu meshcore-mqtt.meshat.se ile 443 numaralı bağlantı noktasında WebSocket'e ihtiyaç duyar. Ön ayarı sıfırlayın veya diğer aracılar için Özel'e geçin.", + "meshcoreca": "MeshCore.CA, TLS etkinleştirilmiş 443 numaralı bağlantı noktasında WebSocket'e, mqtt1.meshcore.ca veya mqtt2.meshcore.ca sunucusuna ihtiyaç duyar. Broker (Birincil/Yedekleme) kullanın veya diğer brokerler için Özel'e geçin.", + "eastmesh": "EastMesh, TLS etkinleştirilmiş, mqtt2.eastmesh.au sunucusuyla 443 numaralı bağlantı noktasında WebSocket'e ihtiyaç duyar. Ön ayarı sıfırlayın veya diğer aracılar için Özel'e geçin." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Bağlandığınızda kimlik doğrulama jetonu (meshcore-kod çözücü formatı) oluşturulacaktır. Kullanıcı adı v1_ artı 64 karakterlik genel anahtarınızdan (onaltılık) oluşur. JWT izleyicisi Sunucu ana bilgisayar adıyla eşleşir.", "noPrivateKey": "Tam kimlik yok — MeshCore yapılandırmanızı Radyo paneline (genel ve özel anahtarlar) içe aktarın veya kullanıcı adını (v1_) ve belirteci manuel olarak yapıştırın. Belirteçteki JWT hedef kitlesi, Sunucu ana bilgisayar adıyla eşleşmelidir.", "publicKeyMissing": "Kimlikte genel anahtar eksik. MeshCore config JSON'unuzu Radyo paneline içe aktarın (genel ve özel anahtarlar içermelidir) veya şifre alanına bir aracı belirteci yapıştırın.", - "usernameBuildFailed": "LetsMesh kullanıcı adı oluşturulamadı. MeshCore config JSON'unuzu Radyo paneline içe aktarın veya kullanıcı adını (v1_) ve belirteci manuel olarak yapıştırın.", - "noIdentity": "Cihaz kimliği bulunamadı. MeshCore config JSON'unuzu Radyo paneline içe aktarın veya kullanıcı adını ve belirteci manuel olarak yapıştırın." + "noIdentity": "Cihaz kimliği bulunamadı. MeshCore config JSON'unuzu Radyo paneline içe aktarın veya kullanıcı adını ve belirteci manuel olarak yapıştırın.", + "usernameBuildFailed": "MeshCore MQTT kullanıcı adı oluşturulamadı. MeshCore yapılandırma JSON'unuzu Radyo panelinden içe aktarın veya kullanıcı adını (v1_) ve belirteci manuel olarak yapıştırın." }, "meshcorePacketLogger": { "label": "Paket kaydedici — radyonuza ve MQTT'nize bağlandığında, RX paket özetlerini meshcoretomqtt ile aynı JSON biçiminde {{topic}}'ye iletin. Varsayılan olarak kapalı; yalnızca duyulan hava trafiğini bir paket analizörüyle paylaşmak istiyorsanız etkinleştirin." @@ -1341,9 +1345,6 @@ "disableShareAria": "Paylaşım örneğini kapat ve yığını yeniden başlat", "disableFailed": "Paylaşım örneği kapatılamadı." }, - "letsMeshRequiresWebSocket": "LetsMesh, WebSocket taşımacılığı gerektirir.", - "letsMeshRequiresPort": "LetsMesh, {{port}} numaralı bağlantı noktasını gerektirir.", - "letsMeshKnownBrokersOnly": "LetsMesh /MeshMapper ön ayarı yalnızca bilinen cihaz imzalama aracılarını destekler. Diğer brokerler için Özel'i kullanın.", "letsMeshUsernameV1Hex": "Kullanıcı adı v1_ ve ardından 64 onaltılık karakter (genel anahtar) olmalıdır.", "bleWeakSignalWarning": "Bluetooth sinyali zayıf ({{rssi}} dBm). Sabit bir bağlantı için yaklaşın.", "bleRssiDbm": "{{rssi}} dBm", @@ -1352,7 +1353,18 @@ "linkQuality": "Hat kalitesi", "linkQualityMs": "{{ms}} ms", "linkQualityUnavailable": "—", - "signalUnavailableWebBluetooth": "Kullanılamıyor (Web Bluetooth)" + "signalUnavailableWebBluetooth": "Kullanılamıyor (Web Bluetooth)", + "meshcoreCaBroker": "MeshCore.CA brokeri", + "meshcoreCaPrimary": "Birincil", + "meshcoreCaBackup": "Yedekleme", + "broker": "Aracı Kurum", + "letsMeshRequiresTls": "Bu broker ön ayarı TLS (wss) gerektirir. TLS'yi etkinleştirin veya Özel'e geçin.", + "letsMeshRequiresWsPath": "Bu aracı kurum ön ayarı WebSocket yolu {{wsPath}} gerektirir. Ön ayarı sıfırlayın veya Özel'e geçin.", + "letsMeshRegionUs": "ABD", + "letsMeshRegionEu": "AB", + "letsMeshRequiresWebSocket": "Bu broker ön ayarı WebSocket taşımacılığı (wss) gerektirir.", + "letsMeshRequiresPort": "Bu aracı kurum ön ayarı {{port}} (WebSocket TLS) bağlantı noktasını gerektirir.", + "letsMeshKnownBrokersOnly": "Bu ön ayar yalnızca bilinen genel MeshCore MQTT brokerlerini destekler. Diğer brokerler için Özel'e geçin." }, "contactGroupsModal": { "failedLoadMembers": "Üyeler yüklenemedi: {{message}}", @@ -5323,7 +5335,8 @@ "actionFailed": "İşlem başarısız oldu: {{reason}}", "unknownReason": "bilinmeyen neden", "resendFailed": "Yeniden Gönderilemedi", - "deleteFailed": "Oturum silinemedi." + "deleteFailed": "Oturum silinemedi.", + "sessionExpired": "Bu oyun bir süre hareketsiz kaldıktan sonra sona erdi ve artık oynanamaz. Silmek için oturumu sil'i kullanın." }, "deleteConfirmTitle": "Oturumları Yapılandır...", "deleteConfirmMessage": "Bu oyun oturumu listenden kaldırılsın mı? Bu işlem geri alınamaz.", @@ -5337,6 +5350,7 @@ "propagated": "ve saklanmalı.", "failed": "Yeniden deneme gerekli" }, - "drawOfferWaitingBanner": "Beraberlik teklifi gönderildi. Rakip bekleniyor…" + "drawOfferWaitingBanner": "Beraberlik teklifi gönderildi. Rakip bekleniyor…", + "idleExpiryNotice": "Oyunlar işlem yapılmadığında sona erer ve yalnızca silinebilir. Cevapsız zorluklar: 24 saat (tüm oyunlar). Aktif oyunlar: 24 saat sonra tic - tac - toe, 7 gün sonra satranç ve four - in - a - row." } } diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 4b2c2ed95..345d0040a 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -911,8 +911,6 @@ "pairingFailed": "Помилка створення пари: {{msg}}", "pinPairingFailed": "Помилка підключення PIN-коду: {{msg}}", "pinAcceptedNext": "PIN прийнято. Сполучення завершено. Натисніть Connect і виберіть свій пристрій, щоб завершити підключення.", - "autoConnectTimeout": "Час очікування автоматичного підключення минув.", - "autoConnectFailed": "Помилка автоматичного підключення", "connectionFailed": "Помилка підключення" }, "cancelMqttConnect": "Скасувати", @@ -925,8 +923,6 @@ "mqttTlsEnabled": "Увімкнути TLS (mqtts / wss)", "mqttTlsInsecure": "Дозволити незахищений TLS (самопідписаний сертифікат). Вимкнено за замовчуванням — ввімкніть, лише якщо ваш брокер використовує непублічний ЦС.", "channelPsksPrivateUplinkNote": "Канал висхідного зв'язку зашифрований AES-PSK на /2/e/ (немає дзеркала JSON для закритих ключів). Монітори брокерів все ще можуть реєструвати збої розшифрування трафіку PKI/DM між іншими вузлами — це нормально і не пов'язано з канальними PSK тут.", - "sharedBleWithMeshtastic": "Цей пристрій Bluetooth вже підключено через Meshtastic. Перейдіть на вкладку Meshtastic, щоб використовувати її, або виберіть інше радіо або транспорт для MeshCore.", - "sharedBleWithMeshtasticHint": "Одне фізичне радіо не може одночасно проводити окремі сеанси Meshtastic та MeshCore Bluetooth.", "reticulumStackTitle": "Стек Reticulum", "reticulumStackHint": "Запускає локальний sidecar Reticulum/LXMF (інтерфейси TCP та Auto).", "reticulumStartStack": "Запустити стек", @@ -1262,19 +1258,27 @@ "coloradomesh": "Colorado Mesh", "meshmapper": "MeshMapper", "ripple": "Ripple Networks", - "custom": "Custom" + "custom": "Custom", + "waev": "Ваєв", + "meshatse": "Meshat.se", + "meshcoreca": "MeshCore.CA", + "eastmesh": "EastMesh" }, "meshcorePresetDeviation": { "letsmesh": "LetsMesh потребує WebSocket на порту 443 і сервері mqtt-us-v1.letsmesh.net або mqtt-eu-v1.letsmesh.net. Використовуйте регіон (США/ЄС) або перейдіть на спеціальний для інших брокерів.", "coloradomesh": "Colorado Mesh потребує WebSocket на порту 443 із увімкненим TLS, сервер mqtt.meshcore.coloradomesh.org, шлях /ws. Скиньте попередні налаштування або перейдіть на Custom для інших брокерів.", - "meshmapper": "MeshMapper потребує WebSocket на порту 443 і сервері mqtt.meshmapper.net. Скиньте попередні налаштування або перейдіть на Custom для інших брокерів." + "meshmapper": "MeshMapper потребує WebSocket на порту 443 і сервері mqtt.meshmapper.net. Скиньте попередні налаштування або перейдіть на Custom для інших брокерів.", + "waev": "Waev потрібен WebSocket на порту 443 з увімкненим TLS, сервер mqtt.waev.app. Скиньте попереднє налаштування або перейдіть на Custom (Користувацький) для інших брокерів.", + "meshatse": "Meshat.se потрібен WebSocket на порту 443 з увімкненим TLS, сервер meshcore-mqtt.meshat.se. Скиньте попереднє налаштування або перейдіть на Custom (Користувацький) для інших брокерів.", + "meshcoreca": "MeshCore.CA потрібен WebSocket на порту 443 з увімкненим TLS, сервер mqtt1.meshcore.ca або mqtt2.meshcore.ca. Використовуйте Broker (Primary/Backup) або перейдіть на Custom для інших брокерів.", + "eastmesh": "EastMesh потрібен WebSocket на порту 443 з увімкненим TLS, сервер mqtt2.eastmesh.au. Скиньте попереднє налаштування або перейдіть на Custom (Користувацький) для інших брокерів." }, "meshcoreMqttIdentity": { "hasPrivateKey": "Маркер автентифікації (формат meshcore-decoder) буде згенеровано під час підключення. Ім’я користувача v1_ плюс ваш відкритий ключ із 64 символів (шістнадцятковий). Аудиторія JWT відповідає імені хоста сервера.", "noPrivateKey": "Немає повної ідентичності — імпортуйте свою конфігурацію MeshCore на панель радіо (відкритий і закритий ключі) або вставте ім’я користувача (v1_<відкритий ключ>) і маркер вручну. Аудиторія JWT у маркері має відповідати імені хоста сервера.", "publicKeyMissing": "В ідентифікаторі відсутній відкритий ключ. Імпортуйте JSON конфігурації MeshCore на панель Radio (має містити відкритий і закритий ключі) або вставте маркер брокера в поле пароля.", - "usernameBuildFailed": "Не вдалося створити ім’я користувача LetsMesh. Імпортуйте свою конфігурацію MeshCore JSON на панель радіо або вставте ім’я користувача (v1_<відкритий ключ>) і маркер вручну.", - "noIdentity": "Ідентифікатор пристрою не знайдено. Імпортуйте JSON конфігурації MeshCore на панель радіо або вставте ім’я користувача та маркер вручну." + "noIdentity": "Ідентифікатор пристрою не знайдено. Імпортуйте JSON конфігурації MeshCore на панель радіо або вставте ім’я користувача та маркер вручну.", + "usernameBuildFailed": "Не вдалося зібрати ім'я користувача MeshCore MQTT. Імпорт конфігурації MeshCore JSON на панелі радіо або вставка імені користувача (v1_) і маркера вручну." }, "meshcorePacketLogger": { "label": "Реєстратор пакетів — при підключенні до радіо та MQTT пересилайте підсумки пакетів RX на {{topic}} у тій самій формі JSON, що й meshcoretomqtt. Вимкнено за замовчуванням; увімкніть, лише якщо ви маєте намір обмінюватися почутим повітряним трафіком з аналізатором пакетів." @@ -1343,9 +1347,6 @@ "disableShareAria": "Вимкніть функцію Поділитися екземпляром і перезапустіть стек", "disableFailed": "Не вдалося вимкнути екземпляр спільного доступу." }, - "letsMeshRequiresWebSocket": "Для LetsMesh потрібен транспорт WebSocket.", - "letsMeshRequiresPort": "Для LetsMesh потрібен порт {{port}}.", - "letsMeshKnownBrokersOnly": "Попереднє налаштування LetsMesh /MeshMapper підтримує лише відомих брокерів, які підписують пристрої. Використовуйте Custom для інших брокерів.", "letsMeshUsernameV1Hex": "Ім'я користувача має бути v1_ з 64 шістнадцятковими символами (відкритий ключ).", "bleWeakSignalWarning": "Сигнал Bluetooth слабкий ({{rssi}} dBm). Підійдіть ближче для стабільного з'єднання.", "bleRssiDbm": "{{rssi}} дБм", @@ -1354,7 +1355,18 @@ "linkQuality": "Якість зв'язку", "linkQualityMs": "{{ms}} мс", "linkQualityUnavailable": "—", - "signalUnavailableWebBluetooth": "Недоступно (веб Bluetooth)" + "signalUnavailableWebBluetooth": "Недоступно (веб Bluetooth)", + "meshcoreCaBroker": "Брокер MeshCore.CA", + "meshcoreCaPrimary": "Осн.", + "meshcoreCaBackup": "Заднім ходом", + "broker": "Брокер", + "letsMeshRequiresTls": "Для цього попереднього налаштування брокера потрібен TLS (wss). Увімкніть TLS або перейдіть на Custom.", + "letsMeshRequiresWsPath": "Для цього попереднього налаштування брокера потрібен шлях WebSocket {{wsPath}}. Скиньте попереднє налаштування або переключіться на Custom (Користувацький).", + "letsMeshRegionUs": "US", + "letsMeshRegionEu": "ЄС", + "letsMeshRequiresWebSocket": "Для цього попереднього налаштування брокера потрібен транспорт WebSocket (wss).", + "letsMeshRequiresPort": "Для цього попереднього налаштування брокера потрібен порт {{port}} (WebSocket TLS).", + "letsMeshKnownBrokersOnly": "Цей попередній набір підтримує лише відомих публічних брокерів MeshCore MQTT. Перейдіть до користувацького для інших брокерів." }, "contactGroupsModal": { "failedLoadMembers": "Не вдалося завантажити учасників: {{message}}", @@ -5325,7 +5337,8 @@ "actionFailed": "Помилка дії: {{reason}}", "unknownReason": "Невідома причина", "resendFailed": "Помилка повторного надсилання.", - "deleteFailed": "Не вдалося видалити сеанс." + "deleteFailed": "Не вдалося видалити сеанс.", + "sessionExpired": "Ця гра закінчилася після періоду бездіяльності і більше не може бути відтворена. Використовуйте Delete session (Видалити сеанс), щоб видалити його." }, "deleteConfirmTitle": "Вилучити сеанс...", "deleteConfirmMessage": "Видалити цю ігрову сесію зі списку? Цю дію неможливо скасувати.", @@ -5339,6 +5352,7 @@ "propagated": "Збережено", "failed": "Потрібна повторна спроба" }, - "drawOfferWaitingBanner": "Надіслано пропозицію розіграшу. Очікування опонента…" + "drawOfferWaitingBanner": "Надіслано пропозицію розіграшу. Очікування опонента…", + "idleExpiryNotice": "Термін дії ігор закінчується після бездіяльності, а потім їх можна видалити. Проблеми без відповіді: 24 години (всі ігри). Активні ігри: хрестики-ноги через 24 години, шахи та чотири в ряд через 7 днів." } } diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 3b2eeec37..9a3e98855 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -909,8 +909,6 @@ "pairingFailed": "配对失败:{{msg}}", "pinPairingFailed": "PIN 配对失败:{{msg}}", "pinAcceptedNext": "PIN 码已接受。配对完成。按“连接”并选择您的设备以完成连接。", - "autoConnectTimeout": "自动连接超时。", - "autoConnectFailed": "自动连接失败", "connectionFailed": "连接失败" }, "cancelMqttConnect": "取消", @@ -923,8 +921,6 @@ "mqttTlsEnabled": "启用TLS (mqtts/wss)", "mqttTlsInsecure": "允许不安全的TLS (自签名证书)。默认情况下关闭—仅当您的经纪人使用非公开CA时才启用。", "channelPsksPrivateUplinkNote": "频道上行链路是在/2/e/上加密的AES-PSK (私钥没有JSON镜像)。Broker监视器仍可能记录其他节点之间PKI/DM流量的解密失败—这是正常的,与此处的通道PSK无关。", - "sharedBleWithMeshtastic": "此蓝牙设备已通过Meshtastic连接。切换到Meshtastic选项卡以使用它,或为MeshCore选择其他无线电或传输方式。", - "sharedBleWithMeshtasticHint": "一个物理收音机不能同时举行单独的Meshtastic和MeshCore蓝牙会话。", "reticulumStackTitle": "Reticulum 堆栈", "reticulumStackHint": "启动本地 Reticulum/LXMF 侧车进程(TCP 和 Auto 接口)。", "reticulumStartStack": "启动堆栈", @@ -1260,19 +1256,27 @@ "coloradomesh": "Colorado Mesh", "meshmapper": "MeshMapper", "ripple": "Ripple Networks", - "custom": "风俗" + "custom": "风俗", + "waev": "Waev", + "meshatse": "Meshat.se", + "meshcoreca": "MeshCore.CA", + "eastmesh": "EastMesh" }, "meshcorePresetDeviation": { "letsmesh": "LetsMesh 需要端口 443 上的 WebSocket 和服务器 mqtt-us-v1.letsmesh.net 或 mqtt-eu-v1.letsmesh.net。使用区域(美国/欧盟),或切换到其他经纪商的自定义。", "coloradomesh": "Colorado Mesh 需要端口 443 上的 WebSocket 且启用了 TLS,服务器 mqtt.meshcore.coloradomesh.org,路径 /ws。重置预设或切换到其他经纪商的自定义。", - "meshmapper": "MeshMapper 需要端口 443 上的 WebSocket 和服务器 mqtt.meshmapper.net。重置预设或切换到其他经纪商的自定义。" + "meshmapper": "MeshMapper 需要端口 443 上的 WebSocket 和服务器 mqtt.meshmapper.net。重置预设或切换到其他经纪商的自定义。", + "waev": "Waev需要在端口443上启用TLS的WebSocket ,服务器mqtt.waev.app。对于其他经纪商,请重置预设或切换到自定义。", + "meshatse": "Meshat.se需要在端口443上启用TLS的WebSocket ,服务器meshcore-mqtt.meshat.se。对于其他经纪商,请重置预设或切换到自定义。", + "meshcoreca": "MeshCore.CA需要启用TLS的端口443上的WebSocket ,服务器mqtt1.meshcore.ca或mqtt2.meshcore.ca。使用经纪人(主要/备份) ,或切换到其他经纪人的自定义。", + "eastmesh": "EastMesh需要在端口443上启用TLS的WebSocket ,服务器mqtt2.eastmesh.au。对于其他经纪商,请重置预设或切换到自定义。" }, "meshcoreMqttIdentity": { "hasPrivateKey": "连接时将生成身份验证令牌(meshcore-decoder 格式)。用户名是 v1_ 加上您的 64 字符公钥(十六进制)。 JWT 受众与服务器主机名匹配。", "noPrivateKey": "没有完整的身份 - 在无线电面板中导入您的 MeshCore 配置(公钥和私钥),或手动粘贴用户名 (v1_<公钥>) 和令牌。令牌中的 JWT 受众必须与服务器主机名匹配。", "publicKeyMissing": "身份中缺少公钥。在无线电面板中导入 MeshCore 配置 JSON(必须包括公钥和私钥),或将代理令牌粘贴到密码字段中。", - "usernameBuildFailed": "无法构建 LetsMesh 用户名。在 Radio 面板中导入 MeshCore 配置 JSON,或手动粘贴用户名 (v1_) 和令牌。", - "noIdentity": "未找到设备标识。在 Radio 面板中导入 MeshCore 配置 JSON,或手动粘贴用户名和令牌。" + "noIdentity": "未找到设备标识。在 Radio 面板中导入 MeshCore 配置 JSON,或手动粘贴用户名和令牌。", + "usernameBuildFailed": "无法构建MeshCore MQTT用户名。在Radio面板中导入您的MeshCore配置JSON ,或手动粘贴用户名(v1_)和令牌。" }, "meshcorePacketLogger": { "label": "数据包记录器 — 当连接到无线电和 MQTT 时,将 RX 数据包摘要转发到 {{topic}},其 JSON 形状与 meshcoretomqtt 相同。默认关闭;仅当您打算与数据包分析器共享听到的空中交通时才启用。" @@ -1341,9 +1345,6 @@ "disableShareAria": "关闭共享实例并重新启动堆栈", "disableFailed": "无法关闭共享实例。" }, - "letsMeshRequiresWebSocket": "LetsMesh需要WebSocket传输。", - "letsMeshRequiresPort": "LetsMesh需要端口{{port}}。", - "letsMeshKnownBrokersOnly": "LetsMesh/MeshMapper预设仅支持已知的设备签名代理。对其他经纪商使用自定义。", "letsMeshUsernameV1Hex": "用户名必须为v1_后跟64个十六进制字符(公钥)。", "bleWeakSignalWarning": "蓝牙信号弱( {{rssi}} dBm )。靠近一点,以获得稳定的连接。", "bleRssiDbm": "{{rssi}} dBm", @@ -1352,7 +1353,18 @@ "linkQuality": "连接质量", "linkQualityMs": "{{ms}}毫秒", "linkQualityUnavailable": "—", - "signalUnavailableWebBluetooth": "不可用(网页蓝牙)" + "signalUnavailableWebBluetooth": "不可用(网页蓝牙)", + "meshcoreCaBroker": "MeshCore.CA代理", + "meshcoreCaPrimary": "首先", + "meshcoreCaBackup": "后援", + "broker": "经纪商", + "letsMeshRequiresTls": "此Broker预设需要TLS (wss)。启用TLS或切换到自定义。", + "letsMeshRequiresWsPath": "此Broker预设需要WebSocket路径{{wsPath}}。重置预设或切换到“自定义”。", + "letsMeshRegionUs": "美国", + "letsMeshRegionEu": "EU", + "letsMeshRequiresWebSocket": "此Broker预设需要WebSocket传输(wss)。", + "letsMeshRequiresPort": "此代理预设需要端口{{port}} ( WebSocket TLS )。", + "letsMeshKnownBrokersOnly": "此预设仅支持已知的公共MeshCore MQTT代理。对于其他经纪商,请切换到自定义。" }, "contactGroupsModal": { "failedLoadMembers": "无法加载会员: {{message}}", @@ -5323,7 +5335,8 @@ "actionFailed": "操作失败: {{reason}}", "unknownReason": "未知原因", "resendFailed": "重新发送失败", - "deleteFailed": "删除会话失败。" + "deleteFailed": "删除会话失败。", + "sessionExpired": "此游戏在一段时间不活动后过期,无法再玩。使用删除会话将其删除。" }, "deleteConfirmTitle": "删除会话?", "deleteConfirmMessage": "从您的列表中删除此游戏会话?此操作无法撤消。", @@ -5337,6 +5350,7 @@ "propagated": "已存储", "failed": "需要重试" }, - "drawOfferWaitingBanner": "抽奖活动已发送。正在等待对手……" + "drawOfferWaitingBanner": "抽奖活动已发送。正在等待对手……", + "idleExpiryNotice": "游戏在不活动后过期,然后只能删除。未回答的挑战: 24小时(所有游戏)。活跃游戏: 24小时后抽搐, 7天后下棋和一排四棋。" } } diff --git a/src/renderer/runtime/loraRfReconnectParity.contract.test.ts b/src/renderer/runtime/loraRfReconnectParity.contract.test.ts index b100fb6d4..9ba77c1f1 100644 --- a/src/renderer/runtime/loraRfReconnectParity.contract.test.ts +++ b/src/renderer/runtime/loraRfReconnectParity.contract.test.ts @@ -1,13 +1,19 @@ /** * Shared LoRa reconnect parity: MeshCore and Meshtastic must use createRfReconnectController - * so connection-lost never double-schedules while a cycle is active (n7eal TCP / #792–#796). + * + runLoraRfReconnectAttempt so connection-lost never double-schedules while a cycle is + * active (n7eal TCP / #792–#796 / #807). */ import { describe, expect, it } from 'vitest'; -import { extractUseCallbackBody, loadRuntimeSource } from '../lib/sourceContractTestHelpers'; +import { + extractUseCallbackBody, + loadRendererLibSource, + loadRuntimeSource, +} from '../lib/sourceContractTestHelpers'; const MESHCORE = loadRuntimeSource('useMeshcoreRuntime.ts'); const MESHTASTIC = loadRuntimeSource('useMeshtasticRuntime.ts'); +const ATTEMPT_RUNNER = loadRendererLibSource('loraRfReconnectAttempt.ts'); describe('LoRa RF reconnect parity (MeshCore ↔ Meshtastic)', () => { it.each([ @@ -35,6 +41,7 @@ describe('LoRa RF reconnect parity (MeshCore ↔ Meshtastic)', () => { expect(source).toContain('createRfReconnectController'); expect(source).toContain(controllerRef); expect(source).toContain('shouldStartOwner'); + expect(source).toContain('runLoraRfReconnectAttempt'); const lostBody = extractUseCallbackBody(source, lostName); expect(lostBody).toContain('onLinkLost()'); @@ -45,13 +52,26 @@ describe('LoRa RF reconnect parity (MeshCore ↔ Meshtastic)', () => { ); const reconnectBody = extractUseCallbackBody(source, attemptName); - const finallyBody = reconnectBody.slice(reconnectBody.indexOf('finally {')); - expect(finallyBody).toContain(scheduleRef); - expect(finallyBody).not.toContain(lostRef); - expect(finallyBody).toContain('endAttempt'); + expect(reconnectBody).toContain('runLoraRfReconnectAttempt'); + expect(reconnectBody).toContain('scheduleAttempt:'); + expect(reconnectBody).toContain(scheduleRef); + // Shared finally flush must schedule via scheduleAttempt — never re-enter lost-handler. + expect(ATTEMPT_RUNNER).toContain('endAttempt'); + expect(ATTEMPT_RUNNER).toContain('scheduleAttempt()'); + expect(ATTEMPT_RUNNER).not.toContain(lostRef); }, ); + it('shared attempt runner owns budget + deferred finally flush for both protocols', () => { + expect(ATTEMPT_RUNNER).toContain('raceWithDeadline'); + expect(ATTEMPT_RUNNER).toContain('NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS'); + expect(ATTEMPT_RUNNER).toContain('delayUnlessSuspended'); + expect(ATTEMPT_RUNNER).toContain('createBleReconnectTransportCleanup'); + expect(ATTEMPT_RUNNER).toMatch( + /deferredReconnect\.get\(\) \|\| settled\.shouldSchedule[\s\S]*?scheduleAttempt\(\)/, + ); + }); + it('MeshCore latches session after self-info; UI configured after contacts dump on all RF transports', () => { expect(MESHCORE).toContain('const configureBeforeContactsDump = true'); expect(MESHCORE).toContain('meshcoreTcpContactsDumpInFlightRef'); diff --git a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts index 7dad315f9..037c85e20 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts @@ -7,10 +7,12 @@ import { describe, expect, it } from 'vitest'; import { assertPowerResumeSkipsOnExplicitDisconnect, extractUseCallbackBody, + loadRendererLibSource, loadRuntimeSource, } from '../lib/sourceContractTestHelpers'; const RUNTIME_SOURCE = loadRuntimeSource('useMeshcoreRuntime.ts'); +const ATTEMPT_RUNNER = loadRendererLibSource('loraRfReconnectAttempt.ts'); const CONN_EVENTS_SOURCE = readFileSync( join(__dirname, '../hooks/meshcore/meshcoreConnSideEffects.ts'), 'utf-8', @@ -24,14 +26,18 @@ describe('useMeshcoreRuntime auto-reconnect (regression)', () => { it('implements exponential backoff reconnect with max attempts', () => { expect(RUNTIME_SOURCE).toContain('attemptMeshcoreReconnect'); expect(RUNTIME_SOURCE).toContain('handleMeshcoreConnectionLost'); - expect(RUNTIME_SOURCE).toContain('rfMaxReconnectAttemptsForTransport'); - expect(RUNTIME_SOURCE).toContain('delayUnlessSuspended'); + expect(RUNTIME_SOURCE).toContain('runLoraRfReconnectAttempt'); + expect(ATTEMPT_RUNNER).toContain('rfMaxReconnectAttemptsForTransport'); + expect(ATTEMPT_RUNNER).toContain('delayUnlessSuspended'); }); it('normalizes reconnect UI to disconnected when backoff aborts due to suspend', () => { - expect(RUNTIME_SOURCE).toMatch( - /if \(delayResult === 'suspended'\) \{[\s\S]*?status: 'disconnected'[\s\S]*?connectionLoss: true/, + expect(ATTEMPT_RUNNER).toMatch( + /if \(delayResult === 'suspended'\) \{[\s\S]*?setDisconnectedUi\(\{ connectionLoss: true \}\)/, ); + const reconnectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'attemptMeshcoreReconnect'); + expect(reconnectBody).toContain('setDisconnectedUi'); + expect(reconnectBody).toMatch(/status: 'disconnected'[\s\S]*?connectionLoss: true/); }); it('persists connection params for ble, serial, and tcp reconnect', () => { @@ -115,18 +121,14 @@ describe('useMeshcoreRuntime auto-reconnect (regression)', () => { }); it('bounds every reconnect open+attach with NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS', () => { - // Applies to all transports, not just BLE (see comment at the call site): TCP/serial used - // to await the open+attach attempt with no ceiling at all, so a hang anywhere in that - // sequence (e.g. a disconnect landing mid-attach) wedged reconnection forever — and unlike - // serial, MeshCore TCP has no fallback watchdog either (see meshcoreSerialWatchdog). - expect(RUNTIME_SOURCE).toContain('NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS'); - expect(RUNTIME_SOURCE).toContain('raceWithDeadline'); - const reconnectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'attemptMeshcoreReconnect'); - expect(reconnectBody).toContain('raceWithDeadline'); - expect(reconnectBody).toContain('Reconnect attempt timed out after'); - expect(reconnectBody).toContain('attemptActive'); - expect(reconnectBody).toContain('meshcoreReconnectConnectInFlightRef.current = true'); - expect(reconnectBody).not.toContain('if (isBleReconnect) {\n await raceWithDeadline'); + // Shared runner applies the budget to every transport (constant name is historical). + expect(ATTEMPT_RUNNER).toContain('NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS'); + expect(ATTEMPT_RUNNER).toContain('raceWithDeadline'); + expect(ATTEMPT_RUNNER).toContain('Reconnect attempt timed out after'); + expect(ATTEMPT_RUNNER).toContain('attemptActive'); + expect(ATTEMPT_RUNNER).toContain('connectInFlight.set(true)'); + expect(RUNTIME_SOURCE).toContain('runLoraRfReconnectAttempt'); + expect(RUNTIME_SOURCE).toContain('meshcoreReconnectConnectInFlightRef'); }); it('on reconnect timeout invalidates setup generation and cleans late transports (any transport, CodeRabbit #792)', () => { @@ -136,7 +138,7 @@ describe('useMeshcoreRuntime auto-reconnect (regression)', () => { // BLE-only; now that every transport's reconnect races the same deadline, a timed-out // TCP/serial attempt must invalidate the setup generation too, or its background RPCs keep // running and can apply stale state after the attempt was already declared failed. - expect(RUNTIME_SOURCE).toContain('createBleReconnectTransportCleanup'); + expect(ATTEMPT_RUNNER).toContain('createBleReconnectTransportCleanup'); const reconnectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'attemptMeshcoreReconnect'); expect(reconnectBody).toMatch( /lateTransport\.cleanup\(opened\.driverIdentityId\);\s*throw new Error\('MeshCore reconnect superseded after open'\)/, @@ -144,12 +146,8 @@ describe('useMeshcoreRuntime auto-reconnect (regression)', () => { expect(reconnectBody).toMatch( /lateTransport\.cleanup\(opened\.driverIdentityId\);\s*throw new Error\('MeshCore reconnect superseded during attach'\)/, ); - const catchBody = reconnectBody.slice( - reconnectBody.indexOf('} catch (err) {'), - reconnectBody.indexOf('await lateTransport.cleanup(opened?.driverIdentityId)'), - ); - expect(catchBody).toContain('meshcoreSetupGenerationRef.current += 1'); - expect(catchBody).not.toContain('if (isBleReconnect)'); + expect(reconnectBody).toContain('meshcoreSetupGenerationRef.current += 1'); + expect(reconnectBody).toContain('onAttemptError:'); }); it('cleans up transport when RF link is lost after reconnect attach', () => { @@ -207,12 +205,13 @@ describe('useMeshcoreRuntime auto-reconnect (regression)', () => { }); it('flushes deferred reconnects after reconnect attempts settle', () => { + expect(ATTEMPT_RUNNER).toContain('connectInFlight.set(false)'); + expect(ATTEMPT_RUNNER).toContain('deferredReconnect.get()'); + expect(ATTEMPT_RUNNER).toContain('scheduleAttempt()'); + expect(ATTEMPT_RUNNER).not.toContain('handleMeshcoreConnectionLostRef.current()'); const reconnectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'attemptMeshcoreReconnect'); - const finallyBody = reconnectBody.slice(reconnectBody.indexOf('finally {')); - expect(finallyBody).toContain('meshcoreReconnectConnectInFlightRef.current = false'); - expect(finallyBody).toContain('if (meshcoreDeferredReconnectRef.current)'); - expect(finallyBody).toContain('scheduleMeshcoreReconnectAttemptRef.current()'); - expect(finallyBody).not.toContain('handleMeshcoreConnectionLostRef.current()'); + expect(reconnectBody).toContain('scheduleMeshcoreReconnectAttemptRef.current()'); + expect(reconnectBody).toContain('connectInFlight:'); }); it('reconnects when stored session exists before everConfigured (HMR/stale runtime)', () => { @@ -251,18 +250,17 @@ describe('useMeshcoreRuntime auto-reconnect (regression)', () => { }); it('clears bleConnectInProgressRef after auto-reconnect attempts', () => { + expect(ATTEMPT_RUNNER).toContain('bleConnectInProgress?.set(false)'); const reconnectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'attemptMeshcoreReconnect'); - const finallyBody = reconnectBody.slice(reconnectBody.indexOf('finally {')); - expect(finallyBody).toContain('bleConnectInProgressRef.current = false'); - expect(RUNTIME_SOURCE).toMatch( - /meshcoreReconnectAttemptRef\.current >= maxReconnectAttempts[\s\S]{0,800}bleConnectInProgressRef\.current = false/, + expect(reconnectBody).toContain('bleConnectInProgress:'); + expect(reconnectBody).toMatch( + /onExhausted:[\s\S]*?rfType === 'ble'[\s\S]*?bleConnectInProgressRef\.current = false/, ); }); it('escalates serial reconnect exhaustion with forget and re-select UI flag', () => { - expect(RUNTIME_SOURCE).toMatch( - /meshcoreReconnectAttemptRef\.current >= maxReconnectAttempts[\s\S]{0,900}escalateSerialReconnectExhaustion/, - ); + expect(ATTEMPT_RUNNER).toContain('markExhausted()'); + expect(RUNTIME_SOURCE).toContain('escalateSerialReconnectExhaustion'); expect(RUNTIME_SOURCE).toContain('serialNeedsReselect'); expect(RUNTIME_SOURCE).toContain('attachMeshcoreSerialTransportLossWatch'); expect(RUNTIME_SOURCE).toContain('startMeshcoreSerialWatchdog'); @@ -476,8 +474,8 @@ describe('useMeshcoreRuntime manual disconnect must not auto-reconnect', () => { }); it('attemptMeshcoreReconnect marks controller exhausted and re-enters via onLinkLost after serial rediscovery', () => { + expect(ATTEMPT_RUNNER).toContain('markExhausted()'); const reconnectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'attemptMeshcoreReconnect'); - expect(reconnectBody).toContain('markExhausted()'); expect(reconnectBody).toMatch( /startSerialRediscovery\(\{[\s\S]*?onFound:[\s\S]*?onLinkLost\(\)[\s\S]*?scheduleMeshcoreReconnectAttemptRef\.current\(\)/, ); @@ -498,17 +496,17 @@ describe('useMeshcoreRuntime manual disconnect must not auto-reconnect', () => { }); it('attemptMeshcoreReconnect returns when connection params are cleared', () => { + expect(ATTEMPT_RUNNER).toMatch(/if \(!params\) \{[\s\S]*?isReconnecting\.set\(false\)/); const reconnectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'attemptMeshcoreReconnect'); - expect(reconnectBody).toMatch( - /if \(!params\) \{[\s\S]*?meshcoreIsReconnectingRef\.current = false/, - ); + expect(reconnectBody).toContain('isReconnecting:'); + expect(reconnectBody).toContain('getParams:'); }); it('attemptMeshcoreReconnect returns early on explicit user disconnect', () => { + expect(ATTEMPT_RUNNER).toMatch(/isExplicitDisconnect\(\)[\s\S]*?isReconnecting\.set\(false\)/); const reconnectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'attemptMeshcoreReconnect'); - expect(reconnectBody).toMatch( - /if \(meshcoreExplicitDisconnectRef\.current\) \{[\s\S]*?meshcoreIsReconnectingRef\.current = false/, - ); + expect(reconnectBody).toContain('isExplicitDisconnect:'); + expect(reconnectBody).toContain('meshcoreExplicitDisconnectRef.current'); }); it('attemptMeshcoreReconnect treats setup AbortError as superseded reconnect', () => { @@ -525,36 +523,35 @@ describe('useMeshcoreRuntime manual disconnect must not auto-reconnect', () => { expect(abortIdx).toBeGreaterThan(-1); const abortBlock = reconnectBody.slice(abortIdx, abortIdx + 900); expect(abortBlock).toContain('meshcoreDeferredReconnectRef.current = true'); + expect(abortBlock).toContain("return 'defer'"); expect(abortBlock).not.toMatch(/meshcoreIsReconnectingRef\.current = false/); }); it('attemptMeshcoreReconnect clears stuck reconnecting UI when delay aborts', () => { - const reconnectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'attemptMeshcoreReconnect'); - expect(reconnectBody).toMatch( - /delayResult === 'aborted'[\s\S]*?!meshcoreIsReconnectingRef\.current[\s\S]*?status: 'disconnected'/, + expect(ATTEMPT_RUNNER).toMatch( + /delayResult === 'aborted'[\s\S]*?!deps\.isReconnecting\.get\(\)[\s\S]*?setDisconnectedUi/, ); + const reconnectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'attemptMeshcoreReconnect'); + expect(reconnectBody).toMatch(/status: 'disconnected'[\s\S]*?connectionLoss: true/); }); it('attemptMeshcoreReconnect clears stuck UI when generation mismatches after delay', () => { - const reconnectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'attemptMeshcoreReconnect'); - // Post-delay guard: generation bump or cleared isReconnecting must not leave status=reconnecting. - expect(reconnectBody).toMatch( - /meshcoreReconnectGenerationRef\.current !== generation[\s\S]*?!meshcoreIsReconnectingRef\.current[\s\S]*?status: 'disconnected'/, + expect(ATTEMPT_RUNNER).toMatch( + /generation\.get\(\) !== generation[\s\S]*?!deps\.isReconnecting\.get\(\)[\s\S]*?setDisconnectedUi/, ); + const reconnectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'attemptMeshcoreReconnect'); + expect(reconnectBody).toContain('generation:'); + expect(reconnectBody).toContain('setDisconnectedUi'); }); it('attemptMeshcoreReconnect finally flushes deferred restart via coalesced schedule', () => { - const reconnectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'attemptMeshcoreReconnect'); - const abortIdx = reconnectBody.indexOf('isMeshcoreSetupAbortError(err)'); - expect(abortIdx).toBeGreaterThan(-1); - const afterAbort = reconnectBody.slice(abortIdx); - expect(afterAbort).toMatch( - /finally[\s\S]*?meshcoreDeferredReconnectRef\.current[\s\S]*?scheduleMeshcoreReconnectAttemptRef\.current\(\)/, - ); - // Must not re-enter handleMeshcoreConnectionLost (double generation bump / dual backoff loops). - expect(afterAbort).not.toMatch( - /finally[\s\S]*?meshcoreDeferredReconnectRef\.current[\s\S]*?handleMeshcoreConnectionLostRef\.current\(\)/, + expect(ATTEMPT_RUNNER).toMatch( + /deferredReconnect\.get\(\) \|\| settled\.shouldSchedule[\s\S]*?scheduleAttempt\(\)/, ); + expect(ATTEMPT_RUNNER).not.toContain('handleMeshcoreConnectionLostRef.current()'); + const reconnectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'attemptMeshcoreReconnect'); + expect(reconnectBody).toContain('scheduleMeshcoreReconnectAttemptRef.current()'); + expect(reconnectBody).toContain('isMeshcoreSetupAbortError(err)'); }); it('handleMeshcoreConnectionLost defers when cycle already active (single-owner controller)', () => { @@ -607,10 +604,12 @@ describe('useMeshcoreRuntime manual disconnect must not auto-reconnect', () => { }); it('attemptMeshcoreReconnect delay abort flushes deferred restart', () => { - const reconnectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'attemptMeshcoreReconnect'); - expect(reconnectBody).toMatch( - /delayResult === 'aborted'[\s\S]*?meshcoreDeferredReconnectRef\.current[\s\S]*?scheduleMeshcoreReconnectAttemptRef\.current\(\)/, + expect(ATTEMPT_RUNNER).toMatch( + /delayResult === 'aborted'[\s\S]*?deferredReconnect\.get\(\)[\s\S]*?scheduleAttempt\(\)/, ); + const reconnectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'attemptMeshcoreReconnect'); + expect(reconnectBody).toContain('deferredReconnect:'); + expect(reconnectBody).toContain('scheduleMeshcoreReconnectAttemptRef.current()'); }); it('observes parallel init setup AbortErrors so sibling cancels are not unhandled', () => { diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index 25caaf78b..d7d126f2f 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -90,8 +90,7 @@ import { MESHCORE_SETUP_ABORT_MESSAGE, rethrowMeshcoreSetupAbortFromTcpDead, } from '../lib/bleConnectErrors'; -import { raceWithDeadline, verifyNobleBleRfLink } from '../lib/bleReconnectHelper'; -import { createBleReconnectTransportCleanup } from '../lib/bleReconnectLateTransport'; +import { verifyNobleBleRfLink } from '../lib/bleReconnectHelper'; import { MAX_IN_MEMORY_CHAT_MESSAGES, trimChatMessagesToMax } from '../lib/chatInMemoryBuffer'; import { setMeshcoreDiagnosticsNodes } from '../lib/diagnosticsNodesRef'; import { connectionDriver } from '../lib/drivers/ConnectionDriver'; @@ -126,6 +125,7 @@ import { meshcoreIdentityHasFullKeyPair, tryPersistMeshcorePublicKeyFromRadio, } from '../lib/letsMeshJwt'; +import { runLoraRfReconnectAttempt } from '../lib/loraRfReconnectAttempt'; import { assignCayenneTemperatureFields } from '../lib/meshcore/meshcoreCayenneTemperature'; import { ensureMeshcoreChatSenderInNodeStore } from '../lib/meshcore/meshcoreChatSenderNode'; import { takeMeshcoreDiscoverSelfCache } from '../lib/meshcore/meshcoreDiscoverSelfCache'; @@ -405,7 +405,6 @@ import { } from '../lib/repeaterCommandService'; import { createRepeaterRemoteRpcQueue } from '../lib/repeaterRemoteRpcQueue'; import { createRfReconnectController } from '../lib/rfReconnectController'; -import { rfMaxReconnectAttemptsForTransport } from '../lib/rfReconnectShared'; import { registerMeshcoreSerialDisconnectTarget } from '../lib/serialDisconnectRouter'; import { captureSerialIdentityForRediscovery, @@ -422,7 +421,6 @@ import { LAST_SERIAL_PORT_KEY, persistSerialPortIdentity } from '../lib/serialPo import { registerMeshcoreSession } from '../lib/sessions/meshcoreSession'; import { getStoredMeshProtocol } from '../lib/storedMeshProtocol'; import { messageRecordsToChatMessages, nodeRecordsToMeshNodeMap } from '../lib/storeRecordAdapters'; -import { delayUnlessSuspended } from '../lib/systemPowerState'; import { computeRoomPostTotalTimeoutMs, MESHCORE_MAX_RECONNECT_DELAY_MS, @@ -438,7 +436,6 @@ import { MESHCORE_WAITING_MESSAGES_AFTER_TX_DEFER_MS, MESHCORE_WAITING_MESSAGES_DRAIN_DEBOUNCE_MS, MESHCORE_WAITING_MESSAGES_POLL_MS, - NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS, POWER_RESUME_MESHCORE_MESHTASTIC_SETTLE_MS, RF_SERIAL_OPEN_RETRY_DELAY_MS, } from '../lib/timeConstants'; @@ -3273,331 +3270,235 @@ export function useMeshcoreRuntime() { ); const attemptMeshcoreReconnect = useCallback(async () => { - const params = meshcoreConnectionParamsRef.current; - if (!params) { - meshcoreIsReconnectingRef.current = false; - return; - } - if (meshcoreExplicitDisconnectRef.current) { - meshcoreIsReconnectingRef.current = false; - return; - } - - const maxReconnectAttempts = rfMaxReconnectAttemptsForTransport(params.rfType); - if (meshcoreReconnectAttemptRef.current >= maxReconnectAttempts) { - meshcoreIsReconnectingRef.current = false; - meshcoreReconnectAttemptRef.current = 0; - meshcoreRfReconnectRef.current.markExhausted(); - if (params.rfType === 'tcp') { - rejectMeshcoreTcpLiveForUserTx(new Error('MeshCore TCP reconnect exhausted')); - } - if (params.rfType === 'ble') { - bleConnectInProgressRef.current = false; - } - stopMeshcoreSerialWatchdog(); - if (params.rfType === 'serial') { - const exhaustedSerialPort = params.serialPort ?? null; - const captured = captureSerialIdentityForRediscovery(exhaustedSerialPort); - await escalateSerialReconnectExhaustion(exhaustedSerialPort, { forgetPort: false }); - serialRediscoveryStopRef.current?.(); - serialRediscoveryStopRef.current = startSerialRediscovery({ - signature: captured.signature, - portId: captured.portId, - onFound: (port) => { - persistSerialPortIdentity(port); - if (meshcoreConnectionParamsRef.current?.rfType === 'serial') { - meshcoreConnectionParamsRef.current.serialPort = port; - } - setState((s) => ({ - ...s, - serialNeedsReselect: false, - connectionLoss: true, - status: 'reconnecting', - })); - meshcoreIsReconnectingRef.current = true; - meshcoreReconnectAttemptRef.current = 0; - // Re-enter through the controller after markExhausted (idle → owner). - const linkLost = meshcoreRfReconnectRef.current.onLinkLost(); - meshcoreReconnectGenerationRef.current = linkLost.generation; - if (linkLost.shouldStartOwner) { - scheduleMeshcoreReconnectAttemptRef.current(); - } else { - meshcoreDeferredReconnectRef.current = true; - } - }, - onTimeout: () => { - void forgetGrantedSerialPortBestEffort(exhaustedSerialPort); - }, - }); - } - setState((s) => ({ - ...s, - status: 'disconnected', - connectionType: params.rfType === 'serial' ? 'serial' : null, - connectionLoss: true, - serialNeedsReselect: params.rfType === 'serial', - })); - return; - } - - const generation = meshcoreReconnectGenerationRef.current; - meshcoreReconnectAttemptRef.current += 1; - meshcoreRfReconnectRef.current.beginAttempt(meshcoreReconnectAttemptRef.current); - setState((s) => ({ - ...s, - status: 'reconnecting', - connectionLoss: true, - reconnectAttempt: meshcoreReconnectAttemptRef.current, - })); - - const delay = Math.min( - 2000 * Math.pow(2, meshcoreReconnectAttemptRef.current - 1), - MESHCORE_MAX_RECONNECT_DELAY_MS, - ); - console.debug( - `[useMeshcoreRuntime] reconnect: waiting ${delay}ms before attempt ${meshcoreReconnectAttemptRef.current}/${maxReconnectAttempts}`, - ); - const delayResult = await delayUnlessSuspended(delay, () => - !meshcoreIsReconnectingRef.current - ? true - : meshcoreReconnectGenerationRef.current !== generation, - ); - if (delayResult === 'aborted') { - // Generation bump during backoff (or cancel) aborted this delay. If a deferred restart was - // requested, the owning cycle must schedule the next attempt — do not leave isReconnecting - // true with no further work (n7eal TCP / #792). - if ( - meshcoreDeferredReconnectRef.current && - meshcoreIsReconnectingRef.current && - !meshcoreExplicitDisconnectRef.current - ) { - meshcoreDeferredReconnectRef.current = false; - meshcoreRfReconnectRef.current.endAttempt({ keepReconnecting: true }); + let openedDriverIdentityId: string | undefined; + await runLoraRfReconnectAttempt({ + logTag: 'useMeshcoreRuntime', + controller: meshcoreRfReconnectRef.current, + getParams: () => meshcoreConnectionParamsRef.current, + getTransportType: (p) => p.rfType, + isBle: (p) => p.rfType === 'ble', + isExplicitDisconnect: () => meshcoreExplicitDisconnectRef.current, + isReconnecting: { + get: () => meshcoreIsReconnectingRef.current, + set: (v) => { + meshcoreIsReconnectingRef.current = v; + }, + }, + generation: { + get: () => meshcoreReconnectGenerationRef.current, + set: (v) => { + meshcoreReconnectGenerationRef.current = v; + }, + }, + attemptCounter: { + get: () => meshcoreReconnectAttemptRef.current, + set: (v) => { + meshcoreReconnectAttemptRef.current = v; + }, + }, + deferredReconnect: { + get: () => meshcoreDeferredReconnectRef.current, + set: (v) => { + meshcoreDeferredReconnectRef.current = v; + }, + }, + connectInFlight: { + get: () => meshcoreReconnectConnectInFlightRef.current, + set: (v) => { + meshcoreReconnectConnectInFlightRef.current = v; + }, + }, + bleConnectInProgress: { + get: () => bleConnectInProgressRef.current, + set: (v) => { + bleConnectInProgressRef.current = v; + }, + }, + scheduleAttempt: () => { scheduleMeshcoreReconnectAttemptRef.current(); - return; - } - meshcoreRfReconnectRef.current.endAttempt({ - keepReconnecting: meshcoreIsReconnectingRef.current, - }); - // Another cycle may still own reconnect (generation bumped while isReconnecting stayed - // true). Only clear the UI when this cycle was cancelled and nothing else is driving it — - // otherwise a raced setup-abort / delay abort left status=reconnecting forever (#792). - if (!meshcoreIsReconnectingRef.current) { + }, + setReconnectingUi: (attempt) => { + setState((s) => ({ + ...s, + status: 'reconnecting', + connectionLoss: true, + reconnectAttempt: attempt, + })); + }, + setDisconnectedUi: () => { setState((s) => ({ ...s, status: 'disconnected', connectionLoss: true, })); - } - return; - } - if (delayResult === 'suspended') { - meshcoreIsReconnectingRef.current = false; - meshcoreRfReconnectRef.current.cancel(); - setState((s) => ({ - ...s, - status: 'disconnected', - connectionLoss: true, - })); - return; - } - if ( - !meshcoreIsReconnectingRef.current || - meshcoreReconnectGenerationRef.current !== generation - ) { - if ( - meshcoreDeferredReconnectRef.current && - meshcoreIsReconnectingRef.current && - !meshcoreExplicitDisconnectRef.current - ) { - meshcoreDeferredReconnectRef.current = false; - meshcoreRfReconnectRef.current.endAttempt({ keepReconnecting: true }); - scheduleMeshcoreReconnectAttemptRef.current(); - return; - } - meshcoreRfReconnectRef.current.endAttempt({ - keepReconnecting: meshcoreIsReconnectingRef.current, - }); - if (!meshcoreIsReconnectingRef.current) { + }, + maxDelayMs: MESHCORE_MAX_RECONNECT_DELAY_MS, + overlapCheck: 'afterOpening', + disconnectIdentity: (identityId) => connectionDriver.disconnect(identityId), + onExhausted: async (params) => { + if (params.rfType === 'tcp') { + rejectMeshcoreTcpLiveForUserTx(new Error('MeshCore TCP reconnect exhausted')); + } + if (params.rfType === 'ble') { + bleConnectInProgressRef.current = false; + } + stopMeshcoreSerialWatchdog(); + if (params.rfType === 'serial') { + const exhaustedSerialPort = params.serialPort ?? null; + const captured = captureSerialIdentityForRediscovery(exhaustedSerialPort); + await escalateSerialReconnectExhaustion(exhaustedSerialPort, { forgetPort: false }); + serialRediscoveryStopRef.current?.(); + serialRediscoveryStopRef.current = startSerialRediscovery({ + signature: captured.signature, + portId: captured.portId, + onFound: (port) => { + persistSerialPortIdentity(port); + if (meshcoreConnectionParamsRef.current?.rfType === 'serial') { + meshcoreConnectionParamsRef.current.serialPort = port; + } + setState((s) => ({ + ...s, + serialNeedsReselect: false, + connectionLoss: true, + status: 'reconnecting', + })); + meshcoreIsReconnectingRef.current = true; + meshcoreReconnectAttemptRef.current = 0; + // Re-enter through the controller after markExhausted (idle → owner). + const linkLost = meshcoreRfReconnectRef.current.onLinkLost(); + meshcoreReconnectGenerationRef.current = linkLost.generation; + if (linkLost.shouldStartOwner) { + scheduleMeshcoreReconnectAttemptRef.current(); + } else { + meshcoreDeferredReconnectRef.current = true; + } + }, + onTimeout: () => { + void forgetGrantedSerialPortBestEffort(exhaustedSerialPort); + }, + }); + } setState((s) => ({ ...s, status: 'disconnected', + connectionType: params.rfType === 'serial' ? 'serial' : null, connectionLoss: true, + serialNeedsReselect: params.rfType === 'serial', })); - } - return; - } - - meshcoreRfReconnectRef.current.beginOpening(); - let opened: Awaited> | undefined; - const isBleReconnect = params.rfType === 'ble'; - if (meshcoreReconnectConnectInFlightRef.current) { - console.debug( - '[useMeshcoreRuntime] reconnect: skip overlapping open (connect already in flight)', - ); - meshcoreDeferredReconnectRef.current = true; - meshcoreRfReconnectRef.current.markDirty(); - return; - } - meshcoreReconnectConnectInFlightRef.current = true; - if (isBleReconnect) bleConnectInProgressRef.current = true; - let attemptActive = true; - const lateTransport = createBleReconnectTransportCleanup( - (identityId) => connectionDriver.disconnect(identityId), - 'useMeshcoreRuntime', - ); - const runReconnectAttempt = async () => { - await prepareRfConnect(params.rfType, { preserveReconnectState: true }); - if (meshcoreReconnectGenerationRef.current !== generation || !attemptActive) { - throw new Error('MeshCore reconnect superseded before open'); - } - opened = - isBleReconnect && isRendererNobleBlePlatform() - ? await withNobleBleConnectMutex('meshcore', () => - openMeshCoreTransport(params.rfType, { + }, + runOpenAndAttach: async (ctx, params) => { + const { generation, isBle: isBleReconnect, attemptActive, lateTransport } = ctx; + await prepareRfConnect(params.rfType, { preserveReconnectState: true }); + if (meshcoreReconnectGenerationRef.current !== generation || !attemptActive()) { + throw new Error('MeshCore reconnect superseded before open'); + } + const opened = + isBleReconnect && isRendererNobleBlePlatform() + ? await withNobleBleConnectMutex('meshcore', () => + openMeshCoreTransport(params.rfType, { + blePeripheralId: params.blePeripheralId, + host: params.rfType === 'tcp' ? (params.httpAddress ?? 'localhost') : undefined, + portSignature: + params.rfType === 'serial' ? (params.serialPortId ?? undefined) : undefined, + }), + ) + : await openMeshCoreTransport(params.rfType, { blePeripheralId: params.blePeripheralId, host: params.rfType === 'tcp' ? (params.httpAddress ?? 'localhost') : undefined, portSignature: params.rfType === 'serial' ? (params.serialPortId ?? undefined) : undefined, - }), - ) - : await openMeshCoreTransport(params.rfType, { - blePeripheralId: params.blePeripheralId, - host: params.rfType === 'tcp' ? (params.httpAddress ?? 'localhost') : undefined, - portSignature: - params.rfType === 'serial' ? (params.serialPortId ?? undefined) : undefined, - }); - if (meshcoreReconnectGenerationRef.current !== generation || !attemptActive) { - await lateTransport.cleanup(opened.driverIdentityId); - throw new Error('MeshCore reconnect superseded after open'); - } - meshcorePendingDriverIdentityRef.current = opened.driverIdentityId; - await attachRfSession(opened.driverIdentityId, params.rfType); - if (meshcoreReconnectGenerationRef.current !== generation || !attemptActive) { - await lateTransport.cleanup(opened.driverIdentityId); - throw new Error('MeshCore reconnect superseded during attach'); - } - if (!(await verifyNobleBleRfLink(params.rfType, 'meshcore'))) { - await lateTransport.cleanup(opened.driverIdentityId); - throw new Error('RF link lost after MeshCore reconnect attach'); - } - if (!attemptActive || meshcoreReconnectGenerationRef.current !== generation) { - await lateTransport.cleanup(opened.driverIdentityId); - throw new Error('MeshCore reconnect superseded after attach'); - } - console.debug( - `[useMeshcoreRuntime] Reconnect succeeded on attempt ${meshcoreReconnectAttemptRef.current}`, - ); - if (params.rfType === 'ble') { - const bleIdentityOpts = { - blePeripheralId: params.blePeripheralId, - webBluetoothDeviceId: readMeshcoreWebBluetoothDeviceId(opened.conn), - fallbackLastBlePeripheralId: resolveLastBlePeripheralId('meshcore') ?? null, - }; - const bleId = resolveConnectedMeshcoreBleIdentity(bleIdentityOpts); - if (bleId) { - params.blePeripheralId = bleId; + }); + openedDriverIdentityId = opened.driverIdentityId; + if (meshcoreReconnectGenerationRef.current !== generation || !attemptActive()) { + await lateTransport.cleanup(opened.driverIdentityId); + throw new Error('MeshCore reconnect superseded after open'); + } + meshcorePendingDriverIdentityRef.current = opened.driverIdentityId; + await attachRfSession(opened.driverIdentityId, params.rfType); + if (meshcoreReconnectGenerationRef.current !== generation || !attemptActive()) { + await lateTransport.cleanup(opened.driverIdentityId); + throw new Error('MeshCore reconnect superseded during attach'); + } + if (!(await verifyNobleBleRfLink(params.rfType, 'meshcore'))) { + await lateTransport.cleanup(opened.driverIdentityId); + throw new Error('RF link lost after MeshCore reconnect attach'); + } + if (!attemptActive() || meshcoreReconnectGenerationRef.current !== generation) { + await lateTransport.cleanup(opened.driverIdentityId); + throw new Error('MeshCore reconnect superseded after attach'); } - commitConnectedMeshcoreBleSuppression(bleIdentityOpts); - } else { - clearMeshcoreBleMacSuppression(); - } - // Burst-complete attach left a dead bridge (OpenHop FIN after contacts). UI is configured - // from the contacts burst — accept that session. Forcing an immediate live-socket retry - // loops forever on companions that FIN after every contacts dump (WAN :5054 / OpenHop). - // OpenHop-accepted: keep configured; background write-dead must not reconnect-loop. - if (params.rfType === 'tcp' && meshcoreDeferredReconnectRef.current) { - meshcoreDeferredReconnectRef.current = false; - setMeshcoreTcpOpenHopDeadAccepted(true); console.debug( - '[useMeshcoreRuntime] TCP burst-complete reconnect attach — accepting dead bridge (configured)', + `[useMeshcoreRuntime] Reconnect succeeded on attempt ${meshcoreReconnectAttemptRef.current}`, ); - } - meshcoreReconnectAttemptRef.current = 0; - meshcoreIsReconnectingRef.current = false; - meshcoreDeferredReconnectRef.current = false; - meshcoreRfReconnectRef.current.markSuccess(); - setState((s) => ({ - ...s, - serialNeedsReselect: false, - connectionLoss: false, - })); - // OpenHop dead bridge: outbox drain would tcp-write-fail → reconnect thrash. - if (!(params.rfType === 'tcp' && meshcoreTcpBridgeDeadRef.current)) { - requestChatOutboxDrain('meshcore'); - } - }; - const reconnectWork = runReconnectAttempt(); - void reconnectWork.catch((e: unknown) => { - console.debug('[useMeshcoreRuntime] reconnectWork late reject ' + errLikeToLogString(e)); - }); - try { - // Applied to every transport, not just BLE (constant name is historical): TCP/serial - // reconnects used to `await reconnectWork` with no ceiling at all. A disconnect that - // lands while an open+attach is still in flight defers to that attempt settling; without - // a deadline here, a hang anywhere in openMeshCoreTransport/attachRfSession wedges the - // whole reconnect state machine forever instead of just failing this attempt and retrying. - await raceWithDeadline( - reconnectWork, - NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS, - `Reconnect attempt timed out after ${NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS}ms`, - ); - } catch (err) { - attemptActive = false; - // Stop background initConn RPCs (getSelfInfo/getContacts/getChannels/etc.) if open - // resolved into attach after the budget fired. Not BLE-specific: raceWithDeadline now - // guards every transport's reconnect attempt, so a TCP/serial attempt can hit this same - // path — bumping only for BLE here left non-BLE stale setup RPCs free to keep running - // and apply state after the attempt was already declared failed. - meshcoreSetupGenerationRef.current += 1; - if (isMeshcoreSetupAbortError(err)) { - // Setup abort means connection-lost (or a newer connect) bumped setup generation while - // this attempt's initConn was still running. Do NOT clear isReconnecting — that flag is - // what finally's deferred restart and delayUnlessSuspended use to keep the cycle alive. - // Clearing it here left status=reconnecting with no further attempts (n7eal TCP #792). - console.debug('[useMeshcoreRuntime] reconnect aborted (setup superseded)'); - if (meshcoreIsReconnectingRef.current) { - meshcoreDeferredReconnectRef.current = true; + if (params.rfType === 'ble') { + const bleIdentityOpts = { + blePeripheralId: params.blePeripheralId, + webBluetoothDeviceId: readMeshcoreWebBluetoothDeviceId(opened.conn), + fallbackLastBlePeripheralId: resolveLastBlePeripheralId('meshcore') ?? null, + }; + const bleId = resolveConnectedMeshcoreBleIdentity(bleIdentityOpts); + if (bleId) { + params.blePeripheralId = bleId; + } + commitConnectedMeshcoreBleSuppression(bleIdentityOpts); + } else { + clearMeshcoreBleMacSuppression(); } - return; - } - await lateTransport.cleanup(opened?.driverIdentityId); - console.warn( - `[useMeshcoreRuntime] Reconnect attempt ${meshcoreReconnectAttemptRef.current} failed: ` + - errLikeToLogString(err), - ); - // Retry only if this generation is still current. Deferred Noble drops are flushed in finally. - if ( - !meshcoreDeferredReconnectRef.current && - meshcoreIsReconnectingRef.current && - meshcoreReconnectGenerationRef.current === generation - ) { - scheduleMeshcoreReconnectAttemptRef.current(); - } - } finally { - attemptActive = false; - meshcoreReconnectConnectInFlightRef.current = false; - if (isBleReconnect) { - bleConnectInProgressRef.current = false; - } - if (meshcoreDeferredReconnectRef.current) { - meshcoreRfReconnectRef.current.markDirty(); - } - const settled = meshcoreRfReconnectRef.current.endAttempt({ - keepReconnecting: meshcoreIsReconnectingRef.current, - }); - if (meshcoreDeferredReconnectRef.current || settled.shouldSchedule) { - meshcoreDeferredReconnectRef.current = false; - if (meshcoreIsReconnectingRef.current) { + // Burst-complete attach left a dead bridge (OpenHop FIN after contacts). UI is configured + // from the contacts burst — accept that session. Forcing an immediate live-socket retry + // loops forever on companions that FIN after every contacts dump (WAN :5054 / OpenHop). + // OpenHop-accepted: keep configured; background write-dead must not reconnect-loop. + if (params.rfType === 'tcp' && meshcoreDeferredReconnectRef.current) { + meshcoreDeferredReconnectRef.current = false; + setMeshcoreTcpOpenHopDeadAccepted(true); console.debug( - '[useMeshcoreRuntime] reconnect settled — running deferred reconnect after transport drop', + '[useMeshcoreRuntime] TCP burst-complete reconnect attach — accepting dead bridge (configured)', ); - // Call attempt directly (coalesced) — nested handleMeshcoreConnectionLost re-bumped - // generation and raced a second backoff loop with this flush (n7eal TCP / #792). - scheduleMeshcoreReconnectAttemptRef.current(); } - } - } + meshcoreReconnectAttemptRef.current = 0; + meshcoreIsReconnectingRef.current = false; + meshcoreDeferredReconnectRef.current = false; + meshcoreRfReconnectRef.current.markSuccess(); + setState((s) => ({ + ...s, + serialNeedsReselect: false, + connectionLoss: false, + })); + // OpenHop dead bridge: outbox drain would tcp-write-fail → reconnect thrash. + if (!(params.rfType === 'tcp' && meshcoreTcpBridgeDeadRef.current)) { + requestChatOutboxDrain('meshcore'); + } + }, + onAttemptError: async (err, { lateTransport }) => { + // Stop background initConn RPCs (getSelfInfo/getContacts/getChannels/etc.) if open + // resolved into attach after the budget fired. Not BLE-specific: raceWithDeadline now + // guards every transport's reconnect attempt, so a TCP/serial attempt can hit this same + // path — bumping only for BLE here left non-BLE stale setup RPCs free to keep running + // and apply state after the attempt was already declared failed. + meshcoreSetupGenerationRef.current += 1; + if (isMeshcoreSetupAbortError(err)) { + // Setup abort means connection-lost (or a newer connect) bumped setup generation while + // this attempt's initConn was still running. Do NOT clear isReconnecting — that flag is + // what finally's deferred restart and delayUnlessSuspended use to keep the cycle alive. + // Clearing it here left status=reconnecting with no further attempts (n7eal TCP #792). + console.debug('[useMeshcoreRuntime] reconnect aborted (setup superseded)'); + // Clean up any transport this (now-doomed) attempt opened before deferring, otherwise a + // late-opened driver leaks while the deferred restart brings up a fresh one. + await lateTransport.cleanup(openedDriverIdentityId); + if (meshcoreIsReconnectingRef.current) { + meshcoreDeferredReconnectRef.current = true; + } + return 'defer'; + } + await lateTransport.cleanup(openedDriverIdentityId); + console.warn( + `[useMeshcoreRuntime] Reconnect attempt ${meshcoreReconnectAttemptRef.current} failed: ` + + errLikeToLogString(err), + ); + // Retry only if this generation is still current. Deferred Noble drops are flushed in finally. + return 'retry'; + }, + }); }, [attachRfSession, prepareRfConnect, stopMeshcoreSerialWatchdog]); useLayoutEffect(() => { diff --git a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts index cc5f65c53..ac66bc47f 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts @@ -14,24 +14,29 @@ import { describe, expect, it } from 'vitest'; import { assertPowerResumeSkipsOnExplicitDisconnect, - extractIfBlockBody, extractUseCallbackBody, + loadRendererLibSource, loadRuntimeSource, } from '../lib/sourceContractTestHelpers'; const SOURCE = loadRuntimeSource('useMeshtasticRuntime.ts'); +const ATTEMPT_RUNNER = loadRendererLibSource('loraRfReconnectAttempt.ts'); const TEST_DIR = import.meta.dirname ?? __dirname; describe('useMeshtasticRuntime reconnect hardening (regression)', () => { it('uses suspend-aware delayUnlessSuspended for reconnect backoff', () => { - expect(SOURCE).toContain('delayUnlessSuspended'); - expect(SOURCE).toMatch(/delayResult === 'suspended'/); + expect(SOURCE).toContain('runLoraRfReconnectAttempt'); + expect(ATTEMPT_RUNNER).toContain('delayUnlessSuspended'); + expect(ATTEMPT_RUNNER).toMatch(/delayResult === 'suspended'/); }); it('normalizes reconnect UI to disconnected when backoff aborts due to suspend', () => { - expect(SOURCE).toMatch( - /if \(delayResult === 'suspended'\) \{[\s\S]*?status: 'disconnected'[\s\S]*?connectionLoss: true/, + expect(ATTEMPT_RUNNER).toMatch( + /if \(delayResult === 'suspended'\) \{[\s\S]*?setDisconnectedUi\(\{ connectionLoss: true \}\)/, ); + const reconnectBody = extractUseCallbackBody(SOURCE, 'attemptReconnect'); + expect(reconnectBody).toContain('setDisconnectedUi'); + expect(reconnectBody).toMatch(/status: 'disconnected'[\s\S]*?connectionLoss: true/); }); it('restarts reconnect when disconnect fires during an in-flight reconnect', () => { @@ -46,15 +51,13 @@ describe('useMeshtasticRuntime reconnect hardening (regression)', () => { }); it('cleans up device and watchdog when reconnect budget is exhausted', () => { - expect(SOURCE).toContain('rfMaxReconnectAttemptsForTransport'); - const exhaustionBlock = extractIfBlockBody( - SOURCE, - 'reconnectAttemptRef.current >= maxReconnectAttempts', - ); - expect(exhaustionBlock.length).toBeGreaterThan(0); - expect(exhaustionBlock).toContain('cleanupSubscriptions()'); - expect(exhaustionBlock).toContain('stopWatchdog()'); - expect(exhaustionBlock).toContain('deviceRef.current = null'); + expect(ATTEMPT_RUNNER).toContain('rfMaxReconnectAttemptsForTransport'); + expect(ATTEMPT_RUNNER).toContain('markExhausted()'); + const reconnectBody = extractUseCallbackBody(SOURCE, 'attemptReconnect'); + expect(reconnectBody).toContain('onExhausted:'); + expect(reconnectBody).toContain('cleanupSubscriptions()'); + expect(reconnectBody).toContain('stopWatchdog()'); + expect(reconnectBody).toContain('deviceRef.current = null'); expect(SOURCE).toContain('escalateSerialReconnectExhaustion'); expect(SOURCE).toContain('serialNeedsReselect'); expect(SOURCE).toContain('registerMeshtasticSerialDisconnectTarget'); @@ -120,21 +123,18 @@ describe('useMeshtasticRuntime reconnect hardening (regression)', () => { it('wraps BLE reconnect open in withNobleBleConnectMutex (MeshCore parity)', () => { const reconnectBody = extractUseCallbackBody(SOURCE, 'attemptReconnect'); expect(reconnectBody).toContain("withNobleBleConnectMutex('meshtastic'"); - expect(reconnectBody).toContain('reconnectConnectInFlightRef.current = true'); - expect(reconnectBody).toContain('skip overlapping open'); + expect(reconnectBody).toContain('connectInFlight:'); + expect(ATTEMPT_RUNNER).toContain('connectInFlight.set(true)'); + expect(ATTEMPT_RUNNER).toContain('skip overlapping open'); }); it('bounds every reconnect open+configure with NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS', () => { - // Applies to all transports, not just BLE (see comment at the call site): TCP/HTTP/serial - // used to await the open+configure attempt with no ceiling at all, so a hang anywhere in - // that sequence (e.g. a disconnect landing mid-configure) wedged reconnection forever. - expect(SOURCE).toContain('NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS'); - expect(SOURCE).toContain('raceWithDeadline'); - const reconnectBody = extractUseCallbackBody(SOURCE, 'attemptReconnect'); - expect(reconnectBody).toContain('raceWithDeadline'); - expect(reconnectBody).toContain('Reconnect attempt timed out after'); - expect(reconnectBody).toContain('attemptActive'); - expect(reconnectBody).not.toContain('if (isBleReconnect) {'); + // Shared runner applies the budget to every transport (constant name is historical). + expect(ATTEMPT_RUNNER).toContain('NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS'); + expect(ATTEMPT_RUNNER).toContain('raceWithDeadline'); + expect(ATTEMPT_RUNNER).toContain('Reconnect attempt timed out after'); + expect(ATTEMPT_RUNNER).toContain('attemptActive'); + expect(SOURCE).toContain('runLoraRfReconnectAttempt'); }); it('detaches wire subscriptions when a reconnect attempt times out (CodeRabbit #792)', () => { @@ -150,7 +150,7 @@ describe('useMeshtasticRuntime reconnect hardening (regression)', () => { }); it('disconnects late-opened transport when reconnect attempt is inactive or superseded', () => { - expect(SOURCE).toContain('createBleReconnectTransportCleanup'); + expect(ATTEMPT_RUNNER).toContain('createBleReconnectTransportCleanup'); const reconnectBody = extractUseCallbackBody(SOURCE, 'attemptReconnect'); expect(reconnectBody).toContain('lateTransport.cleanup(opened.driverIdentityId)'); expect(reconnectBody).toMatch( @@ -189,16 +189,13 @@ describe('useMeshtasticRuntime reconnect hardening (regression)', () => { }); it('flushes deferred reconnects after non-BLE reconnect attempts settle', () => { + expect(ATTEMPT_RUNNER).toContain('bleConnectInProgress?.set(false)'); + expect(ATTEMPT_RUNNER).toContain('deferredReconnect.get()'); + expect(ATTEMPT_RUNNER).toContain('scheduleAttempt()'); + expect(ATTEMPT_RUNNER).not.toContain('handleConnectionLostRef.current()'); const reconnectBody = extractUseCallbackBody(SOURCE, 'attemptReconnect'); - const finallyBody = reconnectBody.slice(reconnectBody.indexOf('finally {')); - expect(finallyBody).toContain('if (isBleReconnect) bleConnectInProgressRef.current = false;'); - expect(finallyBody).toContain('if (meshtasticDeferredReconnectRef.current)'); - expect(finallyBody).toContain('scheduleMeshtasticReconnectAttemptRef.current()'); - // Must not re-enter handleConnectionLost (double generation bump / dual backoff loops). - expect(finallyBody).not.toContain('handleConnectionLostRef.current()'); - expect(finallyBody.indexOf('if (meshtasticDeferredReconnectRef.current)')).toBeGreaterThan( - finallyBody.indexOf('if (isBleReconnect)'), - ); + expect(reconnectBody).toContain('scheduleMeshtasticReconnectAttemptRef.current()'); + expect(reconnectBody).toContain('bleConnectInProgress:'); }); it('handleConnectionLost defers when cycle already active (single-owner controller)', () => { @@ -223,8 +220,8 @@ describe('useMeshtasticRuntime reconnect hardening (regression)', () => { }); it('attemptReconnect marks controller exhausted and re-enters via onLinkLost after serial rediscovery', () => { + expect(ATTEMPT_RUNNER).toContain('markExhausted()'); const reconnectBody = extractUseCallbackBody(SOURCE, 'attemptReconnect'); - expect(reconnectBody).toContain('markExhausted()'); expect(reconnectBody).toMatch( /startSerialRediscovery\(\{[\s\S]*?onFound:[\s\S]*?onLinkLost\(\)[\s\S]*?scheduleMeshtasticReconnectAttemptRef\.current\(\)/, ); @@ -254,17 +251,20 @@ describe('useMeshtasticRuntime reconnect hardening (regression)', () => { }); it('attemptReconnect clears stuck reconnecting UI when delay aborts', () => { - const reconnectBody = extractUseCallbackBody(SOURCE, 'attemptReconnect'); - expect(reconnectBody).toMatch( - /delayResult === 'aborted'[\s\S]*?!isReconnectingRef\.current[\s\S]*?status: 'disconnected'/, + expect(ATTEMPT_RUNNER).toMatch( + /delayResult === 'aborted'[\s\S]*?!deps\.isReconnecting\.get\(\)[\s\S]*?setDisconnectedUi/, ); + const reconnectBody = extractUseCallbackBody(SOURCE, 'attemptReconnect'); + expect(reconnectBody).toMatch(/status: 'disconnected'[\s\S]*?connectionLoss: true/); }); it('attemptReconnect delay abort flushes deferred restart', () => { - const reconnectBody = extractUseCallbackBody(SOURCE, 'attemptReconnect'); - expect(reconnectBody).toMatch( - /delayResult === 'aborted'[\s\S]*?meshtasticDeferredReconnectRef\.current[\s\S]*?scheduleMeshtasticReconnectAttemptRef\.current\(\)/, + expect(ATTEMPT_RUNNER).toMatch( + /delayResult === 'aborted'[\s\S]*?deferredReconnect\.get\(\)[\s\S]*?scheduleAttempt\(\)/, ); + const reconnectBody = extractUseCallbackBody(SOURCE, 'attemptReconnect'); + expect(reconnectBody).toContain('deferredReconnect:'); + expect(reconnectBody).toContain('scheduleMeshtasticReconnectAttemptRef.current()'); }); it('checks reconnect generation before open, wire, and configure', () => { @@ -332,8 +332,11 @@ describe('useMeshtasticRuntime manual disconnect must not auto-reconnect', () => }); it('attemptReconnect returns when connection params are cleared', () => { + expect(ATTEMPT_RUNNER).toContain('onMissingParams?.()'); + expect(ATTEMPT_RUNNER).toMatch(/if \(!params\) \{[\s\S]*?isReconnecting\.set\(false\)/); const reconnectBody = extractUseCallbackBody(SOURCE, 'attemptReconnect'); - expect(reconnectBody).toMatch(/if \(!params\) \{[\s\S]*?isReconnectingRef\.current = false/); + expect(reconnectBody).toContain('onMissingParams:'); + expect(reconnectBody).toContain('isReconnecting:'); }); it('Noble BLE disconnect handler respects explicit user disconnect before rehydrate', () => { diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index 147592825..ebf616ea7 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -78,8 +78,7 @@ import { mergeAppSetting, mergeAppSettingsPartial, } from '../lib/appSettingsStorage'; -import { raceWithDeadline, verifyNobleBleRfLink } from '../lib/bleReconnectHelper'; -import { createBleReconnectTransportCleanup } from '../lib/bleReconnectLateTransport'; +import { verifyNobleBleRfLink } from '../lib/bleReconnectHelper'; import { MAX_IN_MEMORY_CHAT_MESSAGES, trimChatMessagesToMax } from '../lib/chatInMemoryBuffer'; import { getBlePeripheralIdFromMeshTransport, @@ -111,6 +110,7 @@ import { } from '../lib/identityStoreReads'; import type { MeshtasticIngestSession } from '../lib/ingest/meshtasticIngest'; import { rehydrateMeshtasticConnectionParamsFromStorage } from '../lib/lastConnectionStorage'; +import { runLoraRfReconnectAttempt } from '../lib/loraRfReconnectAttempt'; import { isRendererNobleBlePlatform, withNobleBleConnectMutex, @@ -192,7 +192,6 @@ import { reactionGlyphFromPicker } from '../lib/reactions'; import { enrichMeshtasticReplyPreviews, resolveMeshtasticWireReplyId } from '../lib/replyPreview'; import { rfConnectionTransportOpts } from '../lib/rfConnectionTypes'; import { createRfReconnectController } from '../lib/rfReconnectController'; -import { rfMaxReconnectAttemptsForTransport } from '../lib/rfReconnectShared'; import { registerMeshtasticSerialDisconnectTarget } from '../lib/serialDisconnectRouter'; import { captureSerialIdentityForRediscovery, @@ -222,12 +221,10 @@ import { traceRouteEventsToResultsMap, waypointEventsToMeshWaypointMap, } from '../lib/storeRecordAdapters'; -import { delayUnlessSuspended } from '../lib/systemPowerState'; import { MESHTASTIC_PACKET_DEDUP_FALLBACK_MAX_ENTRIES, MESHTASTIC_PACKET_DEDUP_TTL_MS, MESHTASTIC_POST_REBOOT_RECONNECT_DELAY_MS, - NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS, RF_SERIAL_OPEN_RETRY_DELAY_MS, } from '../lib/timeConstants'; import { TransportManager } from '../lib/transport/TransportManager'; @@ -2085,318 +2082,227 @@ export function useMeshtasticRuntime() { // ─── Reconnection with exponential backoff ──────────────────── const attemptReconnect = useCallback(async () => { - const params = connectionParamsRef.current; - if (!params) { - isReconnectingRef.current = false; - setState((s) => ({ - ...s, - status: 'disconnected', - connectionType: null, - connectionLoss: false, - batteryPercent: undefined, - batteryCharging: undefined, - })); - return; - } - - const maxReconnectAttempts = rfMaxReconnectAttemptsForTransport(params.type); - if (reconnectAttemptRef.current >= maxReconnectAttempts) { - isReconnectingRef.current = false; - reconnectAttemptRef.current = 0; - meshtasticRfReconnectRef.current.markExhausted(); - cleanupSubscriptions(); - stopWatchdog(); - stopGpsInterval(); - const exhaustedParams = connectionParamsRef.current; - const exhaustedSerialPort = - exhaustedParams?.type === 'serial' ? (exhaustedParams.serialPort ?? null) : null; - if (exhaustedParams?.type === 'serial') { - const captured = captureSerialIdentityForRediscovery(exhaustedSerialPort); - await escalateSerialReconnectExhaustion(exhaustedSerialPort, { forgetPort: false }); - serialRediscoveryStopRef.current?.(); - serialRediscoveryStopRef.current = startSerialRediscovery({ - signature: captured.signature, - portId: captured.portId, - onFound: (port) => { - persistSerialPortIdentity(port); - if (connectionParamsRef.current?.type === 'serial') { - connectionParamsRef.current.serialPort = port; - connectionParamsRef.current.lastSerialPortId = - (port as SerialPort & { portId?: string }).portId ?? - connectionParamsRef.current.lastSerialPortId; - } - setState((s) => ({ - ...s, - serialNeedsReselect: false, - connectionLoss: true, - status: 'reconnecting', - })); - isReconnectingRef.current = true; - reconnectAttemptRef.current = 0; - // Re-enter through the controller after markExhausted (idle → owner). - const linkLost = meshtasticRfReconnectRef.current.onLinkLost(); - reconnectGenerationRef.current = linkLost.generation; - if (linkLost.shouldStartOwner) { - scheduleMeshtasticReconnectAttemptRef.current(); - } else { - meshtasticDeferredReconnectRef.current = true; - } - }, - onTimeout: () => { - void forgetGrantedSerialPortBestEffort(exhaustedSerialPort); - }, - }); - } - deviceRef.current = null; - meshtasticDriverConnectedRef.current = false; - meshtasticPendingDriverIdentityRef.current = null; - setState((s) => ({ - ...s, - status: 'disconnected', - connectionType: exhaustedParams?.type === 'serial' ? 'serial' : null, - connectionLoss: true, - serialNeedsReselect: exhaustedParams?.type === 'serial', - batteryPercent: undefined, - batteryCharging: undefined, - })); - return; - } - - // Capture the current generation so stale attempts can be detected - const generation = reconnectGenerationRef.current; - - reconnectAttemptRef.current++; - meshtasticRfReconnectRef.current.beginAttempt(reconnectAttemptRef.current); - setState((s) => ({ - ...s, - status: 'reconnecting', - connectionLoss: true, - reconnectAttempt: reconnectAttemptRef.current, - })); - - const delay = Math.min(2000 * Math.pow(2, reconnectAttemptRef.current - 1), 32000); - console.debug( - `[useMeshtasticRuntime] reconnect: waiting ${delay}ms before attempt ${reconnectAttemptRef.current}/${maxReconnectAttempts}`, - ); - const delayResult = await delayUnlessSuspended(delay, () => - !isReconnectingRef.current ? true : reconnectGenerationRef.current !== generation, - ); - if (delayResult === 'aborted') { - // Generation bump during backoff (or cancel) aborted this delay. If a deferred restart was - // requested, schedule the next attempt — do not leave isReconnecting true with no work - // (MeshCore #795 / n7eal TCP parity). - if ( - meshtasticDeferredReconnectRef.current && - isReconnectingRef.current && - !meshtasticExplicitDisconnectRef.current - ) { - meshtasticDeferredReconnectRef.current = false; - meshtasticRfReconnectRef.current.endAttempt({ keepReconnecting: true }); + let openedDriverIdentityId: string | undefined; + await runLoraRfReconnectAttempt({ + logTag: 'useMeshtasticRuntime', + controller: meshtasticRfReconnectRef.current, + getParams: () => connectionParamsRef.current, + getTransportType: (p) => p.type, + isBle: (p) => p.type === 'ble', + isExplicitDisconnect: () => meshtasticExplicitDisconnectRef.current, + isReconnecting: { + get: () => isReconnectingRef.current, + set: (v) => { + isReconnectingRef.current = v; + }, + }, + generation: { + get: () => reconnectGenerationRef.current, + set: (v) => { + reconnectGenerationRef.current = v; + }, + }, + attemptCounter: { + get: () => reconnectAttemptRef.current, + set: (v) => { + reconnectAttemptRef.current = v; + }, + }, + deferredReconnect: { + get: () => meshtasticDeferredReconnectRef.current, + set: (v) => { + meshtasticDeferredReconnectRef.current = v; + }, + }, + connectInFlight: { + get: () => reconnectConnectInFlightRef.current, + set: (v) => { + reconnectConnectInFlightRef.current = v; + }, + }, + bleConnectInProgress: { + get: () => bleConnectInProgressRef.current, + set: (v) => { + bleConnectInProgressRef.current = v; + }, + }, + scheduleAttempt: () => { scheduleMeshtasticReconnectAttemptRef.current(); - return; - } - meshtasticRfReconnectRef.current.endAttempt({ - keepReconnecting: isReconnectingRef.current, - }); - // Only clear the UI when this cycle was cancelled and nothing else is driving reconnect. - if (!isReconnectingRef.current) { + }, + setReconnectingUi: (attempt) => { + setState((s) => ({ + ...s, + status: 'reconnecting', + connectionLoss: true, + reconnectAttempt: attempt, + })); + }, + setDisconnectedUi: () => { setState((s) => ({ ...s, status: 'disconnected', connectionLoss: true, })); - } - return; - } - if (delayResult === 'suspended') { - isReconnectingRef.current = false; - meshtasticRfReconnectRef.current.cancel(); - setState((s) => ({ - ...s, - status: 'disconnected', - connectionLoss: true, - })); - return; - } - - // Check if user manually disconnected or started a new connection during the wait - if (!isReconnectingRef.current || reconnectGenerationRef.current !== generation) { - if ( - meshtasticDeferredReconnectRef.current && - isReconnectingRef.current && - !meshtasticExplicitDisconnectRef.current - ) { - meshtasticDeferredReconnectRef.current = false; - meshtasticRfReconnectRef.current.endAttempt({ keepReconnecting: true }); - scheduleMeshtasticReconnectAttemptRef.current(); - return; - } - meshtasticRfReconnectRef.current.endAttempt({ - keepReconnecting: isReconnectingRef.current, - }); - if (!isReconnectingRef.current) { + }, + overlapCheck: 'beforeOpening', + disconnectIdentity: (identityId) => connectionDriver.disconnect(identityId), + onMissingParams: () => { + setState((s) => ({ + ...s, + status: 'disconnected', + connectionType: null, + connectionLoss: false, + batteryPercent: undefined, + batteryCharging: undefined, + })); + }, + onExhausted: async (params) => { + cleanupSubscriptions(); + stopWatchdog(); + stopGpsInterval(); + const exhaustedSerialPort = params.type === 'serial' ? (params.serialPort ?? null) : null; + if (params.type === 'serial') { + const captured = captureSerialIdentityForRediscovery(exhaustedSerialPort); + await escalateSerialReconnectExhaustion(exhaustedSerialPort, { forgetPort: false }); + serialRediscoveryStopRef.current?.(); + serialRediscoveryStopRef.current = startSerialRediscovery({ + signature: captured.signature, + portId: captured.portId, + onFound: (port) => { + persistSerialPortIdentity(port); + if (connectionParamsRef.current?.type === 'serial') { + connectionParamsRef.current.serialPort = port; + connectionParamsRef.current.lastSerialPortId = + (port as SerialPort & { portId?: string }).portId ?? + connectionParamsRef.current.lastSerialPortId; + } + setState((s) => ({ + ...s, + serialNeedsReselect: false, + connectionLoss: true, + status: 'reconnecting', + })); + isReconnectingRef.current = true; + reconnectAttemptRef.current = 0; + // Re-enter through the controller after markExhausted (idle → owner). + const linkLost = meshtasticRfReconnectRef.current.onLinkLost(); + reconnectGenerationRef.current = linkLost.generation; + if (linkLost.shouldStartOwner) { + scheduleMeshtasticReconnectAttemptRef.current(); + } else { + meshtasticDeferredReconnectRef.current = true; + } + }, + onTimeout: () => { + void forgetGrantedSerialPortBestEffort(exhaustedSerialPort); + }, + }); + } + deviceRef.current = null; + meshtasticDriverConnectedRef.current = false; + meshtasticPendingDriverIdentityRef.current = null; setState((s) => ({ ...s, status: 'disconnected', + connectionType: params.type === 'serial' ? 'serial' : null, connectionLoss: true, + serialNeedsReselect: params.type === 'serial', + batteryPercent: undefined, + batteryCharging: undefined, })); - } - return; - } - - // Single-flight: overlapping handleConnectionLost must not open a second GATT session. - if (reconnectConnectInFlightRef.current) { - console.debug( - '[useMeshtasticRuntime] reconnect: skip overlapping open (connect already in flight)', - ); - meshtasticDeferredReconnectRef.current = true; - meshtasticRfReconnectRef.current.markDirty(); - return; - } - - meshtasticRfReconnectRef.current.beginOpening(); - let opened: Awaited> | undefined; - const isBleReconnect = params.type === 'ble'; - reconnectConnectInFlightRef.current = true; - if (isBleReconnect) bleConnectInProgressRef.current = true; - /** Cleared synchronously on settle so a late Promise.race loser cannot apply success. */ - let attemptActive = true; - const lateTransport = createBleReconnectTransportCleanup( - (identityId) => connectionDriver.disconnect(identityId), - 'useMeshtasticRuntime', - ); - const runReconnectAttempt = async () => { - if (reconnectGenerationRef.current !== generation || !attemptActive) { - throw new Error('Reconnect superseded before open'); - } - opened = - isBleReconnect && isRendererNobleBlePlatform() - ? await withNobleBleConnectMutex('meshtastic', () => - openMeshtasticTransport(params.type, { + }, + runOpenAndAttach: async (ctx, params) => { + const { generation, isBle: isBleReconnect, attemptActive, lateTransport } = ctx; + if (reconnectGenerationRef.current !== generation || !attemptActive()) { + throw new Error('Reconnect superseded before open'); + } + const opened = + isBleReconnect && isRendererNobleBlePlatform() + ? await withNobleBleConnectMutex('meshtastic', () => + openMeshtasticTransport(params.type, { + httpAddress: params.httpAddress, + blePeripheralId: params.blePeripheralId, + lastSerialPortId: params.lastSerialPortId, + }), + ) + : await openMeshtasticTransport(params.type, { httpAddress: params.httpAddress, blePeripheralId: params.blePeripheralId, lastSerialPortId: params.lastSerialPortId, - }), - ) - : await openMeshtasticTransport(params.type, { - httpAddress: params.httpAddress, - blePeripheralId: params.blePeripheralId, - lastSerialPortId: params.lastSerialPortId, - }); - if (reconnectGenerationRef.current !== generation || !attemptActive) { - await lateTransport.cleanup(opened.driverIdentityId); - throw new Error('Reconnect superseded after open'); - } - deviceRef.current = opened.device; - wireSubscriptions(opened.device, params.type, { - driverIdentityId: opened.driverIdentityId, - }); - if (reconnectGenerationRef.current !== generation || !attemptActive) { - await lateTransport.cleanup(opened.driverIdentityId); - throw new Error('Reconnect superseded before configure'); - } - await configureMeshtasticDeviceWithRetry(opened.device, { - logTag: 'useMeshtasticRuntime reconnect', - }); + }); + openedDriverIdentityId = opened.driverIdentityId; + if (reconnectGenerationRef.current !== generation || !attemptActive()) { + await lateTransport.cleanup(opened.driverIdentityId); + throw new Error('Reconnect superseded after open'); + } + deviceRef.current = opened.device; + wireSubscriptions(opened.device, params.type, { + driverIdentityId: opened.driverIdentityId, + }); + if (reconnectGenerationRef.current !== generation || !attemptActive()) { + await lateTransport.cleanup(opened.driverIdentityId); + throw new Error('Reconnect superseded before configure'); + } + await configureMeshtasticDeviceWithRetry(opened.device, { + logTag: 'useMeshtasticRuntime reconnect', + }); - if (reconnectGenerationRef.current !== generation || !attemptActive) { - await lateTransport.cleanup(opened.driverIdentityId); - throw new Error('Reconnect superseded during configure'); - } - if (!(await verifyNobleBleRfLink(params.type, 'meshtastic'))) { - await lateTransport.cleanup(opened.driverIdentityId); - throw new Error('RF link lost after reconnect configure'); - } - if (!attemptActive || reconnectGenerationRef.current !== generation) { - await lateTransport.cleanup(opened.driverIdentityId); - throw new Error('Reconnect superseded after configure'); - } + if (reconnectGenerationRef.current !== generation || !attemptActive()) { + await lateTransport.cleanup(opened.driverIdentityId); + throw new Error('Reconnect superseded during configure'); + } + if (!(await verifyNobleBleRfLink(params.type, 'meshtastic'))) { + await lateTransport.cleanup(opened.driverIdentityId); + throw new Error('RF link lost after reconnect configure'); + } + if (!attemptActive() || reconnectGenerationRef.current !== generation) { + await lateTransport.cleanup(opened.driverIdentityId); + throw new Error('Reconnect superseded after configure'); + } - // Success - console.debug( - `[useMeshtasticRuntime] Reconnect succeeded on attempt ${reconnectAttemptRef.current}`, - ); - if (params.type === 'serial' && connectionParamsRef.current && opened?.device) { - connectionParamsRef.current.serialPort = getSerialPortFromMeshTransport( - opened.device.transport, + // Success + console.debug( + `[useMeshtasticRuntime] Reconnect succeeded on attempt ${reconnectAttemptRef.current}`, ); - } - reconnectAttemptRef.current = 0; - isReconnectingRef.current = false; - meshtasticDeferredReconnectRef.current = false; - meshtasticRfReconnectRef.current.markSuccess(); - setState((s) => ({ - ...s, - serialNeedsReselect: false, - connectionLoss: false, - })); - requestChatOutboxDrain('meshtastic'); - }; - const reconnectWork = runReconnectAttempt(); - // Late loser after budget timeout — avoid unhandledRejection; cleanup is in catch / late path. - void reconnectWork.catch(() => {}); - try { - // Applied to every transport, not just BLE (constant name is historical): TCP/HTTP/serial - // reconnects used to `await reconnectWork` with no ceiling at all. A disconnect that lands - // while an open+configure is still in flight (now common — TCP disconnect detection is - // near-instant, see meshtasticTransportLossDetection.ts) defers to that attempt settling; - // without a deadline here, a hang anywhere in openMeshtasticTransport/configure wedges the - // whole reconnect state machine forever instead of just failing this attempt and retrying. - await raceWithDeadline( - reconnectWork, - NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS, - `Reconnect attempt timed out after ${NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS}ms`, - ); - } catch (err) { - attemptActive = false; - const failedDriverIdentity = - opened?.driverIdentityId ?? - meshtasticIdentityIdRef.current ?? - meshtasticPendingDriverIdentityRef.current; - deviceRef.current = null; - meshtasticDriverConnectedRef.current = false; - meshtasticPendingDriverIdentityRef.current = null; - await lateTransport.cleanup(failedDriverIdentity); - // wireSubscriptions() already ran for this attempt's device by the time raceWithDeadline - // can time out (it's called synchronously right after open, before any long await); if we - // don't detach here, the loss-watch listener and wrapped toDevice stream stay live against - // the now-abandoned device until something else happens to tear them down. - cleanupSubscriptions(); - console.warn( - `[useMeshtasticRuntime] Reconnect attempt ${reconnectAttemptRef.current} failed:` + - ' ' + - errLikeToLogString(err), - ); - // Retry only if this generation is still current. Deferred Noble drops are flushed in finally. - if ( - !meshtasticDeferredReconnectRef.current && - isReconnectingRef.current && - reconnectGenerationRef.current === generation - ) { - scheduleMeshtasticReconnectAttemptRef.current(); - } - } finally { - attemptActive = false; - reconnectConnectInFlightRef.current = false; - if (isBleReconnect) bleConnectInProgressRef.current = false; - if (meshtasticDeferredReconnectRef.current) { - meshtasticRfReconnectRef.current.markDirty(); - } - const settled = meshtasticRfReconnectRef.current.endAttempt({ - keepReconnecting: isReconnectingRef.current, - }); - if (meshtasticDeferredReconnectRef.current || settled.shouldSchedule) { - meshtasticDeferredReconnectRef.current = false; - if (isReconnectingRef.current) { - console.debug( - '[useMeshtasticRuntime] reconnect settled — running deferred reconnect after transport drop', + if (params.type === 'serial' && connectionParamsRef.current && opened.device) { + connectionParamsRef.current.serialPort = getSerialPortFromMeshTransport( + opened.device.transport, ); - // Call attempt directly (coalesced) — nested handleConnectionLost re-bumped generation - // and raced a second backoff loop with this flush (MeshCore #792 / n7eal TCP parity). - scheduleMeshtasticReconnectAttemptRef.current(); } - } - } + reconnectAttemptRef.current = 0; + isReconnectingRef.current = false; + meshtasticDeferredReconnectRef.current = false; + meshtasticRfReconnectRef.current.markSuccess(); + setState((s) => ({ + ...s, + serialNeedsReselect: false, + connectionLoss: false, + })); + requestChatOutboxDrain('meshtastic'); + }, + onAttemptError: async (err, { lateTransport }) => { + const failedDriverIdentity = + openedDriverIdentityId ?? + meshtasticIdentityIdRef.current ?? + meshtasticPendingDriverIdentityRef.current; + deviceRef.current = null; + meshtasticDriverConnectedRef.current = false; + meshtasticPendingDriverIdentityRef.current = null; + await lateTransport.cleanup(failedDriverIdentity); + // wireSubscriptions() already ran for this attempt's device by the time raceWithDeadline + // can time out (it's called synchronously right after open, before any long await); if we + // don't detach here, the loss-watch listener and wrapped toDevice stream stay live against + // the now-abandoned device until something else happens to tear them down. + cleanupSubscriptions(); + console.warn( + `[useMeshtasticRuntime] Reconnect attempt ${reconnectAttemptRef.current} failed:` + + ' ' + + errLikeToLogString(err), + ); + // Retry only if this generation is still current. Deferred Noble drops are flushed in finally. + return 'retry'; + }, + }); }, [ wireSubscriptions, openMeshtasticTransport, diff --git a/vitest.config.mts b/vitest.config.mts index c883523ae..626de2486 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -75,6 +75,7 @@ const RENDERER_LOGIC_EXCLUDE = [ 'src/renderer/lib/reticulumVoiceCapability.test.ts', 'src/renderer/lib/chatPanelProtocolStorage.test.ts', 'src/renderer/lib/chatScrollUtils.test.ts', + 'src/renderer/lib/confettiBurst.test.ts', 'src/renderer/lib/connectedMeshcoreBleMac.test.ts', 'src/renderer/lib/connection.ble-retry.test.ts', 'src/renderer/lib/connection.serial-cleanup.test.ts',