This commit is contained in:
2026-04-21 18:46:52 +02:00
parent dcee5c9447
commit e754cb63bf
14 changed files with 1691 additions and 200 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -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 dune clé API,
- variable denvironnement 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 larrêt propre,
- fermeture avec timeout.
Contraintes :
- aucune reconnexion implicite au départ,
- pas dusage 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 lapplication 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 dattente 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 dunsubscribe correspondants,
- premiers tests de validation des noms de méthodes.
### 6.7. Version `0.3.2` — Helpers typed et notifications typed
Objectif : sappuyer 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 lusage direct de `serde_json::Value`.
### 6.8. Version `0.3.3` — Distinction API typed / raw
Objectif : clarifier lAPI publique de `WsClient`.
Réalisé :
- suffixe `_raw` sur les helpers raw,
- conservation des helpers typed comme interface plus propre,
- préparation dune 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 dun 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 laffichage UI sous fort débit,
- limiter ou résumer les événements affichés côté fenêtre,
- conserver linté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 dendpoints 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 lapplication.
@@ -256,7 +302,7 @@ Objectif : commencer la détection utile pour lapplication.
- 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 daction.
@@ -300,7 +346,7 @@ Objectif : préparer la couche daction.
- préparation dordres 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 lanalyse à laction tout en gardant des garde-fous explicites.
@@ -312,7 +358,7 @@ Objectif : brancher lanalyse à laction tout en gardant des garde-fous exp
- confirmations explicites ou semi-automatiques,
- journaux dexé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 lUI sous fort débit,
4. préparer ensuite le transport HTTP générique,
5. poursuivre la structuration des connecteurs DEX.

View File

@@ -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

View File

@@ -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"
]
}

View File

@@ -0,0 +1,157 @@
<!-- file: kb_app/frontend/demo_ws.html -->
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Demo Ws Subscribe</title>
<link rel="stylesheet" href="sass/main.scss" />
</head>
<body class="bg-body-tertiary">
<header class="app-header">
<nav class="navbar navbar-expand-lg h-100 py-0 bg-light text-dark">
<div class="container my-0">
<a class="navbar-brand d-flex align-items-center" href="/">
<img alt="Logo" src="imgs/logo.png" class="app-logo" />
<span class="ps-2 fs-4 fw-bold text-primary font-logo">Khadhroony-BoBoBot</span>
</a>
<div class="ms-auto">
<span id="demoWsStatusBadge" class="badge text-bg-secondary">Disconnected</span>
</div>
</div>
</nav>
</header>
<main class="app-main">
<div class="osb-scrollable pt-1 pb-4" data-simplebar>
<div class="container py-4">
<div class="row g-4">
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-body">
<h1 class="h4 mb-3">Demo Ws Subscribe</h1>
<p class="text-body-secondary mb-0">
Fenêtre de test complète pour les souscriptions WebSocket Solana en mode raw ou typed.
</p>
</div>
</div>
</div>
<div class="col-12 col-xxl-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-body">
<h2 class="h5 mb-3">Connexion</h2>
<div class="mb-3">
<label for="demoWsEndpointSelect" class="form-label">Endpoint</label>
<select id="demoWsEndpointSelect" class="form-select"></select>
</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<button id="demoWsConnectButton" type="button" class="btn btn-success">Connect</button>
<button id="demoWsDisconnectButton" type="button" class="btn btn-danger">Disconnect</button>
</div>
<div class="small text-body-secondary">
<div><strong>State:</strong> <span id="demoWsStateText">Disconnected</span></div>
<div><strong>Endpoint:</strong> <span id="demoWsEndpointText">-</span></div>
</div>
<hr />
<h2 class="h5 mb-3">Souscription</h2>
<div class="mb-3">
<label for="demoWsMethodSelect" class="form-label">Méthode</label>
<select id="demoWsMethodSelect" class="form-select">
<option value="account">account</option>
<option value="block">block</option>
<option value="logs">logs</option>
<option value="program">program</option>
<option value="root">root</option>
<option value="signature">signature</option>
<option value="slot" selected>slot</option>
<option value="slotsUpdates">slotsUpdates</option>
<option value="vote">vote</option>
</select>
</div>
<div class="mb-3">
<label for="demoWsModeSelect" class="form-label">Mode</label>
<select id="demoWsModeSelect" class="form-select">
<option value="typed" selected>typed</option>
<option value="raw">raw</option>
</select>
</div>
<div id="demoWsTargetGroup" class="mb-3">
<label for="demoWsTargetInput" id="demoWsTargetLabel" class="form-label">Target</label>
<input id="demoWsTargetInput" type="text" class="form-control" spellcheck="false" />
</div>
<div id="demoWsFilterGroup" class="mb-3">
<label for="demoWsFilterTextarea" class="form-label">Filter JSON</label>
<textarea
id="demoWsFilterTextarea"
class="form-control font-monospace"
rows="5"
spellcheck="false"
></textarea>
</div>
<div id="demoWsConfigGroup" class="mb-3">
<label for="demoWsConfigTextarea" class="form-label">Config JSON</label>
<textarea
id="demoWsConfigTextarea"
class="form-control font-monospace"
rows="6"
spellcheck="false"
></textarea>
</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<button id="demoWsSubscribeButton" type="button" class="btn btn-primary">Subscribe</button>
<button id="demoWsUnsubscribeButton" type="button" class="btn btn-warning">Unsubscribe current</button>
<button id="demoWsClearLogButton" type="button" class="btn btn-outline-secondary">Clear log</button>
</div>
<div class="small text-body-secondary">
<div><strong>Current subscription:</strong> <span id="demoWsSubscriptionText">-</span></div>
<div><strong>Request:</strong> <span id="demoWsRequestText">-</span></div>
</div>
</div>
</div>
</div>
<div class="col-12 col-xxl-8">
<div class="card shadow-sm border-0 h-100">
<div class="card-body">
<h2 class="h5 mb-3">Logs</h2>
<textarea
id="demoWsLogTextarea"
class="form-control font-monospace"
rows="28"
readonly
spellcheck="false"
></textarea>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="app-footer bg-dark text-light">
<div class="container h-100 d-flex align-items-center">
<div class="row flex-grow-1 align-items-center">
<div class="col-12 col-md-6 text-center text-small my-1 my-md-0">
&copy; 2026 SASEDEV — Demo Ws
</div>
</div>
</div>
</footer>
<script type="module" src="ts/demo_ws.ts" defer></script>
</body>
</html>

