diff --git a/CHANGELOG.md b/CHANGELOG.md index fda5823..2acc6a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ 0.1.1 - Intégration Tauri minimale du WsClient 0.2.0 - Couche JSON-RPC WS Solana 0.3.0 - Registre subscriptions / notifications -0.3.1 - Ajout de subscribe/unsubscribe hlpers à WsClient -0.3.2 - Ajout de notifications typés -0.3.3 - Ajout du suffix _raw au fonction raw pour avoir des fonction subscribe _typed et _raw +0.3.1 - Ajout des helpers subscribe/unsubscribe à WsClient +0.3.2 - Ajout des helpers typed et du parsing typed basé sur solana-rpc-client-api +0.3.3 - Ajout du suffixe _raw aux helpers raw pour distinguer typed et raw +0.3.4 - Ajout de la fenêtre Demo Ws dans kb_app pour tester les souscriptions live diff --git a/Cargo.toml b/Cargo.toml index 7e15a79..04e0dbb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "0.3.3" +version = "0.3.4" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-bobobot" diff --git a/ROADMAP.md b/ROADMAP.md index 08c66f5..e3967df 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -61,11 +61,11 @@ Le projet doit pouvoir évoluer progressivement vers les capacités suivantes : ## 4. Configuration cible -La configuration applicative sera stockée dans un fichier `config.json`. +La configuration applicative est stockée dans un fichier `config.json`. ### 4.1. Points à couvrir dans la configuration -Le fichier devra à terme permettre de configurer : +Le fichier doit permettre de configurer : - les endpoints HTTP, - les endpoints WebSocket, @@ -93,12 +93,13 @@ Le fichier devra à terme permettre de configurer : ### 4.3. Exigences particulières -Chaque endpoint devra pouvoir porter sa propre configuration, par exemple : +Chaque endpoint doit pouvoir porter sa propre configuration, par exemple : - nom logique, - URL, - provider, - présence ou non d’une clé API, +- variable d’environnement pour clé API, - plafond de requêtes, - burst, - timeout, @@ -116,7 +117,7 @@ Exemples de rôles futurs : ## 5. Tracing cible -Le tracing devra être centralisé dans `kb_lib`. +Le tracing est centralisé dans `kb_lib`. ### 5.1. Exigences initiales @@ -139,83 +140,132 @@ Le tracing devra être centralisé dans `kb_lib`. ## 6. Phasage par versions -## 6.1. Version `0.0.2` — Socle conforme +### 6.1. Version `0.0.2` — Socle conforme Objectif : corriger le squelette et poser la base de travail. -À faire : +Réalisé : -- corriger `kb_lib/src/lib.rs`, -- créer `KbError`, -- créer `KbConfig`, -- créer `init_tracing`, -- créer les constantes Solana officielles, -- préparer les modules `ws_client` et `http_client`, -- remettre `kb_app/src/lib.rs` en conformité, -- documenter `kb_app/src/splash.rs`, -- garder une UI minimale avec bouton connect / disconnect et zone de sortie. +- correction de `kb_lib/src/lib.rs`, +- création de `KbError`, +- création de `KbConfig`, +- création de `init_tracing`, +- création des constantes Solana officielles, +- préparation des modules `ws_client` et `http_client`, +- remise de `kb_app/src/lib.rs` en conformité, +- documentation de `kb_app/src/splash.rs`, +- UI Tauri minimale. -Livrables : - -- squelette propre, -- config JSON minimale, -- tracing centralisé, -- UI Tauri minimale opérationnelle, -- premiers types partagés. - -## 6.2. Version `0.1.x` — Transport WebSocket générique +### 6.2. Version `0.1.x` — Transport WebSocket générique Objectif : construire un vrai `WsClient` asynchrone clonable. -À faire : +Réalisé : -- `connect`, `disconnect`, `is_connected`, +- `connect`, `disconnect`, `connection_state`, - flux de lecture séparé du flux d’écriture, - identifiant incrémental interne par client, - canal sortant borné, - émission d’événements internes, - support de l’arrêt propre, -- fermeture avec timeout. - -Contraintes : - -- aucune reconnexion implicite au départ, -- pas d’usage de `solana-pubsub-client`, +- fermeture avec timeout, - tests offline avec serveur mock. -## 6.3. Version `0.2.x` — Couche JSON-RPC WS Solana +### 6.3. Version `0.1.1` — Intégration Tauri minimale du `WsClient` + +Objectif : valider le transport via l’application desktop. + +Réalisé : + +- intégration minimale de `WsClient` dans `kb_app`, +- boutons start/stop, +- zone de logs, +- validation du flux `frontend -> tauri -> kb_lib -> frontend`. + +### 6.4. Version `0.2.0` — Couche JSON-RPC WS Solana Objectif : séparer clairement transport, réponses RPC et notifications. -À faire : +Réalisé : - enveloppes JSON-RPC 2.0, - gestion des `request_id`, -- registre des requêtes en attente, -- distinction entre requêtes standard et subscribe/unsubscribe, -- parsing des réponses, -- séparation stricte des notifications. +- parsing des réponses et erreurs, +- parsing des notifications, +- premiers helpers JSON-RPC sur `WsClient`. -Livrables : - -- registre `request_id -> pending kind`, -- registre `subscription_id -> metadata`, -- tests de corrélation et de séparation des flux. - -## 6.4. Version `0.3.x` — Registre subscriptions / notifications +### 6.5. Version `0.3.0` — Registre subscriptions / notifications Objectif : fiabiliser la gestion des subscriptions. -À faire : +Réalisé : - stockage des subscriptions actives, - mapping entre requête de subscribe et `subscription_id` serveur, - unsubscribe propre avant fermeture, - timeout d’attente sur unsubscribe, -- purge locale même si le serveur ne répond pas, +- purge locale si nécessaire, - routage séparé des notifications. -## 6.5. Version `0.4.x` — Transport HTTP générique +### 6.6. Version `0.3.1` — Helpers subscribe/unsubscribe WebSocket + +Objectif : ajouter les helpers haut niveau correspondant aux principales méthodes PubSub Solana. + +Réalisé : + +- helpers pour `account`, `block`, `logs`, `program`, `root`, `signature`, `slot`, `slotsUpdates`, `vote`, +- helpers d’unsubscribe correspondants, +- premiers tests de validation des noms de méthodes. + +### 6.7. Version `0.3.2` — Helpers typed et notifications typed + +Objectif : s’appuyer principalement sur `solana-rpc-client-api` pour typer les subscribe et les notifications. + +Réalisé : + +- helpers typed pour `account`, `block`, `logs`, `program`, `signature`, +- parsing typed des notifications, +- base de travail pour réduire l’usage direct de `serde_json::Value`. + +### 6.8. Version `0.3.3` — Distinction API typed / raw + +Objectif : clarifier l’API publique de `WsClient`. + +Réalisé : + +- suffixe `_raw` sur les helpers raw, +- conservation des helpers typed comme interface plus propre, +- préparation d’une hiérarchie API plus explicite. + +### 6.9. Version `0.3.4` — Fenêtre `Demo Ws` dans `kb_app` + +Objectif : tester manuellement les souscriptions live dans une fenêtre dédiée. + +Réalisé : + +- fenêtre séparée `demo_ws`, +- ouverture depuis la fenêtre principale, +- connexion/déconnexion d’un client de démo, +- test de souscriptions live, +- affichage des événements raw et typed, +- premiers tests réels sur `wss://api.mainnet.solana.com`. + +### 6.10. Version `0.3.5` — Stabilisation de `Demo Ws` + +Objectif : rendre la fenêtre de démonstration robuste sous flux élevé et cohérente avec la configuration. + +À faire : + +- lire correctement les endpoints activés depuis la config et refléter les URLs résolues avec `api_key_env_var`, +- améliorer la sélection réelle des endpoints affichés et utilisables, +- ajouter du throttling / rate limiting de l’affichage UI sous fort débit, +- limiter ou résumer les événements affichés côté fenêtre, +- conserver l’intégralité des traces côté `tracing`, +- éviter le gel de la fenêtre sur `logsSubscribe` et `programSubscribe`, +- conserver des compteurs et états UI exploitables, +- mieux gérer les fermetures/ralentissements d’endpoints publics. + +### 6.11. Version `0.4.x` — Transport HTTP générique Objectif : construire un `HttpClient` clonable et limité. @@ -226,15 +276,11 @@ Objectif : construire un `HttpClient` clonable et limité. - burst configurable, - délais configurables, - profils par endpoint, -- endpoints publics ou API-key. - -Livrables : - -- `HttpClient`, +- endpoints publics ou API-key, - abstraction de requêtes JSON-RPC HTTP, - premiers appels sur endpoints Solana. -## 6.6. Version `0.5.x` — Base de données SQLite +### 6.12. Version `0.5.x` — Base de données SQLite Objectif : poser la persistance locale. @@ -245,7 +291,7 @@ Objectif : poser la persistance locale. - premières tables techniques, - stockage des endpoints, événements, tokens observés, subscriptions actives si utile. -## 6.7. Version `0.6.x` — Détection technique on-chain / RPC +### 6.13. Version `0.6.x` — Détection technique on-chain / RPC Objectif : commencer la détection utile pour l’application. @@ -256,7 +302,7 @@ Objectif : commencer la détection utile pour l’application. - débuts de normalisation d’événements, - premiers connecteurs DEX. -## 6.8. Version `0.7.x` — DEX connectors v1 +### 6.14. Version `0.7.x` — DEX connectors v1 Objectif : structurer les connecteurs par protocole. @@ -276,7 +322,7 @@ Cibles initiales possibles : - création de types métiers propres, - enrichissement des métadonnées token/pool/pair. -## 6.9. Version `0.8.x` — Analyse et filtrage +### 6.15. Version `0.8.x` — Analyse et filtrage Objectif : transformer les événements bruts en signaux exploitables. @@ -288,7 +334,7 @@ Objectif : transformer les événements bruts en signaux exploitables. - statistiques de comportement, - premiers patterns. -## 6.10. Version `1.x.y` — Wallets et swap préparatoire +### 6.16. Version `1.x.y` — Wallets et swap préparatoire Objectif : préparer la couche d’action. @@ -300,7 +346,7 @@ Objectif : préparer la couche d’action. - préparation d’ordres et de swaps, - simulation et garde-fous. -## 6.11. Version `2.x.y` — Trading semi-automatisé +### 6.17. Version `2.x.y` — Trading semi-automatisé Objectif : brancher l’analyse à l’action tout en gardant des garde-fous explicites. @@ -312,7 +358,7 @@ Objectif : brancher l’analyse à l’action tout en gardant des garde-fous exp - confirmations explicites ou semi-automatiques, - journaux d’exécution. -## 6.12. Version `3.x.y` — Yellowstone gRPC +### 6.18. Version `3.x.y` — Yellowstone gRPC Objectif : ajouter le connecteur gRPC dédié. @@ -325,7 +371,7 @@ Objectif : ajouter le connecteur gRPC dédié. ## 7. Organisation des modules ciblés -## 7.1. `kb_lib` +### 7.1. `kb_lib` Modules cibles à court terme : @@ -338,8 +384,9 @@ Modules cibles à court terme : - `http_client.rs` - `rpc_json.rs` - `rpc_ws.rs` +- `rpc_ws_solana.rs` -## 7.2. `kb_app` +### 7.2. `kb_app` Responsabilités cibles : @@ -347,7 +394,8 @@ Responsabilités cibles : - commandes UI, - affichage des états et messages, - réception des événements venant de `kb_lib`, -- persistance future des préférences UI. +- persistance future des préférences UI, +- fenêtres de démonstration / diagnostic isolées. ## 8. Ligne de conduite sur le `WsClient` @@ -393,7 +441,8 @@ Plus tard, ce comportement pourra devenir configurable dans `config.json` et pil Le projet doit maintenir au minimum : - un `README.md` global, -- un `Roadmap.md` global, +- un `ROADMAP.md` global, +- un `CHANGELOG.md` global, - des `README.md` et `TODO.md` par crate à mesure de l’évolution, - des tests unitaires robustes, - les bindings TS générés via `cargo test export_bindings` lorsque les types partagés évoluent. @@ -402,11 +451,8 @@ Le projet doit maintenir au minimum : La priorité immédiate est la suivante : -1. corriger le squelette, -2. poser `KbError`, -3. poser `KbConfig`, -4. poser `init_tracing`, -5. poser les constantes Solana, -6. préparer `ws_client` et `http_client`, -7. remettre `kb_app` en conformité, -8. conserver une UI minimale, puis brancher progressivement les clients réseau. +1. stabiliser `Demo Ws`, +2. corriger la lecture/exposition des endpoints activés depuis la config, +3. améliorer la robustesse de l’UI sous fort débit, +4. préparer ensuite le transport HTTP générique, +5. poursuivre la structuration des connecteurs DEX. diff --git a/kb_app/Cargo.toml b/kb_app/Cargo.toml index aecf940..8a4c716 100644 --- a/kb_app/Cargo.toml +++ b/kb_app/Cargo.toml @@ -23,6 +23,8 @@ fs2.workspace = true kb_lib = { path = "../kb_lib" } rustls.workspace = true serde.workspace = true +serde_json.workspace = true +solana-rpc-client-api.workspace = true tauri.workspace = true tauri-plugin-tracing.workspace = true tokio.workspace = true diff --git a/kb_app/capabilities/default.json b/kb_app/capabilities/default.json index 9c02bc7..53de134 100644 --- a/kb_app/capabilities/default.json +++ b/kb_app/capabilities/default.json @@ -1,10 +1,14 @@ { - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "default", - "description": "Capability for the main window", - "windows": ["main", "splash"], - "permissions": [ - "core:default", - "tracing:default" - ] -} + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": [ + "main", + "splash", + "demo_ws" + ], + "permissions": [ + "core:default", + "tracing:default" + ] +} \ No newline at end of file diff --git a/kb_app/frontend/demo_ws.html b/kb_app/frontend/demo_ws.html new file mode 100644 index 0000000..37836d0 --- /dev/null +++ b/kb_app/frontend/demo_ws.html @@ -0,0 +1,157 @@ + + + + + + + Demo Ws Subscribe + + + +
+ +
+ +
+
+
+
+
+
+
+

