diff --git a/app/MindWork AI Studio/Tools/RustService.APIKeys.cs b/app/MindWork AI Studio/Tools/RustService.APIKeys.cs new file mode 100644 index 00000000..1a3b4af7 --- /dev/null +++ b/app/MindWork AI Studio/Tools/RustService.APIKeys.cs @@ -0,0 +1,76 @@ +using AIStudio.Tools.Rust; + +namespace AIStudio.Tools; + +public sealed partial class RustService +{ + /// + /// Try to get the API key for the given secret ID. + /// + /// The secret ID to get the API key for. + /// Indicates if we are trying to get the API key. In that case, we don't log errors. + /// The requested secret. + public async Task GetAPIKey(ISecretId secretId, bool isTrying = false) + { + var secretRequest = new SelectSecretRequest($"provider::{secretId.SecretId}::{secretId.SecretName}::api_key", Environment.UserName, isTrying); + var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, this.jsonRustSerializerOptions); + if (!result.IsSuccessStatusCode) + { + if(!isTrying) + this.logger!.LogError($"Failed to get the API key for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'"); + return new RequestedSecret(false, new EncryptedText(string.Empty), "Failed to get the API key due to an API issue."); + } + + var secret = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + if (!secret.Success && !isTrying) + this.logger!.LogError($"Failed to get the API key for secret ID '{secretId.SecretId}': '{secret.Issue}'"); + + return secret; + } + + /// + /// Try to store the API key for the given secret ID. + /// + /// The secret ID to store the API key for. + /// The API key to store. + /// The store secret response. + public async Task SetAPIKey(ISecretId secretId, string key) + { + var encryptedKey = await this.encryptor!.Encrypt(key); + var request = new StoreSecretRequest($"provider::{secretId.SecretId}::{secretId.SecretName}::api_key", Environment.UserName, encryptedKey); + var result = await this.http.PostAsJsonAsync("/secrets/store", request, this.jsonRustSerializerOptions); + if (!result.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to store the API key for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'"); + return new StoreSecretResponse(false, "Failed to get the API key due to an API issue."); + } + + var state = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + if (!state.Success) + this.logger!.LogError($"Failed to store the API key for secret ID '{secretId.SecretId}': '{state.Issue}'"); + + return state; + } + + /// + /// Tries to delete the API key for the given secret ID. + /// + /// The secret ID to delete the API key for. + /// The delete secret response. + public async Task DeleteAPIKey(ISecretId secretId) + { + var request = new SelectSecretRequest($"provider::{secretId.SecretId}::{secretId.SecretName}::api_key", Environment.UserName, false); + var result = await this.http.PostAsJsonAsync("/secrets/delete", request, this.jsonRustSerializerOptions); + if (!result.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to delete the API key for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'"); + return new DeleteSecretResponse{Success = false, WasEntryFound = false, Issue = "Failed to delete the API key due to an API issue."}; + } + + var state = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + if (!state.Success) + this.logger!.LogError($"Failed to delete the API key for secret ID '{secretId.SecretId}': '{state.Issue}'"); + + return state; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/RustService.App.cs b/app/MindWork AI Studio/Tools/RustService.App.cs new file mode 100644 index 00000000..ea27f6d0 --- /dev/null +++ b/app/MindWork AI Studio/Tools/RustService.App.cs @@ -0,0 +1,120 @@ +using System.Security.Cryptography; + +namespace AIStudio.Tools; + +public sealed partial class RustService +{ + public async Task GetAppPort() + { + Console.WriteLine("Trying to get app port from Rust runtime..."); + + // + // Note I: In the production environment, the Rust runtime is already running + // and listening on the given port. In the development environment, the IDE + // starts the Rust runtime in parallel with the .NET runtime. Since the + // Rust runtime needs some time to start, we have to wait for it to be ready. + // + const int MAX_TRIES = 160; + var tris = 0; + var wait4Try = TimeSpan.FromMilliseconds(250); + var url = new Uri($"https://127.0.0.1:{this.apiPort}/system/dotnet/port"); + while (tris++ < MAX_TRIES) + { + // + // Note II: We use a new HttpClient instance for each try to avoid + // .NET is caching the result. When we use the same HttpClient + // instance, we would always get the same result (403 forbidden), + // without even trying to connect to the Rust server. + // + + using var initialHttp = new HttpClient(new HttpClientHandler + { + // + // Note III: We have to create also a new HttpClientHandler instance + // for each try to avoid .NET is caching the result. This is necessary + // because it gets disposed when the HttpClient instance gets disposed. + // + ServerCertificateCustomValidationCallback = (_, certificate, _, _) => + { + if(certificate is null) + return false; + + var currentCertificateFingerprint = certificate.GetCertHashString(HashAlgorithmName.SHA256); + return currentCertificateFingerprint == this.certificateFingerprint; + } + }); + + initialHttp.DefaultRequestVersion = Version.Parse("2.0"); + initialHttp.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher; + initialHttp.DefaultRequestHeaders.AddApiToken(); + + try + { + var response = await initialHttp.GetAsync(url); + if (!response.IsSuccessStatusCode) + { + Console.WriteLine($"Try {tris}/{MAX_TRIES} to get the app port from Rust runtime"); + await Task.Delay(wait4Try); + continue; + } + + var appPortContent = await response.Content.ReadAsStringAsync(); + var appPort = int.Parse(appPortContent); + Console.WriteLine($"Received app port from Rust runtime: '{appPort}'"); + return appPort; + } + catch (Exception e) + { + Console.WriteLine($"Error: Was not able to get the app port from Rust runtime: '{e.Message}'"); + Console.WriteLine(e.InnerException); + throw; + } + } + + Console.WriteLine("Failed to receive the app port from Rust runtime."); + return 0; + } + + public async Task AppIsReady() + { + const string URL = "/system/dotnet/ready"; + this.logger!.LogInformation("Notifying Rust runtime that the app is ready."); + try + { + var response = await this.http.GetAsync(URL); + if (!response.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to notify Rust runtime that the app is ready: '{response.StatusCode}'"); + } + } + catch (Exception e) + { + this.logger!.LogError(e, "Failed to notify the Rust runtime that the app is ready."); + throw; + } + } + + public async Task GetConfigDirectory() + { + var response = await this.http.GetAsync("/system/directories/config"); + if (!response.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to get the config directory from Rust: '{response.StatusCode}'"); + return string.Empty; + } + + return await response.Content.ReadAsStringAsync(); + } + + public async Task GetDataDirectory() + { + var response = await this.http.GetAsync("/system/directories/data"); + if (!response.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to get the data directory from Rust: '{response.StatusCode}'"); + return string.Empty; + } + + return await response.Content.ReadAsStringAsync(); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/RustService.Clipboard.cs b/app/MindWork AI Studio/Tools/RustService.Clipboard.cs new file mode 100644 index 00000000..d3e6520a --- /dev/null +++ b/app/MindWork AI Studio/Tools/RustService.Clipboard.cs @@ -0,0 +1,50 @@ +using AIStudio.Tools.Rust; + +namespace AIStudio.Tools; + +public sealed partial class RustService +{ + /// + /// Tries to copy the given text to the clipboard. + /// + /// The snackbar to show the result. + /// The text to copy to the clipboard. + public async Task CopyText2Clipboard(ISnackbar snackbar, string text) + { + var message = "Successfully copied the text to your clipboard"; + var iconColor = Color.Error; + var severity = Severity.Error; + try + { + var encryptedText = await text.Encrypt(this.encryptor!); + var response = await this.http.PostAsync("/clipboard/set", new StringContent(encryptedText.EncryptedData)); + if (!response.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to copy the text to the clipboard due to an network error: '{response.StatusCode}'"); + message = "Failed to copy the text to your clipboard."; + return; + } + + var state = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + if (!state.Success) + { + this.logger!.LogError("Failed to copy the text to the clipboard."); + message = "Failed to copy the text to your clipboard."; + return; + } + + iconColor = Color.Success; + severity = Severity.Success; + this.logger!.LogDebug("Successfully copied the text to the clipboard."); + } + finally + { + snackbar.Add(message, severity, config => + { + config.Icon = Icons.Material.Filled.ContentCopy; + config.IconSize = Size.Large; + config.IconColor = iconColor; + }); + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/RustService.FileSystem.cs b/app/MindWork AI Studio/Tools/RustService.FileSystem.cs new file mode 100644 index 00000000..6d5b931d --- /dev/null +++ b/app/MindWork AI Studio/Tools/RustService.FileSystem.cs @@ -0,0 +1,19 @@ +using AIStudio.Tools.Rust; + +namespace AIStudio.Tools; + +public sealed partial class RustService +{ + public async Task SelectDirectory(string title, string? initialDirectory = null) + { + PreviousDirectory? previousDirectory = initialDirectory is null ? null : new (initialDirectory); + var result = await this.http.PostAsJsonAsync($"/select/directory?title={title}", previousDirectory, this.jsonRustSerializerOptions); + if (!result.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to select a directory: '{result.StatusCode}'"); + return new DirectorySelectionResponse(true, string.Empty); + } + + return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/RustService.Updates.cs b/app/MindWork AI Studio/Tools/RustService.Updates.cs new file mode 100644 index 00000000..edb14663 --- /dev/null +++ b/app/MindWork AI Studio/Tools/RustService.Updates.cs @@ -0,0 +1,40 @@ +using AIStudio.Tools.Rust; + +namespace AIStudio.Tools; + +public sealed partial class RustService +{ + public async Task CheckForUpdate() + { + try + { + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45)); + var response = await this.http.GetFromJsonAsync("/updates/check", this.jsonRustSerializerOptions, cts.Token); + this.logger!.LogInformation($"Checked for an update: update available='{response.UpdateIsAvailable}'; error='{response.Error}'; next version='{response.NewVersion}'; changelog len='{response.Changelog.Length}'"); + return response; + } + catch (Exception e) + { + this.logger!.LogError(e, "Failed to check for an update."); + return new UpdateResponse + { + Error = true, + UpdateIsAvailable = false, + }; + } + } + + public async Task InstallUpdate() + { + try + { + var cts = new CancellationTokenSource(); + await this.http.GetAsync("/updates/install", cts.Token); + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/RustService.cs b/app/MindWork AI Studio/Tools/RustService.cs index 99476e52..75effa53 100644 --- a/app/MindWork AI Studio/Tools/RustService.cs +++ b/app/MindWork AI Studio/Tools/RustService.cs @@ -10,7 +10,7 @@ namespace AIStudio.Tools; /// /// Calling Rust functions. /// -public sealed class RustService : IDisposable +public sealed partial class RustService : IDisposable { private readonly HttpClient http; @@ -60,281 +60,6 @@ public sealed class RustService : IDisposable { this.encryptor = encryptionService; } - - public async Task GetAppPort() - { - Console.WriteLine("Trying to get app port from Rust runtime..."); - - // - // Note I: In the production environment, the Rust runtime is already running - // and listening on the given port. In the development environment, the IDE - // starts the Rust runtime in parallel with the .NET runtime. Since the - // Rust runtime needs some time to start, we have to wait for it to be ready. - // - const int MAX_TRIES = 160; - var tris = 0; - var wait4Try = TimeSpan.FromMilliseconds(250); - var url = new Uri($"https://127.0.0.1:{this.apiPort}/system/dotnet/port"); - while (tris++ < MAX_TRIES) - { - // - // Note II: We use a new HttpClient instance for each try to avoid - // .NET is caching the result. When we use the same HttpClient - // instance, we would always get the same result (403 forbidden), - // without even trying to connect to the Rust server. - // - - using var initialHttp = new HttpClient(new HttpClientHandler - { - // - // Note III: We have to create also a new HttpClientHandler instance - // for each try to avoid .NET is caching the result. This is necessary - // because it gets disposed when the HttpClient instance gets disposed. - // - ServerCertificateCustomValidationCallback = (_, certificate, _, _) => - { - if(certificate is null) - return false; - - var currentCertificateFingerprint = certificate.GetCertHashString(HashAlgorithmName.SHA256); - return currentCertificateFingerprint == this.certificateFingerprint; - } - }); - - initialHttp.DefaultRequestVersion = Version.Parse("2.0"); - initialHttp.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher; - initialHttp.DefaultRequestHeaders.AddApiToken(); - - try - { - var response = await initialHttp.GetAsync(url); - if (!response.IsSuccessStatusCode) - { - Console.WriteLine($"Try {tris}/{MAX_TRIES} to get the app port from Rust runtime"); - await Task.Delay(wait4Try); - continue; - } - - var appPortContent = await response.Content.ReadAsStringAsync(); - var appPort = int.Parse(appPortContent); - Console.WriteLine($"Received app port from Rust runtime: '{appPort}'"); - return appPort; - } - catch (Exception e) - { - Console.WriteLine($"Error: Was not able to get the app port from Rust runtime: '{e.Message}'"); - Console.WriteLine(e.InnerException); - throw; - } - } - - Console.WriteLine("Failed to receive the app port from Rust runtime."); - return 0; - } - - public async Task AppIsReady() - { - const string URL = "/system/dotnet/ready"; - this.logger!.LogInformation("Notifying Rust runtime that the app is ready."); - try - { - var response = await this.http.GetAsync(URL); - if (!response.IsSuccessStatusCode) - { - this.logger!.LogError($"Failed to notify Rust runtime that the app is ready: '{response.StatusCode}'"); - } - } - catch (Exception e) - { - this.logger!.LogError(e, "Failed to notify the Rust runtime that the app is ready."); - throw; - } - } - - public async Task GetConfigDirectory() - { - var response = await this.http.GetAsync("/system/directories/config"); - if (!response.IsSuccessStatusCode) - { - this.logger!.LogError($"Failed to get the config directory from Rust: '{response.StatusCode}'"); - return string.Empty; - } - - return await response.Content.ReadAsStringAsync(); - } - - public async Task GetDataDirectory() - { - var response = await this.http.GetAsync("/system/directories/data"); - if (!response.IsSuccessStatusCode) - { - this.logger!.LogError($"Failed to get the data directory from Rust: '{response.StatusCode}'"); - return string.Empty; - } - - return await response.Content.ReadAsStringAsync(); - } - - /// - /// Tries to copy the given text to the clipboard. - /// - /// The snackbar to show the result. - /// The text to copy to the clipboard. - public async Task CopyText2Clipboard(ISnackbar snackbar, string text) - { - var message = "Successfully copied the text to your clipboard"; - var iconColor = Color.Error; - var severity = Severity.Error; - try - { - var encryptedText = await text.Encrypt(this.encryptor!); - var response = await this.http.PostAsync("/clipboard/set", new StringContent(encryptedText.EncryptedData)); - if (!response.IsSuccessStatusCode) - { - this.logger!.LogError($"Failed to copy the text to the clipboard due to an network error: '{response.StatusCode}'"); - message = "Failed to copy the text to your clipboard."; - return; - } - - var state = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); - if (!state.Success) - { - this.logger!.LogError("Failed to copy the text to the clipboard."); - message = "Failed to copy the text to your clipboard."; - return; - } - - iconColor = Color.Success; - severity = Severity.Success; - this.logger!.LogDebug("Successfully copied the text to the clipboard."); - } - finally - { - snackbar.Add(message, severity, config => - { - config.Icon = Icons.Material.Filled.ContentCopy; - config.IconSize = Size.Large; - config.IconColor = iconColor; - }); - } - } - - public async Task CheckForUpdate() - { - try - { - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45)); - var response = await this.http.GetFromJsonAsync("/updates/check", this.jsonRustSerializerOptions, cts.Token); - this.logger!.LogInformation($"Checked for an update: update available='{response.UpdateIsAvailable}'; error='{response.Error}'; next version='{response.NewVersion}'; changelog len='{response.Changelog.Length}'"); - return response; - } - catch (Exception e) - { - this.logger!.LogError(e, "Failed to check for an update."); - return new UpdateResponse - { - Error = true, - UpdateIsAvailable = false, - }; - } - } - - public async Task InstallUpdate() - { - try - { - var cts = new CancellationTokenSource(); - await this.http.GetAsync("/updates/install", cts.Token); - } - catch (Exception e) - { - Console.WriteLine(e); - throw; - } - } - - /// - /// Try to get the API key for the given secret ID. - /// - /// The secret ID to get the API key for. - /// Indicates if we are trying to get the API key. In that case, we don't log errors. - /// The requested secret. - public async Task GetAPIKey(ISecretId secretId, bool isTrying = false) - { - var secretRequest = new SelectSecretRequest($"provider::{secretId.SecretId}::{secretId.SecretName}::api_key", Environment.UserName, isTrying); - var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, this.jsonRustSerializerOptions); - if (!result.IsSuccessStatusCode) - { - if(!isTrying) - this.logger!.LogError($"Failed to get the API key for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'"); - return new RequestedSecret(false, new EncryptedText(string.Empty), "Failed to get the API key due to an API issue."); - } - - var secret = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); - if (!secret.Success && !isTrying) - this.logger!.LogError($"Failed to get the API key for secret ID '{secretId.SecretId}': '{secret.Issue}'"); - - return secret; - } - - /// - /// Try to store the API key for the given secret ID. - /// - /// The secret ID to store the API key for. - /// The API key to store. - /// The store secret response. - public async Task SetAPIKey(ISecretId secretId, string key) - { - var encryptedKey = await this.encryptor!.Encrypt(key); - var request = new StoreSecretRequest($"provider::{secretId.SecretId}::{secretId.SecretName}::api_key", Environment.UserName, encryptedKey); - var result = await this.http.PostAsJsonAsync("/secrets/store", request, this.jsonRustSerializerOptions); - if (!result.IsSuccessStatusCode) - { - this.logger!.LogError($"Failed to store the API key for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'"); - return new StoreSecretResponse(false, "Failed to get the API key due to an API issue."); - } - - var state = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); - if (!state.Success) - this.logger!.LogError($"Failed to store the API key for secret ID '{secretId.SecretId}': '{state.Issue}'"); - - return state; - } - - /// - /// Tries to delete the API key for the given secret ID. - /// - /// The secret ID to delete the API key for. - /// The delete secret response. - public async Task DeleteAPIKey(ISecretId secretId) - { - var request = new SelectSecretRequest($"provider::{secretId.SecretId}::{secretId.SecretName}::api_key", Environment.UserName, false); - var result = await this.http.PostAsJsonAsync("/secrets/delete", request, this.jsonRustSerializerOptions); - if (!result.IsSuccessStatusCode) - { - this.logger!.LogError($"Failed to delete the API key for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'"); - return new DeleteSecretResponse{Success = false, WasEntryFound = false, Issue = "Failed to delete the API key due to an API issue."}; - } - - var state = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); - if (!state.Success) - this.logger!.LogError($"Failed to delete the API key for secret ID '{secretId.SecretId}': '{state.Issue}'"); - - return state; - } - - public async Task SelectDirectory(string title, string? initialDirectory = null) - { - PreviousDirectory? previousDirectory = initialDirectory is null ? null : new (initialDirectory); - var result = await this.http.PostAsJsonAsync($"/select/directory?title={title}", previousDirectory, this.jsonRustSerializerOptions); - if (!result.IsSuccessStatusCode) - { - this.logger!.LogError($"Failed to select a directory: '{result.StatusCode}'"); - return new DirectorySelectionResponse(true, string.Empty); - } - - return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); - } #region IDisposable