v0.1.0-pre.049
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
869
kb-app-demo-desktop/src/demo_ws.rs
Normal file
869
kb-app-demo-desktop/src/demo_ws.rs
Normal 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(¬ification) {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user