Demo Ws Subscribe

+

+ Fenêtre de test complète pour les souscriptions WebSocket Solana en mode raw ou typed. +

+
+
+
+ +
+
+
+

Connexion

+ +
+ + +
+ +
+ + +
+ +
+
State: Disconnected
+
Endpoint: -
+
+ +
+ +

Souscription

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+ +
+
Current subscription: -
+
Request: -
+
+
+
+
+ +
+
+
+

Logs

+ +
+
+
+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/kb_app/frontend/index.html b/kb_app/frontend/main.html similarity index 55% rename from kb_app/frontend/index.html rename to kb_app/frontend/main.html index 1661b7d..9deb7ca 100644 --- a/kb_app/frontend/index.html +++ b/kb_app/frontend/main.html @@ -15,6 +15,12 @@ + +
+ +
@@ -31,30 +37,25 @@
-
-
-

WebSocket transport

-

- Démarre ou arrête tous les endpoints WebSocket activés dans config.json. -

-
-
- Disconnected - - -
+

Desktop shell

+

+ La fenêtre principale reste volontairement légère. + Les tests WebSocket manuels sont disponibles dans la fenêtre dédiée + Demo Ws. +

+ +
+
-
- - -
+
+ +

+ Cette fenêtre sert de point d’entrée applicatif. Les démonstrations de + souscriptions Solana live sont isolées dans la fenêtre de test dédiée. +