View File

@@ -15,6 +15,12 @@
<img alt="Logo" src="imgs/logo.png" class="app-logo" />
<span class="ps-2 fs-4 fw-bold text-primary font-logo">Khadhroony-BoBoBot</span>
</a>
<div class="ms-auto d-flex align-items-center gap-2">
<button id="openDemoWsButton" type="button" class="btn btn-outline-primary">
Demo Ws
</button>
</div>
</div>
</nav>
</header>
@@ -31,30 +37,25 @@
<div class="col-12 col-xl-8">
<div class="card shadow-sm border-0 h-100">
<div class="card-body text-start">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-3">
<div>
<h3 class="h5 card-title mb-1">WebSocket transport</h3>
<p class="text-body-secondary mb-0">
Démarre ou arrête tous les endpoints WebSocket activés dans <code>config.json</code>.
</p>
</div>
<div class="d-flex align-items-center gap-2">
<span id="wsStatusBadge" class="badge text-bg-secondary">Disconnected</span>
<button id="wsConnectButton" class="btn btn-success" type="button">Start</button>
<button id="wsDisconnectButton" class="btn btn-danger" type="button" disabled>Stop</button>
</div>
<h3 class="h5 card-title mb-3">Desktop shell</h3>
<p class="text-body-secondary mb-3">
La fenêtre principale reste volontairement légère.
Les tests WebSocket manuels sont disponibles dans la fenêtre dédiée
<strong>Demo Ws</strong>.
</p>
<div class="d-flex flex-wrap gap-2">
<button id="openDemoWsButtonSecondary" type="button" class="btn btn-primary">
Ouvrir Demo Ws
</button>
</div>
<div class="mb-2">
<label for="wsLogTextarea" class="form-label fw-semibold">Transport log</label>
<textarea
id="wsLogTextarea"
class="form-control font-monospace"
rows="18"
readonly
spellcheck="false"
></textarea>
</div>
<hr />
<p class="mb-0 text-body-secondary">
Cette fenêtre sert de point dentrée applicatif. Les démonstrations de
souscriptions Solana live sont isolées dans la fenêtre de test dédiée.
</p>
</div>
</div>
</div>

View File

