v0.1.0-pre.049

This commit is contained in:
2026-07-25 20:40:03 +02:00
parent a595fd920d
commit 717d97482a
7 changed files with 1089 additions and 31 deletions

View File

@@ -1,5 +1,5 @@
<!-- file: kb-app-demo-desktop/frontend/main.html -->
<!-- version: 3 -->
<!-- version: 4 -->
<!DOCTYPE html>
<html lang="fr">
<head>
@@ -22,6 +22,7 @@
Démos
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a id="openDemoWsLink" class="dropdown-item" href="#">WebSocket standard</a></li>
<li><a id="openDemoHttpLink" class="dropdown-item" href="#">HTTP JSON-RPC</a></li>
<li><a id="openDemoBackfillLink" class="dropdown-item" href="#">Backfill HTTP</a></li>
</ul>

View File

@@ -37,6 +37,14 @@ document.addEventListener("DOMContentLoaded", async () => {
}
});
const openDemoWsLink = document.querySelector<HTMLAnchorElement>("#openDemoWsLink");
if (openDemoWsLink) {
openDemoWsLink.addEventListener("click", event => {
event.preventDefault();
void invoke("open_demo_ws_window");
});
}
const openDemoHttpLink = document.querySelector<HTMLAnchorElement>("#openDemoHttpLink");
if (openDemoHttpLink) {
openDemoHttpLink.addEventListener("click", event => {

View File

@@ -1,5 +1,5 @@
// file: kb-app-demo-desktop/src/app_state.rs
// version: 2
// version: 3
//! Shared Tauri application state and startup initialization.
@@ -10,6 +10,8 @@ pub(crate) struct AppState {
active_profile: kb_config::ProfileConfig,
logging_guard: std::sync::Mutex<kb_logging::LoggingGuard>,
http_pool: kb_onchain_transport::HttpEndpointPool,
ws_pool: kb_onchain_transport::WsEndpointPool,
demo_ws_session: tokio::sync::Mutex<std::option::Option<std::sync::Arc<kb_onchain_transport::WsSession>>>,
demo_backfill_running: std::sync::atomic::AtomicBool,
demo_backfill_cancel_requested: std::sync::atomic::AtomicBool,
}
@@ -31,8 +33,11 @@ impl crate::AppState {
std::result::Result::Ok(guard) => guard,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let http_pool = match kb_onchain_transport::HttpEndpointPool::from_profile(&active_profile)
{
let http_pool = match kb_onchain_transport::HttpEndpointPool::from_profile(&active_profile) {
std::result::Result::Ok(pool) => pool,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let ws_pool = match kb_onchain_transport::WsEndpointPool::from_profile(&active_profile) {
std::result::Result::Ok(pool) => pool,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
@@ -42,6 +47,8 @@ impl crate::AppState {
active_profile,
logging_guard: std::sync::Mutex::new(logging_guard),
http_pool,
ws_pool,
demo_ws_session: tokio::sync::Mutex::new(std::option::Option::None),
demo_backfill_running: std::sync::atomic::AtomicBool::new(false),
demo_backfill_cancel_requested: std::sync::atomic::AtomicBool::new(false),
});
@@ -67,6 +74,20 @@ impl crate::AppState {
return &self.http_pool;
}
/// Returns the configured WebSocket endpoint pool.
pub(crate) fn ws_pool(&self) -> &kb_onchain_transport::WsEndpointPool {
return &self.ws_pool;
}
/// Returns the persistent WebSocket demo session slot.
pub(crate) fn demo_ws_session(
&self,
) -> &tokio::sync::Mutex<
std::option::Option<std::sync::Arc<kb_onchain_transport::WsSession>>,
> {
return &self.demo_ws_session;
}
/// Returns the single-campaign execution flag used by the backfill window.
pub(crate) fn demo_backfill_running(&self) -> &std::sync::atomic::AtomicBool {
return &self.demo_backfill_running;

View File

@@ -0,0 +1,869 @@
// file: kb-app-demo-desktop/src/demo_ws.rs
// version: 14
//! Standard Solana WebSocket demo commands backed by `kb_onchain_transport::WsSession`.
use tauri::Emitter; // rust-rules: trait-import
use tauri::Manager; // rust-rules: trait-import
use ts_rs::TS; // rust-rules: derive-import
/// One selectable role shown by the WebSocket demo.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_ws/DemoWsRoleOption.ts"
)]
pub(crate) struct DemoWsRoleOption {
/// Endpoint role code.
pub(crate) role: std::string::String,
/// Request kinds accepted by this role.
pub(crate) request_kinds: std::vec::Vec<std::string::String>,
}
/// One selectable WebSocket JSON-RPC method shown by the WebSocket demo.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_ws/DemoWsMethodOption.ts"
)]
pub(crate) struct DemoWsMethodOption {
/// JSON-RPC subscribe method name.
pub(crate) method: std::string::String,
/// Matching unsubscribe method name.
pub(crate) unsubscribe_method: std::string::String,
/// Derived request kind used for endpoint routing.
pub(crate) request_kind: std::string::String,
/// Human-readable method label.
pub(crate) label: std::string::String,
/// Whether this method needs the target field.
pub(crate) requires_target: bool,
/// Whether this method needs the filter JSON field.
pub(crate) requires_filter_json: bool,
/// Whether this method supports an optional configuration object.
pub(crate) supports_config_json: bool,
}
/// WebSocket demo options derived from configuration and the standard registry.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_ws/DemoWsOptionsPayload.ts"
)]
pub(crate) struct DemoWsOptionsPayload {
/// Selectable roles.
pub(crate) roles: std::vec::Vec<crate::DemoWsRoleOption>,
/// Selectable methods.
pub(crate) methods: std::vec::Vec<crate::DemoWsMethodOption>,
}
/// Request payload for one WebSocket subscription demo command.
#[derive(Clone, Debug, serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_ws/DemoWsRequest.ts"
)]
pub(crate) struct DemoWsRequest {
/// Required endpoint role used by the WebSocket pool.
pub(crate) role: std::string::String,
/// JSON-RPC WebSocket subscribe method name.
pub(crate) method: std::string::String,
/// Optional target string used by account/program/signature subscriptions.
pub(crate) target: std::option::Option<std::string::String>,
/// Optional JSON filter string used by logsSubscribe or blockSubscribe.
pub(crate) filter_json: std::option::Option<std::string::String>,
/// Optional JSON configuration string.
pub(crate) config_json: std::option::Option<std::string::String>,
}
/// Response payload for one WebSocket subscription response.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_ws/DemoWsExecutionPayload.ts"
)]
pub(crate) struct DemoWsExecutionPayload {
/// Selected endpoint name.
pub(crate) endpoint_name: std::string::String,
/// Selected provider name.
pub(crate) provider: std::string::String,
/// Selected endpoint URL.
pub(crate) endpoint_url: std::string::String,
/// Required role used by the selection.
pub(crate) role: std::string::String,
/// JSON-RPC method name.
pub(crate) method: std::string::String,
/// Derived request kind.
pub(crate) request_kind: std::string::String,
/// Parsed response kind.
pub(crate) response_kind: std::string::String,
/// Remote subscription id returned by the node.
#[ts(type = "number | null")]
pub(crate) subscription_id: std::option::Option<u64>,
/// Pretty JSON response text.
pub(crate) response_json: std::string::String,
}
/// Current WebSocket demo session status.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_ws/DemoWsStatusPayload.ts"
)]
pub(crate) struct DemoWsStatusPayload {
/// Whether a session is currently connected.
pub(crate) connected: bool,
/// Selected endpoint name.
pub(crate) endpoint_name: std::option::Option<std::string::String>,
/// Selected endpoint URL.
pub(crate) endpoint_url: std::option::Option<std::string::String>,
/// Last subscription method.
pub(crate) method: std::option::Option<std::string::String>,
/// Last unsubscribe method.
pub(crate) unsubscribe_method: std::option::Option<std::string::String>,
/// Last remote subscription id.
#[ts(type = "number | null")]
pub(crate) subscription_id: std::option::Option<u64>,
/// Number of active subscriptions on the current connection.
pub(crate) subscription_count: u32,
/// Active subscriptions kept by the session.
pub(crate) subscriptions: std::vec::Vec<crate::DemoWsSubscriptionStatusPayload>,
}
/// One active WebSocket subscription shown by the demo UI.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_ws/DemoWsSubscriptionStatusPayload.ts"
)]
pub(crate) struct DemoWsSubscriptionStatusPayload {
/// Remote subscription id.
#[ts(type = "number")]
pub(crate) subscription_id: u64,
/// Subscribe method used to create this subscription.
pub(crate) method: std::string::String,
/// Matching unsubscribe method.
pub(crate) unsubscribe_method: std::string::String,
}
/// Request payload for one explicit WebSocket unsubscribe command.
#[derive(Clone, Debug, serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_ws/DemoWsUnsubscribeRequest.ts"
)]
pub(crate) struct DemoWsUnsubscribeRequest {
/// Remote subscription id to unsubscribe.
#[ts(type = "number")]
pub(crate) subscription_id: u64,
}
/// WebSocket event payload emitted to the demo window.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_ws/DemoWsMessagePayload.ts"
)]
pub(crate) struct DemoWsMessagePayload {
/// Message kind.
pub(crate) kind: std::string::String,
/// Pretty JSON or text payload.
pub(crate) payload_json: std::string::String,
}
const DEMO_WS_UI_RATE_WINDOW_MS: u64 = 1000;
const DEMO_WS_UI_MAX_MESSAGES_PER_WINDOW: u32 = 12;
const DEMO_WS_UI_MAX_PAYLOAD_CHARS: usize = 8_000;
const DEMO_WS_RECONNECT_ATTEMPTS: u32 = 3;
const DEMO_WS_RECONNECT_INITIAL_DELAY_MS: u64 = 250;
const DEMO_WS_RECONNECT_MAX_DELAY_MS: u64 = 2_000;
struct DemoWsUiRateLimiter {
window_started_at: std::time::Instant,
emitted_in_window: u32,
dropped_in_window: u32,
}
impl DemoWsUiRateLimiter {
fn new() -> Self {
return Self {
window_started_at: std::time::Instant::now(),
emitted_in_window: 0,
dropped_in_window: 0,
};
}
fn should_emit(&mut self, app_handle: &tauri::AppHandle) -> bool {
let now = std::time::Instant::now();
if now.duration_since(self.window_started_at)
>= std::time::Duration::from_millis(DEMO_WS_UI_RATE_WINDOW_MS)
{
if self.dropped_in_window > 0 {
emit_demo_ws_message(
app_handle,
"throttled",
format!(
"{} WebSocket notification(s) masquée(s) pendant la dernière fenêtre UI.",
self.dropped_in_window
),
);
}
self.window_started_at = now;
self.emitted_in_window = 0;
self.dropped_in_window = 0;
}
if self.emitted_in_window >= DEMO_WS_UI_MAX_MESSAGES_PER_WINDOW {
self.dropped_in_window = self.dropped_in_window.saturating_add(1);
return false;
}
self.emitted_in_window = self.emitted_in_window.saturating_add(1);
return true;
}
}
pub(crate) async fn demo_ws_status_inner(
state: &crate::AppState,
) -> std::result::Result<crate::DemoWsStatusPayload, std::string::String> {
let session = {
let guard = state.demo_ws_session().lock().await;
guard.clone()
};
return match session {
std::option::Option::Some(session) => {
std::result::Result::Ok(status_from_snapshot(session.snapshot().await))
},
std::option::Option::None => std::result::Result::Ok(disconnected_status()),
};
}
pub(crate) async fn demo_ws_connect_inner(
app_handle: tauri::AppHandle,
state: &crate::AppState,
request: crate::DemoWsRequest,
) -> std::result::Result<crate::DemoWsExecutionPayload, std::string::String> {
let role = request.role.trim().to_string();
if role.is_empty() {
return std::result::Result::Err("demo WebSocket role must not be empty".to_string());
}
let method = request.method.trim().to_string();
if method.is_empty() {
return std::result::Result::Err("demo WebSocket method must not be empty".to_string());
}
let standard_request = match build_standard_ws_request(
&method,
request.target,
request.filter_json,
request.config_json,
) {
std::result::Result::Ok(request) => request,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let selected_client = match state.ws_pool().select_client_for_role_and_method(&role, &method) {
std::result::Result::Ok(client) => client,
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
};
let session = match ensure_demo_ws_session(&app_handle, state, selected_client.clone()).await {
std::result::Result::Ok(session) => session,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let acknowledgement = match session.subscribe(standard_request).await {
std::result::Result::Ok(acknowledgement) => acknowledgement,
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
};
let response_value = match acknowledgement.response.to_value() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
};
let response_json = match serde_json::to_string_pretty(&response_value) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
};
emit_demo_ws_status(&app_handle, session.snapshot().await);
return std::result::Result::Ok(crate::DemoWsExecutionPayload {
endpoint_name: selected_client.endpoint_name().to_string(),
provider: selected_client.provider().to_string(),
endpoint_url: selected_client.endpoint_url().to_string(),
role,
method: method.clone(),
request_kind: kb_onchain_transport::request_kind_from_method(&method),
response_kind: acknowledgement.response.kind_name().to_string(),
subscription_id: acknowledgement.subscription.remote_subscription_id,
response_json,
});
}
async fn ensure_demo_ws_session(
app_handle: &tauri::AppHandle,
state: &crate::AppState,
selected_client: kb_onchain_transport::WsClient,
) -> std::result::Result<std::sync::Arc<kb_onchain_transport::WsSession>, std::string::String> {
let existing = {
let guard = state.demo_ws_session().lock().await;
guard.clone()
};
if let std::option::Option::Some(session) = existing {
let snapshot = session.snapshot().await;
if snapshot.state != kb_onchain_transport::WsSessionState::Disconnected {
if snapshot.endpoint_url != selected_client.endpoint_url() {
return std::result::Result::Err(format!(
"demo WebSocket session already uses endpoint '{}'; disconnect before selecting '{}'",
snapshot.endpoint_name,
selected_client.endpoint_name()
));
}
return std::result::Result::Ok(session);
}
}
let reconnect_policy = if selected_client.endpoint_config().auto_reconnect {
match kb_onchain_transport::WsReconnectPolicy::bounded(
DEMO_WS_RECONNECT_ATTEMPTS,
DEMO_WS_RECONNECT_INITIAL_DELAY_MS,
DEMO_WS_RECONNECT_MAX_DELAY_MS,
) {
std::result::Result::Ok(policy) => policy,
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
}
} else {
kb_onchain_transport::WsReconnectPolicy::disabled()
};
let capabilities =
kb_onchain_transport::StandardWsCapabilities::from_endpoint(selected_client.endpoint_config());
let session =
match kb_onchain_transport::WsSession::connect(selected_client, capabilities, reconnect_policy).await {
std::result::Result::Ok(session) => std::sync::Arc::new(session),
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
};
spawn_demo_ws_event_bridge(app_handle.clone(), session.clone());
{
let mut guard = state.demo_ws_session().lock().await;
*guard = std::option::Option::Some(session.clone());
}
return std::result::Result::Ok(session);
}
pub(crate) async fn demo_ws_unsubscribe_inner(
app_handle: tauri::AppHandle,
state: &crate::AppState,
remote_subscription_id: u64,
) -> std::result::Result<crate::DemoWsStatusPayload, std::string::String> {
let session = {
let guard = state.demo_ws_session().lock().await;
guard.clone()
};
let session = match session {
std::option::Option::Some(session) => session,
std::option::Option::None => {
return std::result::Result::Err("demo WebSocket session is not connected".to_string());
},
};
let unsubscribe_result = session.unsubscribe(remote_subscription_id).await;
if let std::result::Result::Err(error) = unsubscribe_result {
return std::result::Result::Err(error.to_string());
}
let snapshot = session.snapshot().await;
emit_demo_ws_status(&app_handle, snapshot.clone());
return std::result::Result::Ok(status_from_snapshot(snapshot));
}
pub(crate) async fn demo_ws_disconnect_inner(
state: &crate::AppState,
) -> std::result::Result<crate::DemoWsStatusPayload, std::string::String> {
let session = {
let mut guard = state.demo_ws_session().lock().await;
guard.take()
};
if let std::option::Option::Some(session) = session {
if let std::result::Result::Err(error) = session.disconnect().await {
return std::result::Result::Err(error.to_string());
}
}
return std::result::Result::Ok(disconnected_status());
}
/// Disconnects the demo WebSocket session from application lifecycle hooks.
pub(crate) async fn disconnect_demo_ws_app_state(state: &crate::AppState, _wait_for_relay: bool) {
let disconnect_result = demo_ws_disconnect_inner(state).await;
if let std::result::Result::Err(error) = disconnect_result {
tracing::debug!(target: crate::TRACING_TARGET, "demo ws lifecycle disconnect failed: {error}");
}
}
fn spawn_demo_ws_event_bridge(
app_handle: tauri::AppHandle,
session: std::sync::Arc<kb_onchain_transport::WsSession>,
) {
let mut receiver = session.subscribe_events();
tauri::async_runtime::spawn(async move {
let mut rate_limiter = DemoWsUiRateLimiter::new();
loop {
let event = receiver.recv().await;
let event = match event {
std::result::Result::Ok(event) => event,
std::result::Result::Err(tokio::sync::broadcast::error::RecvError::Lagged(
count,
)) => {
emit_demo_ws_message(
&app_handle,
"lagged",
format!("{count} événement(s) WebSocket interne(s) perdu(s)"),
);
continue;
},
std::result::Result::Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
};
match event {
kb_onchain_transport::WsSessionEvent::Notification { notification, .. } => {
if rate_limiter.should_emit(&app_handle) {
let payload = match serde_json::to_string_pretty(&notification) {
std::result::Result::Ok(payload) => payload,
std::result::Result::Err(error) => {
format!("cannot serialize typed WebSocket notification: {error}")
},
};
emit_demo_ws_message(
&app_handle,
"notification",
truncate_payload(payload),
);
}
},
kb_onchain_transport::WsSessionEvent::Diagnostic { code, message } => {
emit_demo_ws_message(&app_handle, &code, truncate_payload(message));
},
kb_onchain_transport::WsSessionEvent::Reconnecting { attempt, maximum_attempts } => {
emit_demo_ws_message(
&app_handle,
"reconnecting",
format!("tentative {attempt}/{maximum_attempts}"),
);
emit_demo_ws_status(&app_handle, session.snapshot().await);
},
kb_onchain_transport::WsSessionEvent::Reconnected { reconnect_count } => {
emit_demo_ws_message(
&app_handle,
"reconnected",
format!("reconnexion numéro {reconnect_count}"),
);
emit_demo_ws_status(&app_handle, session.snapshot().await);
},
kb_onchain_transport::WsSessionEvent::Connected
| kb_onchain_transport::WsSessionEvent::SubscriptionAdded(_)
| kb_onchain_transport::WsSessionEvent::SubscriptionRemapped { .. }
| kb_onchain_transport::WsSessionEvent::SubscriptionRemoved(_) => {
emit_demo_ws_status(&app_handle, session.snapshot().await);
},
kb_onchain_transport::WsSessionEvent::Disconnected => {
emit_demo_ws_status(&app_handle, session.snapshot().await);
break;
},
}
}
});
}
fn emit_demo_ws_status(app_handle: &tauri::AppHandle, snapshot: kb_onchain_transport::WsSessionSnapshot) {
let window = match app_handle.get_webview_window("demo_ws") {
std::option::Option::Some(window) => window,
std::option::Option::None => return,
};
let status = status_from_snapshot(snapshot);
if let std::result::Result::Err(error) = window.emit("demo-ws-status", status) {
tracing::debug!(target: crate::TRACING_TARGET, "cannot emit demo ws status: {error}");
}
}
fn emit_demo_ws_message(
app_handle: &tauri::AppHandle,
kind: &str,
payload_json: std::string::String,
) {
let window = match app_handle.get_webview_window("demo_ws") {
std::option::Option::Some(window) => window,
std::option::Option::None => return,
};
if let std::result::Result::Err(error) = window.emit(
"demo-ws-message",
crate::DemoWsMessagePayload { kind: kind.to_string(), payload_json },
) {
tracing::debug!(target: crate::TRACING_TARGET, "cannot emit demo ws message: {error}");
}
}
fn status_from_snapshot(snapshot: kb_onchain_transport::WsSessionSnapshot) -> crate::DemoWsStatusPayload {
let mut subscriptions = std::vec::Vec::new();
for subscription in &snapshot.subscriptions {
if let std::option::Option::Some(remote_subscription_id) =
subscription.remote_subscription_id
{
subscriptions.push(crate::DemoWsSubscriptionStatusPayload {
subscription_id: remote_subscription_id,
method: subscription.subscribe_method.clone(),
unsubscribe_method: subscription.unsubscribe_method.clone(),
});
}
}
let (method, unsubscribe_method, subscription_id) = match subscriptions.last() {
std::option::Option::Some(subscription) => (
std::option::Option::Some(subscription.method.clone()),
std::option::Option::Some(subscription.unsubscribe_method.clone()),
std::option::Option::Some(subscription.subscription_id),
),
std::option::Option::None => {
(std::option::Option::None, std::option::Option::None, std::option::Option::None)
},
};
return crate::DemoWsStatusPayload {
connected: snapshot.state != kb_onchain_transport::WsSessionState::Disconnected,
endpoint_name: std::option::Option::Some(snapshot.endpoint_name),
endpoint_url: std::option::Option::Some(snapshot.endpoint_url),
method,
unsubscribe_method,
subscription_id,
subscription_count: match u32::try_from(subscriptions.len()) {
std::result::Result::Ok(count) => count,
std::result::Result::Err(_) => u32::MAX,
},
subscriptions,
};
}
fn disconnected_status() -> crate::DemoWsStatusPayload {
return crate::DemoWsStatusPayload {
connected: false,
endpoint_name: std::option::Option::None,
endpoint_url: std::option::Option::None,
method: std::option::Option::None,
unsubscribe_method: std::option::Option::None,
subscription_id: std::option::Option::None,
subscription_count: 0,
subscriptions: std::vec::Vec::new(),
};
}
pub(crate) fn build_ws_role_options(
snapshots: std::vec::Vec<kb_onchain_transport::WsPoolClientSnapshot>,
) -> std::vec::Vec<crate::DemoWsRoleOption> {
let mut roles = std::collections::BTreeMap::<
std::string::String,
std::collections::BTreeSet<std::string::String>,
>::new();
for snapshot in snapshots {
for role in snapshot.roles {
if !role.enabled {
continue;
}
let role_entry = roles.entry(role.role).or_default();
for request_kind in role.request_kinds {
role_entry.insert(request_kind);
}
}
}
let mut options = std::vec::Vec::new();
for (role, request_kinds) in roles {
options.push(crate::DemoWsRoleOption {
role,
request_kinds: request_kinds.into_iter().collect(),
});
}
return options;
}
pub(crate) fn build_ws_method_options() -> std::vec::Vec<crate::DemoWsMethodOption> {
let labels = std::collections::BTreeMap::from([
("accountSubscribe", ("Compte", true, false, true)),
("blockSubscribe", ("Bloc", false, true, true)),
("logsSubscribe", ("Logs", false, true, true)),
("programSubscribe", ("Programme", true, false, true)),
("rootSubscribe", ("Roots", false, false, false)),
("signatureSubscribe", ("Signature", true, false, true)),
("slotSubscribe", ("Slots", false, false, false)),
("slotsUpdatesSubscribe", ("Mises à jour slots", false, false, false)),
("voteSubscribe", ("Votes", false, false, false)),
]);
let mut options = std::vec::Vec::new();
for specification in &kb_onchain_transport::STANDARD_WS_SUBSCRIPTIONS {
let metadata = labels.get(specification.subscribe_method);
let (label, requires_target, requires_filter_json, supports_config_json) = match metadata {
std::option::Option::Some(metadata) => *metadata,
std::option::Option::None => continue,
};
options.push(crate::DemoWsMethodOption {
method: specification.subscribe_method.to_string(),
unsubscribe_method: specification.unsubscribe_method.to_string(),
request_kind: kb_onchain_transport::request_kind_from_method(specification.subscribe_method),
label: label.to_string(),
requires_target,
requires_filter_json,
supports_config_json,
});
}
return options;
}
fn build_standard_ws_request(
method: &str,
target: std::option::Option<std::string::String>,
filter_json: std::option::Option<std::string::String>,
config_json: std::option::Option<std::string::String>,
) -> std::result::Result<kb_onchain_transport::StandardWsRequest, std::string::String> {
return match method {
"accountSubscribe" => {
let target = match required_target(target, method) {
std::result::Result::Ok(target) => target,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let config = match parse_optional_json_as::<kb_onchain_transport::WsAccountSubscribeConfig>(
config_json,
"configJson",
) {
std::result::Result::Ok(config) => config,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if let std::result::Result::Err(error) =
reject_present(filter_json, "filterJson", method)
{
return std::result::Result::Err(error);
}
std::result::Result::Ok(kb_onchain_transport::StandardWsRequest::Account(
kb_onchain_transport::AccountSubscribeRequest { pubkey: target, config },
))
},
"blockSubscribe" => {
if let std::result::Result::Err(error) = reject_present(target, "target", method) {
return std::result::Result::Err(error);
}
let filter =
match parse_required_json_as::<kb_onchain_transport::WsBlockFilter>(filter_json, "filterJson") {
std::result::Result::Ok(filter) => filter,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let config = match parse_optional_json_as::<kb_onchain_transport::WsBlockSubscribeConfig>(
config_json,
"configJson",
) {
std::result::Result::Ok(config) => config,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
std::result::Result::Ok(kb_onchain_transport::StandardWsRequest::Block(
kb_onchain_transport::BlockSubscribeRequest { filter, config },
))
},
"logsSubscribe" => {
if let std::result::Result::Err(error) = reject_present(target, "target", method) {
return std::result::Result::Err(error);
}
let filter =
match parse_required_json_as::<kb_onchain_transport::WsLogsFilter>(filter_json, "filterJson") {
std::result::Result::Ok(filter) => filter,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let config = match parse_optional_json_as::<kb_onchain_transport::WsLogsSubscribeConfig>(
config_json,
"configJson",
) {
std::result::Result::Ok(config) => config,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
std::result::Result::Ok(kb_onchain_transport::StandardWsRequest::Logs(kb_onchain_transport::LogsSubscribeRequest {
filter,
config,
}))
},
"programSubscribe" => {
let target = match required_target(target, method) {
std::result::Result::Ok(target) => target,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if let std::result::Result::Err(error) =
reject_present(filter_json, "filterJson", method)
{
return std::result::Result::Err(error);
}
let config = match parse_optional_json_as::<kb_onchain_transport::WsProgramSubscribeConfig>(
config_json,
"configJson",
) {
std::result::Result::Ok(config) => config,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
std::result::Result::Ok(kb_onchain_transport::StandardWsRequest::Program(
kb_onchain_transport::ProgramSubscribeRequest { program_id: target, config },
))
},
"signatureSubscribe" => {
let target = match required_target(target, method) {
std::result::Result::Ok(target) => target,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if let std::result::Result::Err(error) =
reject_present(filter_json, "filterJson", method)
{
return std::result::Result::Err(error);
}
let config = match parse_optional_json_as::<kb_onchain_transport::WsSignatureSubscribeConfig>(
config_json,
"configJson",
) {
std::result::Result::Ok(config) => config,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
std::result::Result::Ok(kb_onchain_transport::StandardWsRequest::Signature(
kb_onchain_transport::SignatureSubscribeRequest { signature: target, config },
))
},
"rootSubscribe" => {
if let std::result::Result::Err(error) =
reject_parameterless_inputs(target, filter_json, config_json, method)
{
return std::result::Result::Err(error);
}
std::result::Result::Ok(kb_onchain_transport::StandardWsRequest::Root(kb_onchain_transport::RootSubscribeRequest))
},
"slotSubscribe" => {
if let std::result::Result::Err(error) =
reject_parameterless_inputs(target, filter_json, config_json, method)
{
return std::result::Result::Err(error);
}
std::result::Result::Ok(kb_onchain_transport::StandardWsRequest::Slot(kb_onchain_transport::SlotSubscribeRequest))
},
"slotsUpdatesSubscribe" => {
if let std::result::Result::Err(error) =
reject_parameterless_inputs(target, filter_json, config_json, method)
{
return std::result::Result::Err(error);
}
std::result::Result::Ok(kb_onchain_transport::StandardWsRequest::SlotsUpdates(
kb_onchain_transport::SlotsUpdatesSubscribeRequest,
))
},
"voteSubscribe" => {
if let std::result::Result::Err(error) =
reject_parameterless_inputs(target, filter_json, config_json, method)
{
return std::result::Result::Err(error);
}
std::result::Result::Ok(kb_onchain_transport::StandardWsRequest::Vote(kb_onchain_transport::VoteSubscribeRequest))
},
_ => std::result::Result::Err(format!("unsupported standard WebSocket method '{method}'")),
};
}
fn required_target(
target: std::option::Option<std::string::String>,
method: &str,
) -> std::result::Result<std::string::String, std::string::String> {
let target = match target {
std::option::Option::Some(target) => target.trim().to_string(),
std::option::Option::None => std::string::String::new(),
};
if target.is_empty() {
return std::result::Result::Err(format!("method '{method}' requires target"));
}
return std::result::Result::Ok(target);
}
fn reject_parameterless_inputs(
target: std::option::Option<std::string::String>,
filter_json: std::option::Option<std::string::String>,
config_json: std::option::Option<std::string::String>,
method: &str,
) -> std::result::Result<(), std::string::String> {
if let std::result::Result::Err(error) = reject_present(target, "target", method) {
return std::result::Result::Err(error);
}
if let std::result::Result::Err(error) = reject_present(filter_json, "filterJson", method) {
return std::result::Result::Err(error);
}
if let std::result::Result::Err(error) = reject_present(config_json, "configJson", method) {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(());
}
fn reject_present(
value: std::option::Option<std::string::String>,
field: &str,
method: &str,
) -> std::result::Result<(), std::string::String> {
if let std::option::Option::Some(value) = value {
if !value.trim().is_empty() {
return std::result::Result::Err(format!("method '{method}' does not accept {field}"));
}
}
return std::result::Result::Ok(());
}
fn parse_required_json_as<ValueType: serde::de::DeserializeOwned>(
text: std::option::Option<std::string::String>,
field: &str,
) -> std::result::Result<ValueType, std::string::String> {
let text = match text {
std::option::Option::Some(text) if !text.trim().is_empty() => text,
_ => return std::result::Result::Err(format!("{field} is required")),
};
return match serde_json::from_str::<ValueType>(&text) {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => {
std::result::Result::Err(format!("invalid {field}: {error}"))
},
};
}
fn parse_optional_json_as<ValueType: serde::de::DeserializeOwned>(
text: std::option::Option<std::string::String>,
field: &str,
) -> std::result::Result<std::option::Option<ValueType>, std::string::String> {
let text = match text {
std::option::Option::Some(text) if !text.trim().is_empty() => text,
_ => return std::result::Result::Ok(std::option::Option::None),
};
return match serde_json::from_str::<ValueType>(&text) {
std::result::Result::Ok(value) => std::result::Result::Ok(std::option::Option::Some(value)),
std::result::Result::Err(error) => {
std::result::Result::Err(format!("invalid {field}: {error}"))
},
};
}
fn truncate_payload(payload: std::string::String) -> std::string::String {
if payload.chars().count() <= DEMO_WS_UI_MAX_PAYLOAD_CHARS {
return payload;
}
let mut truncated = payload
.chars()
.take(DEMO_WS_UI_MAX_PAYLOAD_CHARS)
.collect::<std::string::String>();
truncated.push_str("\n… payload tronqué côté Rust …");
return truncated;
}
#[cfg(test)]
mod tests {
use ts_rs::TS; // rust-rules: derive-import
#[test]
fn tauri_numeric_bindings_use_json_compatible_numbers() {
let config = ts_rs::Config::default();
let declarations = [
<crate::DemoWsExecutionPayload as TS>::decl(&config),
<crate::DemoWsStatusPayload as TS>::decl(&config),
<crate::DemoWsSubscriptionStatusPayload as TS>::decl(&config),
<crate::DemoWsUnsubscribeRequest as TS>::decl(&config),
<crate::DemoBackfillProgressPayload as TS>::decl(&config),
<crate::DemoBackfillSummaryPayload as TS>::decl(&config),
<crate::DemoSqlTableSnapshot as TS>::decl(&config),
];
for declaration in declarations {
assert!(!declaration.contains("bigint"));
}
}
}

View File

@@ -1,5 +1,5 @@
// file: kb-app-demo-desktop/src/frontend_log.rs
// version: 2
// version: 3
//! Frontend logging bridge used by Tauri WebView scripts.
@@ -23,21 +23,11 @@ pub(crate) struct FrontendLogPayload {
pub(crate) fn emit_frontend_log(payload: crate::FrontendLogPayload) {
let target = normalize_frontend_target(payload.target.as_str());
match payload.level.trim().to_ascii_lowercase().as_str() {
"trace" => {
tracing::trace!(target: crate::TRACING_TARGET, action = "frontend_log", frontend_target = target, "{}", payload.message)
},
"debug" => {
tracing::debug!(target: crate::TRACING_TARGET, action = "frontend_log", frontend_target = target, "{}", payload.message)
},
"warn" => {
tracing::warn!(target: crate::TRACING_TARGET, action = "frontend_log", frontend_target = target, "{}", payload.message)
},
"error" => {
tracing::error!(target: crate::TRACING_TARGET, action = "frontend_log", frontend_target = target, "{}", payload.message)
},
_ => {
tracing::info!(target: crate::TRACING_TARGET, action = "frontend_log", frontend_target = target, "{}", payload.message)
},
"trace" => tracing::trace!(target: crate::TRACING_TARGET, action = "frontend_log", frontend_target = target, "{}", payload.message),
"debug" => tracing::debug!(target: crate::TRACING_TARGET, action = "frontend_log", frontend_target = target, "{}", payload.message),
"warn" => tracing::warn!(target: crate::TRACING_TARGET, action = "frontend_log", frontend_target = target, "{}", payload.message),
"error" => tracing::error!(target: crate::TRACING_TARGET, action = "frontend_log", frontend_target = target, "{}", payload.message),
_ => tracing::info!(target: crate::TRACING_TARGET, action = "frontend_log", frontend_target = target, "{}", payload.message),
}
}
@@ -55,6 +45,9 @@ fn normalize_frontend_target(target: &str) -> std::string::String {
if trimmed == "kb-app-demo-desktop.frontend.demo_http" {
return trimmed.to_string();
}
if trimmed == "kb-app-demo-desktop.frontend.demo_ws" {
return trimmed.to_string();
}
return "kb-app-demo-desktop.frontend".to_string();
}
@@ -85,5 +78,10 @@ mod tests {
super::normalize_frontend_target("kb-app-demo-desktop.frontend.demo_http"),
"kb-app-demo-desktop.frontend.demo_http"
);
assert_eq!(
super::normalize_frontend_target("kb-app-demo-desktop.frontend.demo_ws"),
"kb-app-demo-desktop.frontend.demo_ws"
);
}
}

View File

@@ -11,6 +11,7 @@ mod app_state;
mod constants;
mod demo_backfill;
mod demo_http;
mod demo_ws;
mod frontend_log;
mod main_window;
mod splash;
@@ -57,6 +58,38 @@ pub(crate) use self::demo_http::build_http_method_options;
pub(crate) use self::demo_http::build_http_role_options;
/// Executes one raw HTTP JSON-RPC request.
pub(crate) use self::demo_http::demo_http_execute_request_inner;
/// WebSocket execution response payload.
pub(crate) use self::demo_ws::DemoWsExecutionPayload;
/// WebSocket message emitted to the frontend.
pub(crate) use self::demo_ws::DemoWsMessagePayload;
/// One selectable WebSocket method.
pub(crate) use self::demo_ws::DemoWsMethodOption;
/// WebSocket demo options payload.
pub(crate) use self::demo_ws::DemoWsOptionsPayload;
/// WebSocket subscription request.
pub(crate) use self::demo_ws::DemoWsRequest;
/// One selectable WebSocket role.
pub(crate) use self::demo_ws::DemoWsRoleOption;
/// Current WebSocket session status.
pub(crate) use self::demo_ws::DemoWsStatusPayload;
/// One active WebSocket subscription status.
pub(crate) use self::demo_ws::DemoWsSubscriptionStatusPayload;
/// WebSocket unsubscribe request.
pub(crate) use self::demo_ws::DemoWsUnsubscribeRequest;
/// Builds the standard WebSocket method inventory.
pub(crate) use self::demo_ws::build_ws_method_options;
/// Builds selectable WebSocket roles from pool snapshots.
pub(crate) use self::demo_ws::build_ws_role_options;
/// Connects and subscribes through the persistent WebSocket session.
pub(crate) use self::demo_ws::demo_ws_connect_inner;
/// Disconnects the persistent WebSocket session.
pub(crate) use self::demo_ws::demo_ws_disconnect_inner;
/// Returns the current WebSocket session status.
pub(crate) use self::demo_ws::demo_ws_status_inner;
/// Unsubscribes one active WebSocket subscription.
pub(crate) use self::demo_ws::demo_ws_unsubscribe_inner;
/// Disconnects the application WebSocket session during shutdown.
pub(crate) use self::demo_ws::disconnect_demo_ws_app_state;
/// Frontend logging payload.
pub(crate) use self::frontend_log::FrontendLogPayload;
/// Emits one normalized frontend log event.

View File

@@ -1,5 +1,5 @@
// file: kb-app-demo-desktop/src/tauri.rs
// version: 5
// version: 6
//! Tauri runtime assembly and private command wrappers.
@@ -45,6 +45,13 @@ pub fn run() -> kb_core::Result<()> {
demo_http_list_pool_clients,
demo_http_options,
demo_http_execute_request,
open_demo_ws_window,
demo_ws_list_pool_clients,
demo_ws_options,
demo_ws_status,
demo_ws_connect,
demo_ws_unsubscribe,
demo_ws_disconnect,
]);
builder = builder.plugin(tracing_builder.build::<tauri::Wry>());
builder = builder.setup(|app| {
@@ -89,6 +96,13 @@ pub fn run() -> kb_core::Result<()> {
std::option::Option::None,
std::option::Option::None,
);
crate::emit_splash_order(
&splash_window,
"add_log",
std::option::Option::Some("Pool WebSocket initialisé"),
std::option::Option::None,
std::option::Option::None,
);
crate::emit_splash_order(
&splash_window,
"fadein",
@@ -143,9 +157,9 @@ pub fn run() -> kb_core::Result<()> {
let run_result = builder.run(tauri::generate_context!());
return match run_result {
std::result::Result::Ok(()) => std::result::Result::Ok(()),
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::tauri(
format!("cannot run desktop demo application: {error:?}"),
)),
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::tauri(format!(
"cannot run desktop demo application: {error:?}"
))),
};
}
@@ -162,7 +176,9 @@ fn install_default_rustls_provider() -> kb_core::Result<()> {
};
}
fn into_ipc_result<T>(result: kb_core::Result<T>) -> std::result::Result<T, std::string::String> {
fn into_ipc_result<T>(
result: kb_core::Result<T>,
) -> std::result::Result<T, std::string::String> {
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(error.to_string()),
@@ -181,7 +197,9 @@ fn load_project_readme() -> std::result::Result<std::string::String, std::string
#[tauri::command]
fn demo_backfill_cancel(state: tauri::State<'_, crate::AppState>) -> bool {
let running = state.demo_backfill_running().load(std::sync::atomic::Ordering::Acquire);
let running = state
.demo_backfill_running()
.load(std::sync::atomic::Ordering::Acquire);
state
.demo_backfill_cancel_requested()
.store(true, std::sync::atomic::Ordering::Release);
@@ -240,12 +258,14 @@ fn demo_backfill_options(
default_max_pages: 20,
default_max_concurrent_requests: 4,
default_max_retries: 2,
running: state.demo_backfill_running().load(std::sync::atomic::Ordering::Acquire),
running: state
.demo_backfill_running()
.load(std::sync::atomic::Ordering::Acquire),
};
}
#[allow(clippy::question_mark_used)]
#[tauri::command]
#[allow(clippy::question_mark_used)]
async fn demo_backfill_execute(
app_handle: tauri::AppHandle,
state: tauri::State<'_, crate::AppState>,
@@ -260,7 +280,9 @@ async fn demo_backfill_execute(
if acquire_result.is_err() {
return std::result::Result::Err("a backfill campaign is already running".to_string());
}
let _run_guard = crate::DemoBackfillRunGuard { running: state.demo_backfill_running() };
let _run_guard = crate::DemoBackfillRunGuard {
running: state.demo_backfill_running(),
};
state
.demo_backfill_cancel_requested()
.store(false, std::sync::atomic::Ordering::Release);
@@ -345,18 +367,124 @@ fn demo_http_list_pool_clients(
}
#[tauri::command]
fn demo_http_options(state: tauri::State<'_, crate::AppState>) -> crate::DemoHttpOptionsPayload {
fn demo_http_options(
state: tauri::State<'_, crate::AppState>,
) -> crate::DemoHttpOptionsPayload {
return crate::DemoHttpOptionsPayload {
roles: crate::build_http_role_options(state.http_pool().snapshot()),
methods: crate::build_http_method_options(),
};
}
#[allow(clippy::question_mark_used)]
#[tauri::command]
#[allow(clippy::question_mark_used)]
async fn demo_http_execute_request(
state: tauri::State<'_, crate::AppState>,
request: crate::DemoHttpRequest,
) -> std::result::Result<crate::DemoHttpExecutionPayload, std::string::String> {
return crate::demo_http_execute_request_inner(state, request).await;
}
/// Opens or focuses the WebSocket demo window.
#[tauri::command]
fn open_demo_ws_window(
app_handle: tauri::AppHandle,
) -> std::result::Result<(), std::string::String> {
tracing::info!(target: crate::TRACING_TARGET, "open_demo_ws_window");
if let std::option::Option::Some(window) = app_handle.get_webview_window("demo_ws") {
if let std::result::Result::Err(error) = window.show() {
return std::result::Result::Err(
kb_core::Error::tauri(format!("cannot show demo_ws window: {error}")).to_string(),
);
}
if let std::result::Result::Err(error) = window.set_focus() {
return std::result::Result::Err(
kb_core::Error::tauri(format!("cannot focus demo_ws window: {error}")).to_string(),
);
}
return std::result::Result::Ok(());
}
let build_result = tauri::WebviewWindowBuilder::new(
&app_handle,
"demo_ws",
tauri::WebviewUrl::App("demo_ws.html".into()),
)
.title("Khadhroony Bot3 - WebSocket standard")
.inner_size(1200.0, 760.0)
.min_inner_size(920.0, 560.0)
.resizable(true)
.visible(true)
.build();
return match build_result {
std::result::Result::Ok(window) => match window.set_focus() {
std::result::Result::Ok(()) => std::result::Result::Ok(()),
std::result::Result::Err(error) => std::result::Result::Err(
kb_core::Error::tauri(format!("cannot focus created demo_ws window: {error}"))
.to_string(),
),
},
std::result::Result::Err(error) => std::result::Result::Err(
kb_core::Error::tauri(format!("cannot create demo_ws window: {error}")).to_string(),
),
};
}
/// Lists WebSocket endpoints available through the configured pool.
#[tauri::command]
fn demo_ws_list_pool_clients(
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<std::vec::Vec<kb_onchain_transport::WsPoolClientSnapshot>, std::string::String> {
return std::result::Result::Ok(state.ws_pool().snapshot());
}
/// Lists selectable WebSocket roles and methods for the demo UI.
#[tauri::command]
fn demo_ws_options(
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::DemoWsOptionsPayload, std::string::String> {
return std::result::Result::Ok(crate::DemoWsOptionsPayload {
roles: crate::build_ws_role_options(state.ws_pool().snapshot()),
methods: crate::build_ws_method_options(),
});
}
/// Returns the current WebSocket demo session status.
#[tauri::command]
#[allow(clippy::question_mark_used)]
async fn demo_ws_status(
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::DemoWsStatusPayload, std::string::String> {
return crate::demo_ws_status_inner(state.inner()).await;
}
/// Connects if needed, then subscribes through `kb_onchain_transport::WsSession`.
#[tauri::command]
#[allow(clippy::question_mark_used)]
async fn demo_ws_connect(
app_handle: tauri::AppHandle,
state: tauri::State<'_, crate::AppState>,
request: crate::DemoWsRequest,
) -> std::result::Result<crate::DemoWsExecutionPayload, std::string::String> {
return crate::demo_ws_connect_inner(app_handle, state.inner(), request).await;
}
/// Unsubscribes one subscription while keeping the WebSocket connection open.
#[tauri::command]
#[allow(clippy::question_mark_used)]
async fn demo_ws_unsubscribe(
app_handle: tauri::AppHandle,
state: tauri::State<'_, crate::AppState>,
request: crate::DemoWsUnsubscribeRequest,
) -> std::result::Result<crate::DemoWsStatusPayload, std::string::String> {
return crate::demo_ws_unsubscribe_inner(app_handle, state.inner(), request.subscription_id)
.await;
}
/// Disconnects the current persistent WebSocket demo session.
#[tauri::command]
#[allow(clippy::question_mark_used)]
async fn demo_ws_disconnect(
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::DemoWsStatusPayload, std::string::String> {
return crate::demo_ws_disconnect_inner(state.inner()).await;
}