diff --git a/kb_app/frontend/ts/demo_ws.ts b/kb_app/frontend/ts/demo_ws.ts new file mode 100644 index 0000000..58ef059 --- /dev/null +++ b/kb_app/frontend/ts/demo_ws.ts @@ -0,0 +1,477 @@ +// file: kb_app/frontend/ts/demo_ws.ts + +import * as bootstrap from "bootstrap"; +import "simplebar"; +import ResizeObserver from "resize-observer-polyfill"; +import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { trace, takeoverConsole } from "@fltsci/tauri-plugin-tracing"; + +(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap; +(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver; + +interface DemoWsEndpointSummary { + name: string; + resolvedUrl: string; + provider: string; + enabled: boolean; + roles: string[]; +} + +interface DemoWsStatusPayload { + connectionState: string; + endpointName: string | null; + endpointUrl: string | null; + currentSubscriptionId: number | null; + currentSubscribeMethod: string | null; + currentUnsubscribeMethod: string | null; + currentNotificationMethod: string | null; +} + +interface DemoWsSubscribeRequest { + method: string; + mode: string; + target: string | null; + filterJson: string | null; + configJson: string | null; +} + +function shortenLine(line: string, maxChars = 3000): string { + if (line.length <= maxChars) { + return line; + } + + return `${line.slice(0, maxChars)} …[truncated ${line.length - maxChars} chars]`; +} + +function appendLogLine(textarea: HTMLTextAreaElement, line: string): void { + const now = new Date(); + const timestamp = now.toLocaleTimeString("fr-CH", { hour12: false }); + const safeLine = shortenLine(line, 3000); + + const existingLines = textarea.value === "" ? [] : textarea.value.split("\n"); + existingLines.push(`[${timestamp}] ${safeLine}`); + + const maxLines = 800; + const trimmedLines = existingLines.length > maxLines + ? existingLines.slice(existingLines.length - maxLines) + : existingLines; + + textarea.value = trimmedLines.join("\n"); + textarea.scrollTop = textarea.scrollHeight; +} + +function setStatusBadge(badge: HTMLSpanElement, state: string): void { + badge.textContent = state; + + if (state === "Connected") { + badge.className = "badge text-bg-success"; + return; + } + + if (state === "Connecting" || state === "Disconnecting") { + badge.className = "badge text-bg-warning"; + return; + } + + badge.className = "badge text-bg-secondary"; +} + +function methodSupportsTypedMode(method: string): boolean { + return ["account", "block", "logs", "program", "signature"].includes(method); +} + +function methodNeedsTarget(method: string): boolean { + return ["account", "program", "signature"].includes(method); +} + +function methodNeedsFilter(method: string): boolean { + return ["block", "logs"].includes(method); +} + +function methodNeedsConfig(method: string): boolean { + return ["account", "block", "logs", "program", "signature"].includes(method); +} + +function targetLabelForMethod(method: string): string { + if (method === "account") return "Account pubkey"; + if (method === "program") return "Program id"; + if (method === "signature") return "Signature"; + return "Target"; +} + +function methodPreset(method: string, mode: string): { + target: string; + filterJson: string; + configJson: string; +} { + if (method === "account") { + return { + target: "11111111111111111111111111111111", + filterJson: "", + configJson: mode === "typed" + ? `{"encoding":"base64","commitment":"confirmed"}` + : `{"encoding":"base64","commitment":"confirmed"}`, + }; + } + + if (method === "block") { + return { + target: "", + filterJson: `{"mentionsAccountOrProgram":"11111111111111111111111111111111"}`, + configJson: mode === "typed" + ? `{"commitment":"confirmed","encoding":"base64","transactionDetails":"signatures","showRewards":false,"maxSupportedTransactionVersion":0}` + : `{"commitment":"confirmed","encoding":"base64","transactionDetails":"signatures","showRewards":false,"maxSupportedTransactionVersion":0}`, + }; + } + + if (method === "logs") { + return { + target: "", + filterJson: `{"mentions":["11111111111111111111111111111111"]}`, + configJson: `{"commitment":"confirmed"}`, + }; + } + + if (method === "program") { + return { + target: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + filterJson: "", + configJson: mode === "typed" + ? `{"encoding":"base64","commitment":"confirmed","filters":[]}` + : `{"encoding":"base64","commitment":"confirmed","filters":[]}`, + }; + } + + if (method === "signature") { + return { + target: "2EBVM6cB8vAAD93Ktr6Vd8p67XPbQzCJX47MpReuiCXJAtcjaxpvWpcg9Ege1Nr5Tk3a2GFrByT7WPBjdsTycY9b", + filterJson: "", + configJson: `{"commitment":"confirmed","enableReceivedNotification":true}`, + }; + } + + return { + target: "", + filterJson: "", + configJson: "", + }; +} + +function updateFormVisibility( + methodSelect: HTMLSelectElement, + modeSelect: HTMLSelectElement, + targetGroup: HTMLDivElement, + targetLabel: HTMLLabelElement, + targetInput: HTMLInputElement, + filterGroup: HTMLDivElement, + filterTextarea: HTMLTextAreaElement, + configGroup: HTMLDivElement, + configTextarea: HTMLTextAreaElement, +): void { + const method = methodSelect.value; + const supportsTypedMode = methodSupportsTypedMode(method); + + modeSelect.disabled = !supportsTypedMode; + if (!supportsTypedMode) { + modeSelect.value = "typed"; + } + + targetGroup.style.display = methodNeedsTarget(method) ? "" : "none"; + filterGroup.style.display = methodNeedsFilter(method) ? "" : "none"; + configGroup.style.display = methodNeedsConfig(method) ? "" : "none"; + + targetLabel.textContent = targetLabelForMethod(method); + + const preset = methodPreset(method, modeSelect.value); + targetInput.value = preset.target; + filterTextarea.value = preset.filterJson; + configTextarea.value = preset.configJson; +} + +function applyStatusToUi( + status: DemoWsStatusPayload, + statusBadge: HTMLSpanElement, + stateText: HTMLSpanElement, + endpointText: HTMLSpanElement, + subscriptionText: HTMLSpanElement, + connectButton: HTMLButtonElement, + disconnectButton: HTMLButtonElement, + subscribeButton: HTMLButtonElement, + unsubscribeButton: HTMLButtonElement, +): void { + setStatusBadge(statusBadge, status.connectionState); + stateText.textContent = status.connectionState; + endpointText.textContent = status.endpointName && status.endpointUrl + ? `${status.endpointName} (${status.endpointUrl})` + : "-"; + + if (status.currentSubscriptionId !== null) { + subscriptionText.textContent = + `${status.currentSubscribeMethod ?? "?"} / #${status.currentSubscriptionId} / ${status.currentNotificationMethod ?? "?"}`; + } else { + subscriptionText.textContent = "-"; + } + + const isConnected = status.connectionState === "Connected"; + const isBusy = status.connectionState === "Connecting" || status.connectionState === "Disconnecting"; + + connectButton.disabled = isConnected || isBusy; + disconnectButton.disabled = !isConnected && status.connectionState !== "Disconnecting"; + subscribeButton.disabled = !isConnected || isBusy; + unsubscribeButton.disabled = !isConnected || isBusy || status.currentSubscriptionId === null; +} + +document.addEventListener("DOMContentLoaded", async () => { + void takeoverConsole(); + const sidebarToggle = document.querySelector('#sidebarToggle'); + if (sidebarToggle) { + // restaurer l’état depuis localStorage + if (localStorage.getItem('sidebar-toggle') === 'true') { + document.body.classList.add('sidenav-toggled'); + } + + sidebarToggle.addEventListener('click', (event) => { + event.preventDefault(); + document.body.classList.toggle('sidenav-toggled'); + localStorage.setItem('sidebar-toggle', document.body.classList.contains('sidenav-toggled') ? 'true' : 'false'); + }); + } + + const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); + Array.from(tooltipTriggerList).map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)); + const toastElList = document.querySelectorAll('.toast'); + Array.from(toastElList).map(toastEl => new bootstrap.Toast(toastEl)); + const popoverTriggerList = document.querySelectorAll('[data-bs-toggle="popover"]'); + Array.from(popoverTriggerList).map(popoverTriggerEl => new bootstrap.Popover(popoverTriggerEl)); + + const gobackto = location.pathname + location.search; + + document.querySelectorAll('a[data-setlang]').forEach((a) => { + const href = a.getAttribute("href"); + if (!href) return; // pas de href => on ignore + + const url = new URL(href, location.origin); + url.searchParams.set("gobackto", gobackto); + + // conserve une URL relative (path + query) + a.setAttribute("href", url.pathname + "?" + url.searchParams.toString()); + }); + + const endpointSelect = document.querySelector("#demoWsEndpointSelect"); + const methodSelect = document.querySelector("#demoWsMethodSelect"); + const modeSelect = document.querySelector("#demoWsModeSelect"); + const targetGroup = document.querySelector("#demoWsTargetGroup"); + const targetLabel = document.querySelector("#demoWsTargetLabel"); + const targetInput = document.querySelector("#demoWsTargetInput"); + const filterGroup = document.querySelector("#demoWsFilterGroup"); + const filterTextarea = document.querySelector("#demoWsFilterTextarea"); + const configGroup = document.querySelector("#demoWsConfigGroup"); + const configTextarea = document.querySelector("#demoWsConfigTextarea"); + const statusBadge = document.querySelector("#demoWsStatusBadge"); + const stateText = document.querySelector("#demoWsStateText"); + const endpointText = document.querySelector("#demoWsEndpointText"); + const subscriptionText = document.querySelector("#demoWsSubscriptionText"); + const requestText = document.querySelector("#demoWsRequestText"); + const connectButton = document.querySelector("#demoWsConnectButton"); + const disconnectButton = document.querySelector("#demoWsDisconnectButton"); + const subscribeButton = document.querySelector("#demoWsSubscribeButton"); + const unsubscribeButton = document.querySelector("#demoWsUnsubscribeButton"); + const clearLogButton = document.querySelector("#demoWsClearLogButton"); + const logTextarea = document.querySelector("#demoWsLogTextarea"); + + if ( + !endpointSelect || !methodSelect || !modeSelect || !targetGroup || !targetLabel || !targetInput || + !filterGroup || !filterTextarea || !configGroup || !configTextarea || !statusBadge || + !stateText || !endpointText || !subscriptionText || !requestText || !connectButton || + !disconnectButton || !subscribeButton || !unsubscribeButton || !clearLogButton || !logTextarea + ) { + trace("demo_ws UI controls not found"); + return; + } + + let unlistenLogEvent: UnlistenFn | null = null; + let unlistenStatusEvent: UnlistenFn | null = null; + + try { + unlistenLogEvent = await listen("demo-ws-log", (event) => { + appendLogLine(logTextarea, event.payload); + }); + + unlistenStatusEvent = await listen("demo-ws-status", (event) => { + applyStatusToUi( + event.payload, + statusBadge, + stateText, + endpointText, + subscriptionText, + connectButton, + disconnectButton, + subscribeButton, + unsubscribeButton, + ); + }); + } catch (error) { + appendLogLine(logTextarea, `[ui] event listen error: ${String(error)}`); + } + + try { + const endpoints = await invoke("demo_ws_list_endpoints"); + + endpointSelect.innerHTML = ""; + for (const endpoint of endpoints) { + const option = document.createElement("option"); + option.value = endpoint.name; + option.textContent = `${endpoint.name} — ${endpoint.provider} — ${endpoint.resolvedUrl}`; + option.disabled = !endpoint.enabled; + endpointSelect.appendChild(option); + } + } catch (error) { + appendLogLine(logTextarea, `[ui] endpoint list error: ${String(error)}`); + } + + updateFormVisibility( + methodSelect, + modeSelect, + targetGroup, + targetLabel, + targetInput, + filterGroup, + filterTextarea, + configGroup, + configTextarea, + ); + + methodSelect.addEventListener("change", () => { + updateFormVisibility( + methodSelect, + modeSelect, + targetGroup, + targetLabel, + targetInput, + filterGroup, + filterTextarea, + configGroup, + configTextarea, + ); + }); + + modeSelect.addEventListener("change", () => { + updateFormVisibility( + methodSelect, + modeSelect, + targetGroup, + targetLabel, + targetInput, + filterGroup, + filterTextarea, + configGroup, + configTextarea, + ); + }); + + try { + const status = await invoke("demo_ws_get_status"); + applyStatusToUi( + status, + statusBadge, + stateText, + endpointText, + subscriptionText, + connectButton, + disconnectButton, + subscribeButton, + unsubscribeButton, + ); + } catch (error) { + appendLogLine(logTextarea, `[ui] initial status error: ${String(error)}`); + } + + appendLogLine(logTextarea, "[ui] demo_ws window loaded"); + + connectButton.addEventListener("click", async () => { + try { + const status = await invoke("demo_ws_connect", { + endpointName: endpointSelect.value, + }); + + applyStatusToUi( + status, + statusBadge, + stateText, + endpointText, + subscriptionText, + connectButton, + disconnectButton, + subscribeButton, + unsubscribeButton, + ); + } catch (error) { + appendLogLine(logTextarea, `[ui] connect error: ${String(error)}`); + } + }); + + disconnectButton.addEventListener("click", async () => { + try { + const status = await invoke("demo_ws_disconnect"); + + applyStatusToUi( + status, + statusBadge, + stateText, + endpointText, + subscriptionText, + connectButton, + disconnectButton, + subscribeButton, + unsubscribeButton, + ); + } catch (error) { + appendLogLine(logTextarea, `[ui] disconnect error: ${String(error)}`); + } + }); + + subscribeButton.addEventListener("click", async () => { + const request: DemoWsSubscribeRequest = { + method: methodSelect.value, + mode: modeSelect.value, + target: targetInput.value.trim() === "" ? null : targetInput.value.trim(), + filterJson: filterTextarea.value.trim() === "" ? null : filterTextarea.value.trim(), + configJson: configTextarea.value.trim() === "" ? null : configTextarea.value.trim(), + }; + + try { + const requestId = await invoke("demo_ws_subscribe", { request }); + requestText.textContent = `request_id=${requestId}`; + appendLogLine(logTextarea, `[ui] subscribe request sent: request_id=${requestId}`); + } catch (error) { + appendLogLine(logTextarea, `[ui] subscribe error: ${String(error)}`); + } + }); + + unsubscribeButton.addEventListener("click", async () => { + try { + const requestId = await invoke("demo_ws_unsubscribe_current"); + requestText.textContent = `unsubscribe_request_id=${requestId}`; + appendLogLine(logTextarea, `[ui] unsubscribe request sent: request_id=${requestId}`); + } catch (error) { + appendLogLine(logTextarea, `[ui] unsubscribe error: ${String(error)}`); + } + }); + + clearLogButton.addEventListener("click", () => { + logTextarea.value = ""; + }); + + window.addEventListener("beforeunload", () => { + if (unlistenLogEvent) { + unlistenLogEvent(); + } + + if (unlistenStatusEvent) { + unlistenStatusEvent(); + } + }); + + trace("demo_ws window loaded"); +}); \ No newline at end of file diff --git a/kb_app/frontend/ts/main.ts b/kb_app/frontend/ts/main.ts index 2505c7e..6110719 100644 --- a/kb_app/frontend/ts/main.ts +++ b/kb_app/frontend/ts/main.ts @@ -4,51 +4,17 @@ import * as bootstrap from "bootstrap"; import "simplebar"; import ResizeObserver from "resize-observer-polyfill"; import { invoke } from "@tauri-apps/api/core"; -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; -//import { error } from "@fltsci/tauri-plugin-tracing"; -//import { info } from "@fltsci/tauri-plugin-tracing"; import { trace, takeoverConsole } from "@fltsci/tauri-plugin-tracing"; (window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap; (window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver; -function appendLogLine(textarea: HTMLTextAreaElement, line: string): void { - const now = new Date(); - const timestamp = now.toLocaleTimeString("fr-CH", { hour12: false }); - textarea.value += `[${timestamp}] ${line}\n`; - textarea.scrollTop = textarea.scrollHeight; -} - -function setRunningState( - isRunning: boolean, - statusBadge: HTMLSpanElement, - connectButton: HTMLButtonElement, - disconnectButton: HTMLButtonElement, -): void { - if (isRunning) { - statusBadge.textContent = "Connected"; - statusBadge.className = "badge text-bg-success"; - connectButton.disabled = true; - disconnectButton.disabled = false; - return; +async function openDemoWsWindow(): Promise { + try { + await invoke("open_demo_ws_window"); + } catch (error) { + console.error("open_demo_ws_window failed:", error); } - - statusBadge.textContent = "Disconnected"; - statusBadge.className = "badge text-bg-secondary"; - connectButton.disabled = false; - disconnectButton.disabled = true; -} - -function setBusyState( - label: string, - statusBadge: HTMLSpanElement, - connectButton: HTMLButtonElement, - disconnectButton: HTMLButtonElement, -): void { - statusBadge.textContent = label; - statusBadge.className = "badge text-bg-warning"; - connectButton.disabled = true; - disconnectButton.disabled = true; } document.addEventListener("DOMContentLoaded", async () => { void takeoverConsole(); @@ -86,60 +52,20 @@ document.addEventListener("DOMContentLoaded", async () => { a.setAttribute("href", url.pathname + "?" + url.searchParams.toString()); }); - const connectButton = document.querySelector("#wsConnectButton"); - const disconnectButton = document.querySelector("#wsDisconnectButton"); - const statusBadge = document.querySelector("#wsStatusBadge"); - const logTextarea = document.querySelector("#wsLogTextarea"); + const openDemoWsButton = document.querySelector("#openDemoWsButton"); + const openDemoWsButtonSecondary = document.querySelector("#openDemoWsButtonSecondary"); - if (!connectButton || !disconnectButton || !statusBadge || !logTextarea) { - trace("main UI controls not found"); - return; - } - - let unlistenLogEvent: UnlistenFn | null = null; - - try { - unlistenLogEvent = await listen("kb-log", (event) => { - appendLogLine(logTextarea, event.payload); + if (openDemoWsButton) { + openDemoWsButton.addEventListener("click", () => { + void openDemoWsWindow(); }); - } catch (error) { - appendLogLine(logTextarea, `[ui] event listen error: ${String(error)}`); } - setRunningState(false, statusBadge, connectButton, disconnectButton); - appendLogLine(logTextarea, "[ui] main window loaded"); - - connectButton.addEventListener("click", async () => { - setBusyState("Starting", statusBadge, connectButton, disconnectButton); - - try { - const startedCount = await invoke("start_ws_clients"); - appendLogLine(logTextarea, `[ui] started ${startedCount} websocket client(s)`); - setRunningState(true, statusBadge, connectButton, disconnectButton); - } catch (error) { - appendLogLine(logTextarea, `[ui] start error: ${String(error)}`); - setRunningState(false, statusBadge, connectButton, disconnectButton); - } - }); - - disconnectButton.addEventListener("click", async () => { - setBusyState("Stopping", statusBadge, connectButton, disconnectButton); - - try { - const stoppedCount = await invoke("stop_ws_clients"); - appendLogLine(logTextarea, `[ui] stopped ${stoppedCount} websocket client(s)`); - setRunningState(false, statusBadge, connectButton, disconnectButton); - } catch (error) { - appendLogLine(logTextarea, `[ui] stop error: ${String(error)}`); - setRunningState(true, statusBadge, connectButton, disconnectButton); - } - }); - - window.addEventListener("beforeunload", () => { - if (unlistenLogEvent) { - unlistenLogEvent(); - } - }); + if (openDemoWsButtonSecondary) { + openDemoWsButtonSecondary.addEventListener("click", () => { + void openDemoWsWindow(); + }); + } trace("window loaded"); diff --git a/kb_app/gen/schemas/capabilities.json b/kb_app/gen/schemas/capabilities.json index 63c9e4a..f3ac6da 100644 --- a/kb_app/gen/schemas/capabilities.json +++ b/kb_app/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main","splash"],"permissions":["core:default","tracing:default"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main","splash","demo_ws"],"permissions":["core:default","tracing:default"]}} \ No newline at end of file diff --git a/kb_app/src/demo_ws.rs b/kb_app/src/demo_ws.rs new file mode 100644 index 0000000..96e38c7 --- /dev/null +++ b/kb_app/src/demo_ws.rs @@ -0,0 +1,848 @@ +// file: kb_app/src/demo_ws.rs + +//! Demo WebSocket window commands and runtime state. +//! +//! This module isolates the manual WebSocket subscription test bench from the +//! main application window. + +use tauri::Emitter; +use tauri::Manager; + +/// Endpoint summary sent to the demo frontend. +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct KbDemoWsEndpointSummary { + name: std::string::String, + resolved_url: std::string::String, + provider: std::string::String, + enabled: bool, + roles: std::vec::Vec, +} + +/// Current demo window runtime status. +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct KbDemoWsStatusPayload { + connection_state: std::string::String, + endpoint_name: std::option::Option, + endpoint_url: std::option::Option, + current_subscription_id: std::option::Option, + current_subscribe_method: std::option::Option, + current_unsubscribe_method: std::option::Option, + current_notification_method: std::option::Option, +} + +/// Subscribe request sent by the demo frontend. +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct KbDemoWsSubscribeRequest { + method: std::string::String, + mode: std::string::String, + target: std::option::Option, + filter_json: std::option::Option, + config_json: std::option::Option, +} + +/// Runtime state for the demo websocket window. +#[derive(Debug)] +pub(crate) struct KbDemoWsRuntimeState { + client: std::option::Option, + relay_task: std::option::Option>, + keepalive_task: std::option::Option>, + endpoint_name: std::option::Option, + endpoint_url: std::option::Option, + connection_state: kb_lib::KbConnectionState, + current_subscription: std::option::Option, +} + +impl KbDemoWsRuntimeState { + /// Creates a new empty runtime state. + pub(crate) fn new() -> Self { + Self { + client: None, + relay_task: None, + keepalive_task: None, + endpoint_name: None, + endpoint_url: None, + connection_state: kb_lib::KbConnectionState::Disconnected, + current_subscription: None, + } + } + + fn to_status_payload(&self) -> KbDemoWsStatusPayload { + let current_subscription_id = self + .current_subscription + .as_ref() + .map(|subscription| subscription.subscription_id); + let current_subscribe_method = self + .current_subscription + .as_ref() + .map(|subscription| subscription.subscribe_method.clone()); + let current_unsubscribe_method = self + .current_subscription + .as_ref() + .map(|subscription| subscription.unsubscribe_method.clone()); + let current_notification_method = self + .current_subscription + .as_ref() + .map(|subscription| subscription.notification_method.clone()); + KbDemoWsStatusPayload { + connection_state: kb_connection_state_to_string(self.connection_state), + endpoint_name: self.endpoint_name.clone(), + endpoint_url: self.endpoint_url.clone(), + current_subscription_id, + current_subscribe_method, + current_unsubscribe_method, + current_notification_method, + } + } + + fn clear(&mut self) { + self.client = None; + self.relay_task = None; + self.keepalive_task = None; + self.endpoint_name = None; + self.endpoint_url = None; + self.connection_state = kb_lib::KbConnectionState::Disconnected; + self.current_subscription = None; + } +} + +/// Shows and focuses the preconfigured `demo_ws` window. +#[tauri::command] +pub(crate) fn open_demo_ws_window(app_handle: tauri::AppHandle) -> Result<(), std::string::String> { + let existing_window_option = app_handle.get_webview_window("demo_ws"); + let demo_window = match existing_window_option { + Some(demo_window) => demo_window, + None => { + let builder = tauri::WebviewWindowBuilder::new( + &app_handle, + "demo_ws", + tauri::WebviewUrl::App("demo_ws.html".into()), + ) + .title("Demo Ws Subscribe") + .inner_size(1400.0, 768.0) + .min_inner_size(800.0, 600.0) + .center() + .visible(true) + .transparent(false) + .decorations(true); + let build_result = builder.build(); + match build_result { + Ok(window) => window, + Err(error) => { + return Err(format!("cannot create demo_ws window: {error:?}")); + } + } + } + }; + let show_result = demo_window.show(); + if let Err(error) = show_result { + return Err(format!("cannot show demo_ws window: {error:?}")); + } + let focus_result = demo_window.set_focus(); + if let Err(error) = focus_result { + return Err(format!("cannot focus demo_ws window: {error:?}")); + } + Ok(()) +} + +/// Returns the list of configured websocket endpoints. +#[tauri::command] +pub(crate) async fn demo_ws_list_endpoints( + state: tauri::State<'_, crate::KbAppState>, +) -> Result, std::string::String> { + let mut endpoints = std::vec::Vec::new(); + for endpoint in &state.config.solana.ws_endpoints { + let resolved_url_result = endpoint.resolved_url(); + let resolved_url = match resolved_url_result { + Ok(resolved_url) => resolved_url, + Err(_) => endpoint.url.clone(), + }; + endpoints.push(KbDemoWsEndpointSummary { + name: endpoint.name.clone(), + resolved_url, + provider: endpoint.provider.clone(), + enabled: endpoint.enabled, + roles: endpoint.roles.clone(), + }); + } + Ok(endpoints) +} + +/// Returns the current demo websocket runtime status. +#[tauri::command] +pub(crate) async fn demo_ws_get_status( + state: tauri::State<'_, crate::KbAppState>, +) -> Result { + let runtime_guard = state.demo_ws_runtime.lock().await; + Ok(runtime_guard.to_status_payload()) +} + +/// Connects the demo websocket runtime to the selected endpoint. +#[tauri::command] +pub(crate) async fn demo_ws_connect( + app_handle: tauri::AppHandle, + state: tauri::State<'_, crate::KbAppState>, + endpoint_name: std::string::String, +) -> Result { + let endpoint_option = state.config.find_ws_endpoint(&endpoint_name); + let endpoint = match endpoint_option { + Some(endpoint) => endpoint.clone(), + None => { + return Err(format!("unknown websocket endpoint '{}'", endpoint_name)); + } + }; + let runtime_arc = state.demo_ws_runtime.clone(); + { + let runtime_guard = runtime_arc.lock().await; + if runtime_guard.client.is_some() { + return Err("demo websocket client is already connected or connecting".to_string()); + } + } + let client_result = kb_lib::WsClient::new(endpoint.clone()); + let client = match client_result { + Ok(client) => client, + Err(error) => { + return Err(format!("cannot create websocket client: {error}")); + } + }; + { + let mut runtime_guard = runtime_arc.lock().await; + runtime_guard.endpoint_name = Some(endpoint.name.clone()); + runtime_guard.endpoint_url = Some(client.endpoint_url().to_string()); + runtime_guard.connection_state = kb_lib::KbConnectionState::Connecting; + runtime_guard.current_subscription = None; + } + kb_emit_demo_ws_status(&app_handle, &runtime_arc).await; + kb_emit_demo_ws_log( + &app_handle, + &format!( + "[demo] connecting endpoint '{}' ({})", + endpoint.name, + client.endpoint_url() + ), + ); + let mut event_receiver = client.subscribe_events(); + let relay_runtime = runtime_arc.clone(); + let relay_app_handle = app_handle.clone(); + let relay_task = tauri::async_runtime::spawn(async move { + loop { + let recv_result = event_receiver.recv().await; + match recv_result { + Ok(event) => { + kb_apply_demo_ws_event_to_runtime(&relay_runtime, &event).await; + kb_emit_demo_ws_log(&relay_app_handle, &kb_format_demo_ws_event(&event)); + kb_emit_demo_ws_status(&relay_app_handle, &relay_runtime).await; + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + kb_emit_demo_ws_log( + &relay_app_handle, + &format!( + "[demo] event receiver lagged and skipped {} event(s)", + skipped + ), + ); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + break; + } + } + } + }); + let keepalive_client = client.clone(); + let keepalive_app_handle = app_handle.clone(); + let keepalive_task = tauri::async_runtime::spawn(async move { + kb_demo_ws_keepalive_loop(&keepalive_app_handle, &keepalive_client).await; + }); + let connect_result = client.connect().await; + if let Err(error) = connect_result { + relay_task.abort(); + keepalive_task.abort(); + { + let mut runtime_guard = runtime_arc.lock().await; + runtime_guard.clear(); + } + kb_emit_demo_ws_status(&app_handle, &runtime_arc).await; + return Err(format!("cannot connect websocket client: {error}")); + } + { + let mut runtime_guard = runtime_arc.lock().await; + runtime_guard.client = Some(client); + runtime_guard.relay_task = Some(relay_task); + runtime_guard.keepalive_task = Some(keepalive_task); + runtime_guard.endpoint_name = Some(endpoint.name.clone()); + runtime_guard.endpoint_url = Some(endpoint.resolved_url().unwrap_or(endpoint.url)); + runtime_guard.connection_state = kb_lib::KbConnectionState::Connected; + } + kb_emit_demo_ws_status(&app_handle, &runtime_arc).await; + let runtime_guard = runtime_arc.lock().await; + Ok(runtime_guard.to_status_payload()) +} + +/// Disconnects the demo websocket runtime. +#[tauri::command] +pub(crate) async fn demo_ws_disconnect( + app_handle: tauri::AppHandle, + state: tauri::State<'_, crate::KbAppState>, +) -> Result { + let runtime_arc = state.demo_ws_runtime.clone(); + { + let mut runtime_guard = runtime_arc.lock().await; + runtime_guard.connection_state = kb_lib::KbConnectionState::Disconnecting; + } + kb_emit_demo_ws_status(&app_handle, &runtime_arc).await; + let (client_option, relay_task_option, keepalive_task_option) = { + let mut runtime_guard = runtime_arc.lock().await; + ( + runtime_guard.client.take(), + runtime_guard.relay_task.take(), + runtime_guard.keepalive_task.take(), + ) + }; + if let Some(keepalive_task) = keepalive_task_option { + keepalive_task.abort(); + } + if let Some(client) = &client_option { + let disconnect_result = client.disconnect().await; + if let Err(error) = disconnect_result { + kb_emit_demo_ws_log( + &app_handle, + &format!("[demo] disconnect error: {}", error), + ); + } + } + if let Some(relay_task) = relay_task_option { + relay_task.abort(); + } + { + let mut runtime_guard = runtime_arc.lock().await; + runtime_guard.clear(); + } + kb_emit_demo_ws_status(&app_handle, &runtime_arc).await; + let runtime_guard = runtime_arc.lock().await; + Ok(runtime_guard.to_status_payload()) +} + +/// Sends one demo subscription request. +#[tauri::command] +pub(crate) async fn demo_ws_subscribe( + state: tauri::State<'_, crate::KbAppState>, + request: KbDemoWsSubscribeRequest, +) -> Result { + let client_option = { + let runtime_guard = state.demo_ws_runtime.lock().await; + if runtime_guard.current_subscription.is_some() { + return Err("a subscription is already active, unsubscribe it first".to_string()); + } + runtime_guard.client.clone() + }; + let client = match client_option { + Some(client) => client, + None => { + return Err("demo websocket client is not connected".to_string()); + } + }; + + kb_execute_demo_ws_subscribe(&client, &request).await +} + +/// Sends one unsubscribe request for the current active subscription. +#[tauri::command] +pub(crate) async fn demo_ws_unsubscribe_current( + state: tauri::State<'_, crate::KbAppState>, +) -> Result { + let (client_option, subscription_option) = { + let runtime_guard = state.demo_ws_runtime.lock().await; + ( + runtime_guard.client.clone(), + runtime_guard.current_subscription.clone(), + ) + }; + let client = match client_option { + Some(client) => client, + None => { + return Err("demo websocket client is not connected".to_string()); + } + }; + let subscription = match subscription_option { + Some(subscription) => subscription, + None => { + return Err("no active subscription is currently registered".to_string()); + } + }; + let params = vec![serde_json::Value::from(subscription.subscription_id)]; + let send_result = client + .send_json_rpc_request(subscription.unsubscribe_method.clone(), params) + .await; + match send_result { + Ok(request_id) => Ok(request_id), + Err(error) => Err(format!("cannot send unsubscribe request: {error}")), + } +} + +async fn kb_execute_demo_ws_subscribe( + client: &kb_lib::WsClient, + request: &KbDemoWsSubscribeRequest, +) -> Result { + let method = request.method.trim(); + let mode = request.mode.trim(); + if method == "account" { + let target = kb_required_target(request, "account pubkey")?; + if mode == "typed" { + let config = kb_parse_optional_json_typed::< + solana_rpc_client_api::config::RpcAccountInfoConfig, + >(&request.config_json, "account typed config")?; + let result = client.account_subscribe_typed(target, config).await; + return result.map_err(|error| format!("account typed subscribe failed: {error}")); + } + let config = kb_parse_optional_json_value(&request.config_json, "account raw config")?; + let result = client.account_subscribe_raw(target, config).await; + return result.map_err(|error| format!("account raw subscribe failed: {error}")); + } + if method == "block" { + if mode == "typed" { + let filter = kb_parse_required_json_typed::< + solana_rpc_client_api::config::RpcBlockSubscribeFilter, + >(&request.filter_json, "block typed filter")?; + + let config = kb_parse_optional_json_typed::< + solana_rpc_client_api::config::RpcBlockSubscribeConfig, + >(&request.config_json, "block typed config")?; + let result = client.block_subscribe_typed(filter, config).await; + return result.map_err(|error| format!("block typed subscribe failed: {error}")); + } + let filter = kb_parse_required_json_value(&request.filter_json, "block raw filter")?; + let config = kb_parse_optional_json_value(&request.config_json, "block raw config")?; + let result = client.block_subscribe_raw(filter, config).await; + return result.map_err(|error| format!("block raw subscribe failed: {error}")); + } + if method == "logs" { + if mode == "typed" { + let filter = kb_parse_required_json_typed::< + solana_rpc_client_api::config::RpcTransactionLogsFilter, + >(&request.filter_json, "logs typed filter")?; + let config = kb_parse_optional_json_typed::< + solana_rpc_client_api::config::RpcTransactionLogsConfig, + >(&request.config_json, "logs typed config")?; + let result = client.logs_subscribe_typed(filter, config).await; + return result.map_err(|error| format!("logs typed subscribe failed: {error}")); + } + let filter = kb_parse_required_json_value(&request.filter_json, "logs raw filter")?; + let config = kb_parse_optional_json_value(&request.config_json, "logs raw config")?; + let result = client.logs_subscribe_raw(filter, config).await; + return result.map_err(|error| format!("logs raw subscribe failed: {error}")); + } + if method == "program" { + let target = kb_required_target(request, "program id")?; + if mode == "typed" { + let config = kb_parse_optional_json_typed::< + solana_rpc_client_api::config::RpcProgramAccountsConfig, + >(&request.config_json, "program typed config")?; + let result = client.program_subscribe_typed(target, config).await; + return result.map_err(|error| format!("program typed subscribe failed: {error}")); + } + let config = kb_parse_optional_json_value(&request.config_json, "program raw config")?; + let result = client.program_subscribe_raw(target, config).await; + return result.map_err(|error| format!("program raw subscribe failed: {error}")); + } + if method == "root" { + let result = client.root_subscribe().await; + return result.map_err(|error| format!("root subscribe failed: {error}")); + } + if method == "signature" { + let target = kb_required_target(request, "signature")?; + if mode == "typed" { + let config = kb_parse_optional_json_typed::< + solana_rpc_client_api::config::RpcSignatureSubscribeConfig, + >(&request.config_json, "signature typed config")?; + let result = client.signature_subscribe_typed(target, config).await; + return result.map_err(|error| format!("signature typed subscribe failed: {error}")); + } + let config = kb_parse_optional_json_value(&request.config_json, "signature raw config")?; + let result = client.signature_subscribe_raw(target, config).await; + return result.map_err(|error| format!("signature raw subscribe failed: {error}")); + } + if method == "slot" { + let result = client.slot_subscribe().await; + return result.map_err(|error| format!("slot subscribe failed: {error}")); + } + if method == "slotsUpdates" { + let result = client.slots_updates_subscribe().await; + return result.map_err(|error| format!("slotsUpdates subscribe failed: {error}")); + } + if method == "vote" { + let result = client.vote_subscribe().await; + return result.map_err(|error| format!("vote subscribe failed: {error}")); + } + Err(format!("unsupported demo subscribe method '{}'", method)) +} + +fn kb_required_target( + request: &KbDemoWsSubscribeRequest, + label: &str, +) -> Result { + let target_option = request.target.as_ref(); + let target = match target_option { + Some(target) => target.trim(), + None => { + return Err(format!("{} is required", label)); + } + }; + if target.is_empty() { + return Err(format!("{} is required", label)); + } + Ok(target.to_string()) +} + +fn kb_parse_optional_json_value( + input: &std::option::Option, + label: &str, +) -> Result, std::string::String> { + match input { + Some(input) => { + if input.trim().is_empty() { + return Ok(None); + } + let parse_result = serde_json::from_str::(input); + match parse_result { + Ok(value) => Ok(Some(value)), + Err(error) => Err(format!("cannot parse {}: {}", label, error)), + } + } + None => Ok(None), + } +} + +fn kb_parse_required_json_value( + input: &std::option::Option, + label: &str, +) -> Result { + let input_option = input.as_ref(); + let input = match input_option { + Some(input) => input.trim(), + None => { + return Err(format!("{} is required", label)); + } + }; + if input.is_empty() { + return Err(format!("{} is required", label)); + } + let parse_result = serde_json::from_str::(input); + match parse_result { + Ok(value) => Ok(value), + Err(error) => Err(format!("cannot parse {}: {}", label, error)), + } +} + +fn kb_parse_optional_json_typed( + input: &std::option::Option, + label: &str, +) -> Result, std::string::String> +where + T: serde::de::DeserializeOwned, +{ + match input { + Some(input) => { + if input.trim().is_empty() { + return Ok(None); + } + let parse_result = serde_json::from_str::(input); + match parse_result { + Ok(value) => Ok(Some(value)), + Err(error) => Err(format!("cannot parse {}: {}", label, error)), + } + } + None => Ok(None), + } +} + +fn kb_parse_required_json_typed( + input: &std::option::Option, + label: &str, +) -> Result +where + T: serde::de::DeserializeOwned, +{ + let input_option = input.as_ref(); + let input = match input_option { + Some(input) => input.trim(), + None => { + return Err(format!("{} is required", label)); + } + }; + if input.is_empty() { + return Err(format!("{} is required", label)); + } + let parse_result = serde_json::from_str::(input); + match parse_result { + Ok(value) => Ok(value), + Err(error) => Err(format!("cannot parse {}: {}", label, error)), + } +} + +async fn kb_apply_demo_ws_event_to_runtime( + runtime_arc: &std::sync::Arc>, + event: &kb_lib::WsEvent, +) { + let mut runtime_guard = runtime_arc.lock().await; + match event { + kb_lib::WsEvent::Connected { + endpoint_name, + endpoint_url, + } => { + runtime_guard.connection_state = kb_lib::KbConnectionState::Connected; + runtime_guard.endpoint_name = Some(endpoint_name.clone()); + runtime_guard.endpoint_url = Some(endpoint_url.clone()); + } + kb_lib::WsEvent::SubscriptionRegistered { subscription, .. } => { + runtime_guard.current_subscription = Some(subscription.clone()); + } + kb_lib::WsEvent::SubscriptionUnregistered { + subscription_id, .. + } => { + let current_subscription_id = runtime_guard + .current_subscription + .as_ref() + .map(|subscription| subscription.subscription_id); + + if current_subscription_id == Some(*subscription_id) { + runtime_guard.current_subscription = None; + } + } + kb_lib::WsEvent::Disconnected { .. } => { + runtime_guard.client = None; + runtime_guard.relay_task = None; + runtime_guard.keepalive_task = None; + runtime_guard.connection_state = kb_lib::KbConnectionState::Disconnected; + runtime_guard.current_subscription = None; + } + _ => {} + } +} + +async fn kb_emit_demo_ws_status( + app_handle: &tauri::AppHandle, + runtime_arc: &std::sync::Arc>, +) { + let status_payload = { + let runtime_guard = runtime_arc.lock().await; + runtime_guard.to_status_payload() + }; + let demo_window_option = app_handle.get_webview_window("demo_ws"); + let demo_window = match demo_window_option { + Some(demo_window) => demo_window, + None => { + return; + } + }; + let emit_result = demo_window.emit("demo-ws-status", status_payload); + if let Err(error) = emit_result { + tracing::error!("error emitting demo-ws-status: {error:?}"); + } +} + +fn kb_emit_demo_ws_log(app_handle: &tauri::AppHandle, line: &str) { + tracing::debug!("{}", line); + let demo_window_option = app_handle.get_webview_window("demo_ws"); + let demo_window = match demo_window_option { + Some(demo_window) => demo_window, + None => { + return; + } + }; + let emit_result = demo_window.emit("demo-ws-log", line.to_string()); + if let Err(error) = emit_result { + tracing::error!("error emitting demo-ws-log: {error:?}"); + } +} + +fn kb_connection_state_to_string(state: kb_lib::KbConnectionState) -> std::string::String { + match state { + kb_lib::KbConnectionState::Disconnected => "Disconnected".to_string(), + kb_lib::KbConnectionState::Connecting => "Connecting".to_string(), + kb_lib::KbConnectionState::Connected => "Connected".to_string(), + kb_lib::KbConnectionState::Disconnecting => "Disconnecting".to_string(), + } +} + +fn kb_format_demo_ws_event(event: &kb_lib::WsEvent) -> std::string::String { + match event { + kb_lib::WsEvent::Connected { + endpoint_name, + endpoint_url, + } => { + format!("[demo:{endpoint_name}] connected to {endpoint_url}") + } + kb_lib::WsEvent::TextMessage { + endpoint_name, + text, + } => { + format!( + "[demo:{endpoint_name}] text: {}", + kb_shorten_log_text(text, 1200) + ) + } + kb_lib::WsEvent::JsonRpcMessage { + endpoint_name, + message, + } => { + let rendered = format!("{message:?}"); + format!( + "[demo:{endpoint_name}] json-rpc: {}", + kb_shorten_log_text(&rendered, 1800) + ) + } + kb_lib::WsEvent::JsonRpcParseError { + endpoint_name, + text, + error, + } => { + format!( + "[demo:{endpoint_name}] json-rpc parse error: {} | raw={}", + error, + kb_shorten_log_text(text, 1200) + ) + } + kb_lib::WsEvent::SubscriptionRegistered { + endpoint_name, + subscription, + } => { + format!( + "[demo:{endpoint_name}] subscription registered request_id={} subscription_id={} subscribe={} unsubscribe={} notification={}", + subscription.request_id, + subscription.subscription_id, + subscription.subscribe_method, + subscription.unsubscribe_method, + subscription.notification_method + ) + } + kb_lib::WsEvent::SubscriptionNotification { + endpoint_name, + subscription, + notification, + method_matches_registry, + } => { + let result_text = notification.params.result.to_string(); + let typed_suffix = match kb_lib::parse_kb_solana_ws_typed_notification(notification) { + Ok(typed_notification) => { + let rendered = format!("{typed_notification:?}"); + format!(" | typed={}", kb_shorten_log_text(&rendered, 1200)) + } + Err(_) => std::string::String::new(), + }; + format!( + "[demo:{endpoint_name}] tracked notification subscription_id={} method={} expected={} matches={} result={}{}", + subscription.subscription_id, + notification.method, + subscription.notification_method, + method_matches_registry, + kb_shorten_log_text(&result_text, 1600), + typed_suffix + ) + } + kb_lib::WsEvent::JsonRpcNotificationWithoutSubscription { + endpoint_name, + notification, + } => { + let result_text = notification.params.result.to_string(); + let typed_suffix = match kb_lib::parse_kb_solana_ws_typed_notification(notification) { + Ok(typed_notification) => { + let rendered = format!("{typed_notification:?}"); + format!(" | typed={}", kb_shorten_log_text(&rendered, 1200)) + } + Err(_) => std::string::String::new(), + }; + format!( + "[demo:{endpoint_name}] untracked notification method={} subscription={} result={}{}", + notification.method, + notification.params.subscription, + kb_shorten_log_text(&result_text, 1600), + typed_suffix + ) + } + kb_lib::WsEvent::SubscriptionUnregistered { + endpoint_name, + subscription_id, + unsubscribe_method, + was_active, + } => { + format!( + "[demo:{endpoint_name}] subscription unregistered subscription_id={} unsubscribe_method={} was_active={}", + subscription_id, unsubscribe_method, was_active + ) + } + kb_lib::WsEvent::BinaryMessage { + endpoint_name, + data, + } => { + format!( + "[demo:{endpoint_name}] binary message ({} bytes)", + data.len() + ) + } + kb_lib::WsEvent::Ping { + endpoint_name, + data, + } => { + format!("[demo:{endpoint_name}] ping ({} bytes)", data.len()) + } + kb_lib::WsEvent::Pong { + endpoint_name, + data, + } => { + format!("[demo:{endpoint_name}] pong ({} bytes)", data.len()) + } + kb_lib::WsEvent::CloseReceived { + endpoint_name, + code, + reason, + } => { + format!( + "[demo:{endpoint_name}] close received code={:?} reason={:?}", + code, reason + ) + } + kb_lib::WsEvent::Disconnected { endpoint_name } => { + format!("[demo:{endpoint_name}] disconnected") + } + kb_lib::WsEvent::Error { + endpoint_name, + error, + } => { + format!("[demo:{endpoint_name}] error: {error}") + } + } +} + +fn kb_shorten_log_text(input: &str, max_chars: usize) -> std::string::String { + let char_count = input.chars().count(); + if char_count <= max_chars { + return input.to_string(); + } + + let shortened: std::string::String = input.chars().take(max_chars).collect(); + format!("{shortened} …[truncated {} chars]", char_count - max_chars) +} + +async fn kb_demo_ws_keepalive_loop(app_handle: &tauri::AppHandle, client: &kb_lib::WsClient) { + loop { + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + let state = client.connection_state().await; + if state != kb_lib::KbConnectionState::Connected { + break; + } + let send_result = client.send_ping(b"demo-keepalive".to_vec()).await; + if let Err(error) = send_result { + kb_emit_demo_ws_log( + app_handle, + &format!( + "[demo:{}] keepalive ping failed: {}", + client.endpoint_name(), + error + ), + ); + break; + } + } +} diff --git a/kb_app/src/lib.rs b/kb_app/src/lib.rs index 7f47038..734890f 100644 --- a/kb_app/src/lib.rs +++ b/kb_app/src/lib.rs @@ -10,6 +10,7 @@ #![warn(missing_docs)] mod splash; +mod demo_ws; pub use crate::splash::SplashOrder; use tauri::Emitter; @@ -34,6 +35,7 @@ impl KbWsRuntimeState { struct KbAppState { config: kb_lib::KbConfig, ws_runtime: tokio::sync::Mutex, + demo_ws_runtime: std::sync::Arc>, } /// Runs the desktop application. @@ -73,12 +75,24 @@ pub fn run() { let app_state = KbAppState { config: config.clone(), ws_runtime: tokio::sync::Mutex::new(KbWsRuntimeState::new()), + demo_ws_runtime: std::sync::Arc::new(tokio::sync::Mutex::new( + crate::demo_ws::KbDemoWsRuntimeState::new(), + )), }; let tracing_builder = tauri_plugin_tracing::Builder::new(); let mut tauri_builder = tauri::Builder::default(); tauri_builder = tauri_builder.manage(app_state); - tauri_builder = - tauri_builder.invoke_handler(tauri::generate_handler![start_ws_clients, stop_ws_clients]); + tauri_builder = tauri_builder.invoke_handler(tauri::generate_handler![ + start_ws_clients, + stop_ws_clients, + crate::demo_ws::open_demo_ws_window, + crate::demo_ws::demo_ws_list_endpoints, + crate::demo_ws::demo_ws_get_status, + crate::demo_ws::demo_ws_connect, + crate::demo_ws::demo_ws_disconnect, + crate::demo_ws::demo_ws_subscribe, + crate::demo_ws::demo_ws_unsubscribe_current + ]); tauri_builder = tauri_builder.plugin(tracing_builder.build::()); tauri_builder = tauri_builder.setup(|app| { let app_handle = app.handle().clone(); diff --git a/kb_app/tauri.conf.json b/kb_app/tauri.conf.json index 10fafe7..59896c9 100644 --- a/kb_app/tauri.conf.json +++ b/kb_app/tauri.conf.json @@ -36,6 +36,19 @@ "visible": false, "transparent": false, "decorations": true + }, + { + "label": "demo_ws", + "url": "demo_ws.html", + "title": "Demo Ws Subscribe", + "width": 1400, + "height": 768, + "minWidth": 800, + "minHeight": 600, + "center": true, + "visible": false, + "transparent": false, + "decorations": true } ], "security": { diff --git a/kb_app/vite.config.ts b/kb_app/vite.config.ts index c3e1438..ab80038 100644 --- a/kb_app/vite.config.ts +++ b/kb_app/vite.config.ts @@ -21,7 +21,9 @@ export default defineConfig(() => ({ emptyOutDir: true, rollupOptions: { input: { - "main": normalizePath(resolve(__dirname, 'frontend/index.html')) + "main": normalizePath(resolve(__dirname, 'frontend/main.html')), + "splash": normalizePath(resolve(__dirname, 'frontend/splash.html')), + "demo_ws": normalizePath(resolve(__dirname, 'frontend/demo_ws.html')) }, output: { entryFileNames: 'js/[name]-[hash].js',