From 15d7359c59be5552d5af7365589903c613d62888 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 27 Oct 2025 14:37:15 +0100 Subject: [PATCH] Implemented the Tauri events stream for the Rust server and the .NET client --- .../Settings/TolerantEnumConverter.cs | 76 +++++- .../Tools/Rust/TauriEvent.cs | 7 +- .../Tools/Rust/TauriEventType.cs | 7 + .../Tools/Services/RustService.Events.cs | 26 +- .../Tools/Services/RustService.cs | 11 + runtime/src/app_window.rs | 254 ++++++++++++++---- runtime/src/runtime_api.rs | 1 + 7 files changed, 323 insertions(+), 59 deletions(-) diff --git a/app/MindWork AI Studio/Settings/TolerantEnumConverter.cs b/app/MindWork AI Studio/Settings/TolerantEnumConverter.cs index c3f8fcd0..51bccea4 100644 --- a/app/MindWork AI Studio/Settings/TolerantEnumConverter.cs +++ b/app/MindWork AI Studio/Settings/TolerantEnumConverter.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -9,6 +10,9 @@ namespace AIStudio.Settings; /// /// When the target enum value does not exist, the value will be the default value. /// This converter handles enum values as property names and values. +///

+/// We assume that enum names are in UPPER_SNAKE_CASE, and the JSON strings may be +/// in any case style (e.g., camelCase, PascalCase, snake_case, UPPER_SNAKE_CASE, etc.) ///
public sealed class TolerantEnumConverter : JsonConverter { @@ -16,30 +20,46 @@ public sealed class TolerantEnumConverter : JsonConverter public override bool CanConvert(Type typeToConvert) => typeToConvert.IsEnum; - public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override object? Read(ref Utf8JsonReader reader, Type enumType, JsonSerializerOptions options) { // Is this token a string? if (reader.TokenType == JsonTokenType.String) + { // Try to use that string as the name of the enum value: - if (Enum.TryParse(typeToConvert, reader.GetString(), out var result)) + var text = reader.GetString(); + + // Convert the text to UPPER_SNAKE_CASE: + text = ConvertToUpperSnakeCase(text); + + // Try to parse the enum value: + if (Enum.TryParse(enumType, text, out var result)) return result; + } // In any other case, we will return the default enum value: - LOG.LogWarning($"Cannot read '{reader.GetString()}' as '{typeToConvert.Name}' enum; token type: {reader.TokenType}"); - return Activator.CreateInstance(typeToConvert); + LOG.LogWarning($"Cannot read '{reader.GetString()}' as '{enumType.Name}' enum; token type: {reader.TokenType}"); + return Activator.CreateInstance(enumType); } - public override object ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override object ReadAsPropertyName(ref Utf8JsonReader reader, Type enumType, JsonSerializerOptions options) { // Is this token a property name? if (reader.TokenType == JsonTokenType.PropertyName) + { // Try to use that property name as the name of the enum value: - if (Enum.TryParse(typeToConvert, reader.GetString(), out var result)) + var text = reader.GetString(); + + // Convert the text to UPPER_SNAKE_CASE: + text = ConvertToUpperSnakeCase(text); + + // Try to parse the enum value: + if (Enum.TryParse(enumType, text, out var result)) return result; + } // In any other case, we will return the default enum value: - LOG.LogWarning($"Cannot read '{reader.GetString()}' as '{typeToConvert.Name}' enum; token type: {reader.TokenType}"); - return Activator.CreateInstance(typeToConvert)!; + LOG.LogWarning($"Cannot read '{reader.GetString()}' as '{enumType.Name}' enum; token type: {reader.TokenType}"); + return Activator.CreateInstance(enumType)!; } public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options) @@ -51,4 +71,44 @@ public sealed class TolerantEnumConverter : JsonConverter { writer.WritePropertyName(value.ToString()!); } + + /// + /// Converts a string to UPPER_SNAKE_CASE. + /// + /// The text to convert. + /// The converted text as UPPER_SNAKE_CASE. + private static string ConvertToUpperSnakeCase(string? text) + { + // Handle null or empty strings: + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + // Create a string builder with the same length as the + // input text. We will add underscores as needed, which + // may increase the length -- we cannot predict how many + // underscores will be added, so we just start with the + // original length: + var sb = new StringBuilder(text.Length); + + // State to track if the last character was lowercase. + // This helps to determine when to add underscores: + var lastCharWasLowerCase = false; + + // Iterate through each character in the input text: + foreach(var c in text) + { + // If the current character is uppercase and the last + // character was lowercase, we need to add an underscore: + if (char.IsUpper(c) && lastCharWasLowerCase) + sb.Append('_'); + + // Append the uppercase version of the current character: + sb.Append(char.ToUpperInvariant(c)); + + // Keep track of whether the current character is lowercase: + lastCharWasLowerCase = char.IsLower(c); + } + + return sb.ToString(); + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/TauriEvent.cs b/app/MindWork AI Studio/Tools/Rust/TauriEvent.cs index 140522be..c060e63a 100644 --- a/app/MindWork AI Studio/Tools/Rust/TauriEvent.cs +++ b/app/MindWork AI Studio/Tools/Rust/TauriEvent.cs @@ -1,3 +1,8 @@ namespace AIStudio.Tools.Rust; -public readonly record struct TauriEvent(TauriEventType Type, List Payload); \ No newline at end of file +/// +/// The data structure for a Tauri event sent from the Rust backend to the C# frontend. +/// +/// The type of the Tauri event. +/// The payload of the Tauri event. +public readonly record struct TauriEvent(TauriEventType EventType, List Payload); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/TauriEventType.cs b/app/MindWork AI Studio/Tools/Rust/TauriEventType.cs index 2710c16b..2cd1c792 100644 --- a/app/MindWork AI Studio/Tools/Rust/TauriEventType.cs +++ b/app/MindWork AI Studio/Tools/Rust/TauriEventType.cs @@ -1,7 +1,14 @@ namespace AIStudio.Tools.Rust; +/// +/// The type of Tauri events we can receive. +/// public enum TauriEventType { + NONE, + PING, + UNKNOWN, + WINDOW_FOCUSED, WINDOW_NOT_FOCUSED, diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Events.cs b/app/MindWork AI Studio/Tools/Services/RustService.Events.cs index 539c3d06..4bb0fd63 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Events.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Events.cs @@ -6,24 +6,46 @@ namespace AIStudio.Tools.Services; public partial class RustService { + /// + /// Consume the Tauri event stream and forward relevant events to the message bus. + /// + /// Cancellation token to stop the stream. private async Task StartStreamTauriEvents(CancellationToken stopToken) { + // Outer try-catch to handle cancellation: try { while (!stopToken.IsCancellationRequested) { + // Inner try-catch to handle streaming issues: try { + // Open the event stream: await using var stream = await this.http.GetStreamAsync("/events", stopToken); + + + // Read events line by line: using var reader = new StreamReader(stream); - while(!reader.EndOfStream) + + // Read until the end of the stream or cancellation: + while(!reader.EndOfStream && !stopToken.IsCancellationRequested) { + // Read the next line of JSON from the stream: var line = await reader.ReadLineAsync(stopToken); + + // Skip empty lines: if (string.IsNullOrWhiteSpace(line)) continue; + // Deserialize the Tauri event: var tauriEvent = JsonSerializer.Deserialize(line, this.jsonRustSerializerOptions); - if (tauriEvent != default) + + // Log the received event for debugging: + this.logger!.LogDebug("Received Tauri event: {Event}", tauriEvent); + + // Forward relevant events to the message bus: + if (tauriEvent != default && tauriEvent.EventType is not TauriEventType.NONE + and not TauriEventType.UNKNOWN and not TauriEventType.PING) await MessageBus.INSTANCE.SendMessage(null, Event.TAURI_EVENT_RECEIVED, tauriEvent); } } diff --git a/app/MindWork AI Studio/Tools/Services/RustService.cs b/app/MindWork AI Studio/Tools/Services/RustService.cs index 56032b65..41628992 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.cs @@ -1,6 +1,10 @@ using System.Security.Cryptography; using System.Text.Json; +using AIStudio.Settings; + +using Version = System.Version; + // ReSharper disable NotAccessedPositionalProperty.Local namespace AIStudio.Tools.Services; @@ -15,6 +19,7 @@ public sealed partial class RustService : BackgroundService private readonly JsonSerializerOptions jsonRustSerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + Converters = { new TolerantEnumConverter() }, }; private ILogger? logger; @@ -61,9 +66,15 @@ public sealed partial class RustService : BackgroundService #region Overrides of BackgroundService + /// + /// The main execution loop of the Rust service as a background thread. + /// + /// The cancellation token to stop the service. protected override async Task ExecuteAsync(CancellationToken stopToken) { this.logger?.LogInformation("The Rust service was initialized."); + + // Start consuming Tauri events: await this.StartStreamTauriEvents(stopToken); } diff --git a/runtime/src/app_window.rs b/runtime/src/app_window.rs index ee932355..b08f17ca 100644 --- a/runtime/src/app_window.rs +++ b/runtime/src/app_window.rs @@ -3,12 +3,14 @@ use std::time::Duration; use log::{debug, error, info, trace, warn}; use once_cell::sync::Lazy; use rocket::{get, post}; +use rocket::response::stream::TextStream; use rocket::serde::json::Json; use rocket::serde::Serialize; use serde::Deserialize; use tauri::updater::UpdateResponse; -use tauri::{FileDropEvent, Manager, PathResolver, Window}; +use tauri::{FileDropEvent, UpdaterEvent, RunEvent, Manager, PathResolver, Window, WindowEvent}; use tauri::api::dialog::blocking::FileDialogBuilder; +use tokio::sync::broadcast; use tokio::time; use crate::api_token::APIToken; use crate::dotnet::stop_dotnet_server; @@ -22,41 +24,61 @@ static MAIN_WINDOW: Lazy>> = Lazy::new(|| Mutex::new(None)) /// The update response coming from the Tauri updater. static CHECK_UPDATE_RESPONSE: Lazy>>> = Lazy::new(|| Mutex::new(None)); +/// The event broadcast sender for Tauri events. +static EVENT_BROADCAST: Lazy>>> = Lazy::new(|| Mutex::new(None)); + /// Starts the Tauri app. pub fn start_tauri() { info!("Starting Tauri app..."); + + // Create the event broadcast channel: + let (event_sender, root_event_receiver) = broadcast::channel(100); + + // Save a copy of the event broadcast sender for later use: + *EVENT_BROADCAST.lock().unwrap() = Some(event_sender.clone()); + + // When the last receiver is dropped, we lose the ability to send events. + // Therefore, we spawn a task that keeps the root receiver alive: + tauri::async_runtime::spawn(async move { + let mut root_receiver = root_event_receiver; + loop { + match root_receiver.recv().await { + Ok(event) => { + debug!(Source = "Tauri"; "Tauri event received: location=root receiver , event={event:?}"); + }, + + Err(broadcast::error::RecvError::Lagged(skipped)) => { + warn!(Source = "Tauri"; "Root event receiver lagged, skipped {skipped} messages."); + }, + + Err(broadcast::error::RecvError::Closed) => { + warn!(Source = "Tauri"; "Root event receiver channel closed."); + return; + }, + } + } + }); + let app = tauri::Builder::default() .setup(move |app| { + + // Get the main window: let window = app.get_window("main").expect("Failed to get main window."); - // Register a callback for file drop events: - window.on_window_event(|event| - match event { - tauri::WindowEvent::FileDrop(drop_event) => { - match drop_event { - FileDropEvent::Hovered(files) => { - info!(Source = "Tauri"; "Files hovered over the window: {files:?}"); - }, - - FileDropEvent::Dropped(files) => { - info!(Source = "Tauri"; "Files dropped on the window: {files:?}"); - }, - - FileDropEvent::Cancelled => { - info!(Source = "Tauri"; "File drop was cancelled."); - }, - - _ => {} - } - }, - - tauri::WindowEvent::Focused(state) => { - info!(Source = "Tauri"; "Window focus changed: focused={state}"); - }, - - _ => {} - } - ); + // Register a callback for window events, such as file drops. We have to use + // this handler in addition to the app event handler, because file drop events + // are only available in the window event handler (is a bug, cf. https://github.com/tauri-apps/tauri/issues/14338): + window.on_window_event(move |event| { + debug!(Source = "Tauri"; "Tauri event received: location=window event handler, event={event:?}"); + let event_to_send = Event::from_window_event(event); + let sender = event_sender.clone(); + tauri::async_runtime::spawn(async move { + match sender.send(event_to_send) { + Ok(_) => {}, + Err(error) => error!(Source = "Tauri"; "Failed to channel window event: {error}"), + } + }); + }); // Save the main window for later access: *MAIN_WINDOW.lock().unwrap() = Some(window); @@ -65,6 +87,7 @@ pub fn start_tauri() { let data_path = app.path_resolver().app_local_data_dir().unwrap(); let data_path = data_path.join("data"); + // Get and store the data and config directories: DATA_DIRECTORY.set(data_path.to_str().unwrap().to_string()).map_err(|_| error!("Was not abe to set the data directory.")).unwrap(); CONFIG_DIRECTORY.set(app.path_resolver().app_config_dir().unwrap().to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the config directory.")).unwrap(); @@ -77,46 +100,43 @@ pub fn start_tauri() { .build(tauri::generate_context!()) .expect("Error while running Tauri application"); + // The app event handler: app.run(|app_handle, event| { - if !matches!(event, tauri::RunEvent::MainEventsCleared) { - debug!(Source = "Tauri"; "Event received: {event:?}"); + if !matches!(event, RunEvent::MainEventsCleared) { + debug!(Source = "Tauri"; "Tauri event received: location=app event handler , event={event:?}"); } match event { - tauri::RunEvent::WindowEvent { event, label, .. } => { + RunEvent::WindowEvent { event, label, .. } => { match event { - tauri::WindowEvent::CloseRequested { .. } => { + WindowEvent::CloseRequested { .. } => { warn!(Source = "Tauri"; "Window '{label}': close was requested."); } - tauri::WindowEvent::Destroyed => { + WindowEvent::Destroyed => { warn!(Source = "Tauri"; "Window '{label}': was destroyed."); } - tauri::WindowEvent::FileDrop(files) => { - info!(Source = "Tauri"; "Window '{label}': files were dropped: {files:?}"); - } - _ => (), } } - tauri::RunEvent::Updater(updater_event) => { + RunEvent::Updater(updater_event) => { match updater_event { - tauri::UpdaterEvent::UpdateAvailable { body, date, version } => { + UpdaterEvent::UpdateAvailable { body, date, version } => { let body_len = body.len(); info!(Source = "Tauri"; "Updater: update available: body size={body_len} time={date:?} version={version}"); } - tauri::UpdaterEvent::Pending => { + UpdaterEvent::Pending => { info!(Source = "Tauri"; "Updater: update is pending!"); } - tauri::UpdaterEvent::DownloadProgress { chunk_length, content_length: _ } => { + UpdaterEvent::DownloadProgress { chunk_length, content_length: _ } => { trace!(Source = "Tauri"; "Updater: downloading chunk of {chunk_length} bytes"); } - tauri::UpdaterEvent::Downloaded => { + UpdaterEvent::Downloaded => { info!(Source = "Tauri"; "Updater: update has been downloaded!"); warn!(Source = "Tauri"; "Try to stop the .NET server now..."); @@ -127,7 +147,7 @@ pub fn start_tauri() { } } - tauri::UpdaterEvent::Updated => { + UpdaterEvent::Updated => { info!(Source = "Tauri"; "Updater: app has been updated"); warn!(Source = "Tauri"; "Try to restart the app now..."); @@ -138,21 +158,21 @@ pub fn start_tauri() { } } - tauri::UpdaterEvent::AlreadyUpToDate => { + UpdaterEvent::AlreadyUpToDate => { info!(Source = "Tauri"; "Updater: app is already up to date"); } - tauri::UpdaterEvent::Error(error) => { + UpdaterEvent::Error(error) => { warn!(Source = "Tauri"; "Updater: failed to update: {error}"); } } } - tauri::RunEvent::ExitRequested { .. } => { + RunEvent::ExitRequested { .. } => { warn!(Source = "Tauri"; "Run event: exit was requested."); } - tauri::RunEvent::Ready => { + RunEvent::Ready => { info!(Source = "Tauri"; "Run event: Tauri app is ready."); } @@ -167,6 +187,144 @@ pub fn start_tauri() { } } +/// Our event API endpoint for Tauri events. We try to send an endless stream of events to the client. +/// If no events are available for a certain time, we send a ping event to keep the connection alive. +/// When the client disconnects, the stream is closed. But we try to not lose events in between. +/// The client is expected to reconnect automatically when the connection is closed and continue +/// listening for events. +#[get("/events")] +pub async fn get_event_stream(_token: APIToken) -> TextStream![String] { + // Get the lock to the event broadcast sender: + let event_broadcast_lock = EVENT_BROADCAST.lock().unwrap(); + + // Get and subscribe to the event receiver: + let mut event_receiver = event_broadcast_lock.as_ref() + .expect("Event sender not initialized.") + .subscribe(); + + // Drop the lock to allow other access to the sender: + drop(event_broadcast_lock); + + // Create the event stream: + TextStream! { + loop { + // Wait at most 3 seconds for an event: + match time::timeout(Duration::from_secs(3), event_receiver.recv()).await { + + // Case: we received an event + Ok(Ok(event)) => { + // Serialize the event to JSON. Important is that the entire event + // is serialized as a single line so that the client can parse it + // correctly: + let event_json = serde_json::to_string(&event).unwrap(); + yield event_json; + + // The client expects a newline after each event because we are using + // a method to read the stream line-by-line: + yield "\n".to_string(); + }, + + // Case: we lagged behind and missed some events + Ok(Err(broadcast::error::RecvError::Lagged(skipped))) => { + warn!(Source = "Tauri"; "Event receiver lagged, skipped {skipped} messages."); + }, + + // Case: the event channel was closed + Ok(Err(broadcast::error::RecvError::Closed)) => { + warn!(Source = "Tauri"; "Event receiver channel closed."); + return; + }, + + // Case: timeout. We will send a ping event to keep the connection alive. + Err(_) => { + let ping_event = Event::new(TauriEventType::Ping, Vec::new()); + + // Again, we have to serialize the event as a single line: + let event_json = serde_json::to_string(&ping_event).unwrap(); + yield event_json; + + // The client expects a newline after each event because we are using + // a method to read the stream line-by-line: + yield "\n".to_string(); + }, + } + } + } +} + +/// Data structure representing a Tauri event for our event API. +#[derive(Debug, Clone, Serialize)] +pub struct Event { + pub event_type: TauriEventType, + pub payload: Vec, +} + +/// Implementation of the Event struct. +impl Event { + + /// Creates a new Event instance. + pub fn new(event_type: TauriEventType, payload: Vec) -> Self { + Event { + payload, + event_type, + } + } + + /// Creates an Event instance from a Tauri WindowEvent. + pub fn from_window_event(window_event: &WindowEvent) -> Self { + match window_event { + WindowEvent::FileDrop(drop_event) => { + match drop_event { + FileDropEvent::Hovered(files) => Event::new(TauriEventType::FileDropHovered, + files.iter().map(|f| f.to_string_lossy().to_string()).collect(), + ), + + FileDropEvent::Dropped(files) => Event::new(TauriEventType::FileDropDropped, + files.iter().map(|f| f.to_string_lossy().to_string()).collect(), + ), + + FileDropEvent::Cancelled => Event::new(TauriEventType::FileDropCanceled, + Vec::new(), + ), + + _ => Event::new(TauriEventType::Unknown, + Vec::new(), + ), + } + }, + + WindowEvent::Focused(state) => if *state { + Event::new(TauriEventType::WindowFocused, + Vec::new(), + ) + } else { + Event::new(TauriEventType::WindowNotFocused, + Vec::new(), + ) + }, + + _ => Event::new(TauriEventType::Unknown, + Vec::new(), + ), + } + } +} + +/// The types of Tauri events we can send through our event API. +#[derive(Debug, Serialize, Clone)] +pub enum TauriEventType { + None, + Ping, + Unknown, + + WindowFocused, + WindowNotFocused, + + FileDropHovered, + FileDropDropped, + FileDropCanceled, +} + /// Changes the location of the main window to the given URL. pub async fn change_location_to(url: &str) { // Try to get the main window. If it is not available yet, wait for it: diff --git a/runtime/src/runtime_api.rs b/runtime/src/runtime_api.rs index b700af5b..aec48001 100644 --- a/runtime/src/runtime_api.rs +++ b/runtime/src/runtime_api.rs @@ -68,6 +68,7 @@ pub fn start_runtime_api() { crate::dotnet::dotnet_port, crate::dotnet::dotnet_ready, crate::clipboard::set_clipboard, + crate::app_window::get_event_stream, crate::app_window::check_for_update, crate::app_window::install_update, crate::app_window::select_directory,