@@ -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<HTMLButtonElement>('#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<HTMLAnchorElement>('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<HTMLSelectElement>("#demoWsEndpointSelect");
const methodSelect = document.querySelector<HTMLSelectElement>("#demoWsMethodSelect");
const modeSelect = document.querySelector<HTMLSelectElement>("#demoWsModeSelect");
const targetGroup = document.querySelector<HTMLDivElement>("#demoWsTargetGroup");
const targetLabel = document.querySelector<HTMLLabelElement>("#demoWsTargetLabel");
const targetInput = document.querySelector<HTMLInputElement>("#demoWsTargetInput");
const filterGroup = document.querySelector<HTMLDivElement>("#demoWsFilterGroup");
const filterTextarea = document.querySelector<HTMLTextAreaElement>("#demoWsFilterTextarea");
const configGroup = document.querySelector<HTMLDivElement>("#demoWsConfigGroup");
const configTextarea = document.querySelector<HTMLTextAreaElement>("#demoWsConfigTextarea");
const statusBadge = document.querySelector<HTMLSpanElement>("#demoWsStatusBadge");
const stateText = document.querySelector<HTMLSpanElement>("#demoWsStateText");
const endpointText = document.querySelector<HTMLSpanElement>("#demoWsEndpointText");
const subscriptionText = document.querySelector<HTMLSpanElement>("#demoWsSubscriptionText");
const requestText = document.querySelector<HTMLSpanElement>("#demoWsRequestText");
const connectButton = document.querySelector<HTMLButtonElement>("#demoWsConnectButton");
const disconnectButton = document.querySelector<HTMLButtonElement>("#demoWsDisconnectButton");
const subscribeButton = document.querySelector<HTMLButtonElement>("#demoWsSubscribeButton");
const unsubscribeButton = document.querySelector<HTMLButtonElement>("#demoWsUnsubscribeButton");
const clearLogButton = document.querySelector<HTMLButtonElement>("#demoWsClearLogButton");
const logTextarea = document.querySelector<HTMLTextAreaElement>("#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<string>("demo-ws-log", (event) => {
appendLogLine(logTextarea, event.payload);
});
unlistenStatusEvent = await listen<DemoWsStatusPayload>("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<DemoWsEndpointSummary[]>("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<DemoWsStatusPayload>("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<DemoWsStatusPayload>("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<DemoWsStatusPayload>("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<number>("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<number>("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");
});

View File

@@ -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<void> {
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<HTMLButtonElement>("#wsConnectButton");
const disconnectButton = document.querySelector<HTMLButtonElement>("#wsDisconnectButton");
const statusBadge = document.querySelector<HTMLSpanElement>("#wsStatusBadge");
const logTextarea = document.querySelector<HTMLTextAreaElement>("#wsLogTextarea");
const openDemoWsButton = document.querySelector<HTMLButtonElement>("#openDemoWsButton");
const openDemoWsButtonSecondary = document.querySelector<HTMLButtonElement>("#openDemoWsButtonSecondary");
if (!connectButton || !disconnectButton || !statusBadge || !logTextarea) {
trace("main UI controls not found");
return;
}
let unlistenLogEvent: UnlistenFn | null = null;
try {
unlistenLogEvent = await listen<string>("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<number>("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<number>("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");

View File

@@ -1 +1 @@
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main","splash"],"permissions":["core:default","tracing:default"]}}
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main","splash","demo_ws"],"permissions":["core:default","tracing:default"]}}

848
kb_app/src/demo_ws.rs Normal file
View File

@@ -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<std::string::String>,
}
/// 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<std::string::String>,
endpoint_url: std::option::Option<std::string::String>,
current_subscription_id: std::option::Option<u64>,
current_subscribe_method: std::option::Option<std::string::String>,
current_unsubscribe_method: std::option::Option<std::string::String>,
current_notification_method: std::option::Option<std::string::String>,
}
/// 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<std::string::String>,
filter_json: std::option::Option<std::string::String>,
config_json: std::option::Option<std::string::String>,
}
/// Runtime state for the demo websocket window.
#[derive(Debug)]
pub(crate) struct KbDemoWsRuntimeState {
client: std::option::Option<kb_lib::WsClient>,
relay_task: std::option::Option<tauri::async_runtime::JoinHandle<()>>,
keepalive_task: std::option::Option<tauri::async_runtime::JoinHandle<()>>,
endpoint_name: std::option::Option<std::string::String>,
endpoint_url: std::option::Option<std::string::String>,
connection_state: kb_lib::KbConnectionState,
current_subscription: std::option::Option<kb_lib::WsSubscriptionInfo>,
}
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::vec::Vec<KbDemoWsEndpointSummary>, 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<KbDemoWsStatusPayload, std::string::String> {
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<KbDemoWsStatusPayload, std::string::String> {
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<KbDemoWsStatusPayload, std::string::String> {
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<u64, std::string::String> {
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<u64, std::string::String> {
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<u64, std::string::String> {
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<std::string::String, std::string::String> {
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<std::string::String>,
label: &str,
) -> Result<std::option::Option<serde_json::Value>, std::string::String> {
match input {
Some(input) => {
if input.trim().is_empty() {
return Ok(None);
}
let parse_result = serde_json::from_str::<serde_json::Value>(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<std::string::String>,
label: &str,
) -> Result<serde_json::Value, std::string::String> {
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::<serde_json::Value>(input);
match parse_result {
Ok(value) => Ok(value),
Err(error) => Err(format!("cannot parse {}: {}", label, error)),
}
}
fn kb_parse_optional_json_typed<T>(
input: &std::option::Option<std::string::String>,
label: &str,
) -> Result<std::option::Option<T>, 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::<T>(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<T>(
input: &std::option::Option<std::string::String>,
label: &str,
) -> Result<T, std::string::String>
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::<T>(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<tokio::sync::Mutex<KbDemoWsRuntimeState>>,
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<tokio::sync::Mutex<KbDemoWsRuntimeState>>,
) {
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;
}
}
}

View File

@@ -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<KbWsRuntimeState>,
demo_ws_runtime: std::sync::Arc<tokio::sync::Mutex<crate::demo_ws::KbDemoWsRuntimeState>>,
}
/// 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::Wry>());
tauri_builder = tauri_builder.setup(|app| {
let app_handle = app.handle().clone();

View File

@@ -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": {

View File

@@ -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',