From f2343c7482124fb57212a2c9df40d769778750b7 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 30 Jun 2024 14:29:23 +0200 Subject: [PATCH] Changed automatic updater to use in-app settings & dialog --- .../Components/Blocks/InnerScrolling.razor | 2 + .../Components/Blocks/InnerScrolling.razor.cs | 38 +++- .../CommonDialogs/UpdateDialog.razor | 14 ++ .../CommonDialogs/UpdateDialog.razor.cs | 26 +++ .../Components/ConfigurationSelectData.cs | 9 + .../Components/Layout/MainLayout.razor | 70 +++++-- .../Components/Layout/MainLayout.razor.cs | 119 ++++++++++- .../Components/Pages/About.razor | 3 + .../Components/Pages/About.razor.cs | 10 + .../Components/Pages/Settings.razor | 1 + app/MindWork AI Studio/Program.cs | 1 + app/MindWork AI Studio/Settings/Data.cs | 7 +- .../Settings/UpdateBehavior.cs | 10 + app/MindWork AI Studio/Tools/MessageBus.cs | 1 + app/MindWork AI Studio/Tools/Rust.cs | 12 ++ .../Tools/SetClipboardResponse.cs | 4 +- .../Tools/UpdateResponse.cs | 16 ++ app/MindWork AI Studio/Tools/UpdateService.cs | 105 ++++++++++ runtime/Cargo.lock | 1 + runtime/Cargo.toml | 1 + runtime/src/main.rs | 194 ++++++++++++++++-- runtime/tauri.conf.json | 2 +- 22 files changed, 596 insertions(+), 50 deletions(-) create mode 100644 app/MindWork AI Studio/Components/CommonDialogs/UpdateDialog.razor create mode 100644 app/MindWork AI Studio/Components/CommonDialogs/UpdateDialog.razor.cs create mode 100644 app/MindWork AI Studio/Settings/UpdateBehavior.cs create mode 100644 app/MindWork AI Studio/Tools/UpdateResponse.cs create mode 100644 app/MindWork AI Studio/Tools/UpdateService.cs diff --git a/app/MindWork AI Studio/Components/Blocks/InnerScrolling.razor b/app/MindWork AI Studio/Components/Blocks/InnerScrolling.razor index fbd9f58f..24c2ab78 100644 --- a/app/MindWork AI Studio/Components/Blocks/InnerScrolling.razor +++ b/app/MindWork AI Studio/Components/Blocks/InnerScrolling.razor @@ -1,3 +1,5 @@ +@inherits AIStudio.Tools.MSGComponentBase +
@this.ChildContent diff --git a/app/MindWork AI Studio/Components/Blocks/InnerScrolling.razor.cs b/app/MindWork AI Studio/Components/Blocks/InnerScrolling.razor.cs index f9d7ed7c..4dee88c7 100644 --- a/app/MindWork AI Studio/Components/Blocks/InnerScrolling.razor.cs +++ b/app/MindWork AI Studio/Components/Blocks/InnerScrolling.razor.cs @@ -1,12 +1,15 @@ +using AIStudio.Components.Layout; +using AIStudio.Tools; + using Microsoft.AspNetCore.Components; namespace AIStudio.Components.Blocks; -public partial class InnerScrolling : ComponentBase +public partial class InnerScrolling : MSGComponentBase { /// /// Set the height of anything above the scrolling content; usually a header. - /// What we do is calc(100vh - THIS). Means, you can use multiple measures like + /// What we do is calc(100vh - HeaderHeight). Means, you can use multiple measures like /// 230px - 3em. Default is 3em. /// [Parameter] @@ -21,5 +24,34 @@ public partial class InnerScrolling : ComponentBase [Parameter] public RenderFragment? FooterContent { get; set; } - private string Height => $"height: calc(100vh - {this.HeaderHeight});"; + [CascadingParameter] + private MainLayout MainLayout { get; set; } = null!; + + #region Overrides of ComponentBase + + protected override async Task OnInitializedAsync() + { + this.ApplyFilters([], [ Event.STATE_HAS_CHANGED ]); + await base.OnInitializedAsync(); + } + + #endregion + + #region Overrides of MSGComponentBase + + public override Task ProcessMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default + { + switch (triggeredEvent) + { + case Event.STATE_HAS_CHANGED: + this.StateHasChanged(); + break; + } + + return Task.CompletedTask; + } + + #endregion + + private string Height => $"height: calc(100vh - {this.HeaderHeight} - {this.MainLayout.AdditionalHeight});"; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/CommonDialogs/UpdateDialog.razor b/app/MindWork AI Studio/Components/CommonDialogs/UpdateDialog.razor new file mode 100644 index 00000000..80544496 --- /dev/null +++ b/app/MindWork AI Studio/Components/CommonDialogs/UpdateDialog.razor @@ -0,0 +1,14 @@ +@using AIStudio.Tools + + + + + Update from v@(META_DATA.Version) to v@(this.UpdateResponse.NewVersion) + + + + + Install later + Install now + + \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/CommonDialogs/UpdateDialog.razor.cs b/app/MindWork AI Studio/Components/CommonDialogs/UpdateDialog.razor.cs new file mode 100644 index 00000000..340788d4 --- /dev/null +++ b/app/MindWork AI Studio/Components/CommonDialogs/UpdateDialog.razor.cs @@ -0,0 +1,26 @@ +using System.Reflection; + +using AIStudio.Tools; + +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Components.CommonDialogs; + +/// +/// The update dialog that is used to inform the user about an available update. +/// +public partial class UpdateDialog : ComponentBase +{ + private static readonly Assembly ASSEMBLY = Assembly.GetExecutingAssembly(); + private static readonly MetaDataAttribute META_DATA = ASSEMBLY.GetCustomAttribute()!; + + [CascadingParameter] + private MudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] + public UpdateResponse UpdateResponse { get; set; } + + private void Cancel() => this.MudDialog.Cancel(); + + private void Confirm() => this.MudDialog.Close(DialogResult.Ok(true)); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ConfigurationSelectData.cs b/app/MindWork AI Studio/Components/ConfigurationSelectData.cs index 1fb9a573..1e77a1d4 100644 --- a/app/MindWork AI Studio/Components/ConfigurationSelectData.cs +++ b/app/MindWork AI Studio/Components/ConfigurationSelectData.cs @@ -21,4 +21,13 @@ public static class ConfigurationSelectDataFactory yield return new("Modifier key + enter is sending the input", SendBehavior.MODIFER_ENTER_IS_SENDING); yield return new("Enter is sending the input", SendBehavior.ENTER_IS_SENDING); } + + public static IEnumerable> GetUpdateBehaviorData() + { + yield return new("No automatic update checks", UpdateBehavior.NO_CHECK); + yield return new("Once at startup", UpdateBehavior.ONCE_STARTUP); + yield return new("Check every hour", UpdateBehavior.HOURLY); + yield return new("Check every day", UpdateBehavior.DAILY); + yield return new ("Check every week", UpdateBehavior.WEEKLY); + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Layout/MainLayout.razor b/app/MindWork AI Studio/Components/Layout/MainLayout.razor index 8a0b6a42..8843c671 100644 --- a/app/MindWork AI Studio/Components/Layout/MainLayout.razor +++ b/app/MindWork AI Studio/Components/Layout/MainLayout.razor @@ -2,31 +2,57 @@ - - - - - Home - - - Chats - - - Supporters - - - About - - - Settings - - - - + @if (!this.performingUpdate) + { + + + + + Home + + + Chats + + + Supporters + + + About + + + Settings + + + + + } - @this.Body + @if (!this.performingUpdate && this.IsUpdateAlertVisible) + { + +
+ + An update to version @this.updateToVersion is available. + + Show details + +
+
+ } + + @if (!this.performingUpdate) + { + + @this.Body + + } + + + Please wait for the update to complete... + +
diff --git a/app/MindWork AI Studio/Components/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Components/Layout/MainLayout.razor.cs index de25ecd7..609f8b95 100644 --- a/app/MindWork AI Studio/Components/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Components/Layout/MainLayout.razor.cs @@ -1,12 +1,37 @@ +using AIStudio.Components.CommonDialogs; using AIStudio.Settings; +using AIStudio.Tools; + using Microsoft.AspNetCore.Components; +using DialogOptions = AIStudio.Components.CommonDialogs.DialogOptions; + namespace AIStudio.Components.Layout; -public partial class MainLayout : LayoutComponentBase +public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver { [Inject] - private IJSRuntime JsRuntime { get; set; } = null!; + private IJSRuntime JsRuntime { get; init; } = null!; + + [Inject] + private SettingsManager SettingsManager { get; init; } = null!; + + [Inject] + private MessageBus MessageBus { get; init; } = null!; + + [Inject] + public IDialogService DialogService { get; init; } = null!; + + [Inject] + public Rust Rust { get; init; } = null!; + + public string AdditionalHeight { get; private set; } = "0em"; + + private bool isUpdateAvailable; + private bool performingUpdate; + private bool userDismissedUpdate; + private string updateToVersion = string.Empty; + private UpdateResponse? currentUpdateResponse; #region Overrides of ComponentBase @@ -23,8 +48,98 @@ public partial class MainLayout : LayoutComponentBase SettingsManager.ConfigDirectory = configDir; SettingsManager.DataDirectory = dataDir; + // Ensure that all settings are loaded: + await this.SettingsManager.LoadSettings(); + + // Register this component with the message bus: + this.MessageBus.RegisterComponent(this); + this.MessageBus.ApplyFilters(this, [], [ Event.UPDATE_AVAILABLE, Event.USER_SEARCH_FOR_UPDATE ]); + + // Set the js runtime for the update service: + UpdateService.SetJsRuntime(this.JsRuntime); + await base.OnInitializedAsync(); } #endregion + + #region Implementation of IMessageBusReceiver + + public async Task ProcessMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) + { + switch (triggeredEvent) + { + case Event.USER_SEARCH_FOR_UPDATE: + this.userDismissedUpdate = false; + break; + + case Event.UPDATE_AVAILABLE: + if (data is UpdateResponse updateResponse) + { + this.currentUpdateResponse = updateResponse; + this.isUpdateAvailable = updateResponse.UpdateIsAvailable; + this.updateToVersion = updateResponse.NewVersion; + + await this.InvokeAsync(this.StateHasChanged); + await this.SendMessage(Event.STATE_HAS_CHANGED); + } + + break; + } + } + + #endregion + + private async Task DismissUpdate() + { + this.userDismissedUpdate = true; + this.AdditionalHeight = "0em"; + + await this.SendMessage(Event.STATE_HAS_CHANGED); + } + + private bool IsUpdateAlertVisible + { + get + { + var state = this.isUpdateAvailable && !this.userDismissedUpdate; + this.AdditionalHeight = state ? "3em" : "0em"; + + return state; + } + } + + private async Task ShowUpdateDialog() + { + if(this.currentUpdateResponse is null) + return; + + // + // Replace the fir line with `# Changelog`: + // + var changelog = this.currentUpdateResponse.Value.Changelog; + if (!string.IsNullOrWhiteSpace(changelog)) + { + var lines = changelog.Split('\n'); + if (lines.Length > 0) + lines[0] = "# Changelog"; + + changelog = string.Join('\n', lines); + } + + var updatedResponse = this.currentUpdateResponse.Value with { Changelog = changelog }; + var dialogParameters = new DialogParameters + { + { x => x.UpdateResponse, updatedResponse } + }; + + var dialogReference = await this.DialogService.ShowAsync("Update", dialogParameters, DialogOptions.FULLSCREEN_NO_HEADER); + var dialogResult = await dialogReference.Result; + if (dialogResult.Canceled) + return; + + this.performingUpdate = true; + this.StateHasChanged(); + await this.Rust.InstallUpdate(this.JsRuntime); + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Pages/About.razor b/app/MindWork AI Studio/Components/Pages/About.razor index fbd31df5..a5cef1ae 100644 --- a/app/MindWork AI Studio/Components/Pages/About.razor +++ b/app/MindWork AI Studio/Components/Pages/About.razor @@ -18,6 +18,9 @@ + + Check for updates + diff --git a/app/MindWork AI Studio/Components/Pages/About.razor.cs b/app/MindWork AI Studio/Components/Pages/About.razor.cs index d1bfc3c5..e9f74fea 100644 --- a/app/MindWork AI Studio/Components/Pages/About.razor.cs +++ b/app/MindWork AI Studio/Components/Pages/About.razor.cs @@ -1,11 +1,16 @@ using System.Reflection; +using AIStudio.Tools; + using Microsoft.AspNetCore.Components; namespace AIStudio.Components.Pages; public partial class About : ComponentBase { + [Inject] + private MessageBus MessageBus { get; init; } = null!; + private static readonly Assembly ASSEMBLY = Assembly.GetExecutingAssembly(); private static readonly MetaDataAttribute META_DATA = ASSEMBLY.GetCustomAttribute()!; @@ -135,4 +140,9 @@ public partial class About : ComponentBase OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """; + + private async Task CheckForUpdate() + { + await this.MessageBus.SendMessage(this, Event.USER_SEARCH_FOR_UPDATE); + } } diff --git a/app/MindWork AI Studio/Components/Pages/Settings.razor b/app/MindWork AI Studio/Components/Pages/Settings.razor index 667f4d49..fdc51e4b 100644 --- a/app/MindWork AI Studio/Components/Pages/Settings.razor +++ b/app/MindWork AI Studio/Components/Pages/Settings.razor @@ -52,5 +52,6 @@ +
\ No newline at end of file diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index 0edb1520..4c1ad4a2 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -30,6 +30,7 @@ builder.Services.AddSingleton(); builder.Services.AddMudMarkdownClipboardService(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddHostedService(); builder.Services.AddRazorComponents() .AddInteractiveServerComponents() .AddHubOptions(options => diff --git a/app/MindWork AI Studio/Settings/Data.cs b/app/MindWork AI Studio/Settings/Data.cs index 1c629b80..8052c271 100644 --- a/app/MindWork AI Studio/Settings/Data.cs +++ b/app/MindWork AI Studio/Settings/Data.cs @@ -6,7 +6,7 @@ namespace AIStudio.Settings; public sealed class Data { /// - /// The version of the settings file. Allows us to upgrade the settings, + /// The version of the settings file. Allows us to upgrade the settings /// when a new version is available. /// public Version Version { get; init; } = Version.V1; @@ -36,4 +36,9 @@ public sealed class Data /// Should we enable spellchecking for all input fields? /// public bool EnableSpellchecking { get; set; } + + /// + /// If and when we should look for updates. + /// + public UpdateBehavior UpdateBehavior { get; set; } = UpdateBehavior.ONCE_STARTUP; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/UpdateBehavior.cs b/app/MindWork AI Studio/Settings/UpdateBehavior.cs new file mode 100644 index 00000000..30579f76 --- /dev/null +++ b/app/MindWork AI Studio/Settings/UpdateBehavior.cs @@ -0,0 +1,10 @@ +namespace AIStudio.Settings; + +public enum UpdateBehavior +{ + NO_CHECK, + ONCE_STARTUP, + HOURLY, + DAILY, + WEEKLY, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/MessageBus.cs b/app/MindWork AI Studio/Tools/MessageBus.cs index 6b584c42..53b215c7 100644 --- a/app/MindWork AI Studio/Tools/MessageBus.cs +++ b/app/MindWork AI Studio/Tools/MessageBus.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using Microsoft.AspNetCore.Components; +// ReSharper disable RedundantRecordClassKeyword namespace AIStudio.Tools; diff --git a/app/MindWork AI Studio/Tools/Rust.cs b/app/MindWork AI Studio/Tools/Rust.cs index 9763b39d..af85765d 100644 --- a/app/MindWork AI Studio/Tools/Rust.cs +++ b/app/MindWork AI Studio/Tools/Rust.cs @@ -39,4 +39,16 @@ public sealed class Rust }; }); } + + public async Task CheckForUpdate(IJSRuntime jsRuntime) + { + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(16)); + return await jsRuntime.InvokeAsync("window.__TAURI__.invoke", cts.Token, "check_for_update"); + } + + public async Task InstallUpdate(IJSRuntime jsRuntime) + { + var cts = new CancellationTokenSource(); + await jsRuntime.InvokeVoidAsync("window.__TAURI__.invoke", cts.Token, "install_update"); + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/SetClipboardResponse.cs b/app/MindWork AI Studio/Tools/SetClipboardResponse.cs index 7a65caa9..e7a8fcc3 100644 --- a/app/MindWork AI Studio/Tools/SetClipboardResponse.cs +++ b/app/MindWork AI Studio/Tools/SetClipboardResponse.cs @@ -3,6 +3,6 @@ namespace AIStudio.Tools; /// /// The response from the set clipboard operation. /// -/// True when the operation was successful. -/// The issue that occurred during the operation, empty when successful. +/// True, when the operation was successful. +/// The issues, which occurred during the operation, empty when successful. public readonly record struct SetClipboardResponse(bool Success, string Issue); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/UpdateResponse.cs b/app/MindWork AI Studio/Tools/UpdateResponse.cs new file mode 100644 index 00000000..5a5e3e2b --- /dev/null +++ b/app/MindWork AI Studio/Tools/UpdateResponse.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace AIStudio.Tools; + +/// +/// The response of the update check. +/// +/// True if an update is available. +/// The new version, when available. +/// The changelog of the new version, when available. +public readonly record struct UpdateResponse( + [property:JsonPropertyName("update_is_available")] bool UpdateIsAvailable, + [property:JsonPropertyName("error")] bool Error, + [property:JsonPropertyName("new_version")] string NewVersion, + string Changelog +); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/UpdateService.cs b/app/MindWork AI Studio/Tools/UpdateService.cs new file mode 100644 index 00000000..4b7143df --- /dev/null +++ b/app/MindWork AI Studio/Tools/UpdateService.cs @@ -0,0 +1,105 @@ +using AIStudio.Settings; + +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Tools; + +public sealed class UpdateService : BackgroundService, IMessageBusReceiver +{ + // We cannot inject IJSRuntime into our service. This is due to the fact that + // the service is not a Blaozor component. We need to pass the IJSRuntime from + // the MainLayout component to the service. + private static IJSRuntime? JS_RUNTIME; + private static bool IS_INITALIZED; + + private readonly SettingsManager settingsManager; + private readonly TimeSpan updateInterval; + private readonly MessageBus messageBus; + private readonly Rust rust; + + public UpdateService(MessageBus messageBus, SettingsManager settingsManager, Rust rust) + { + this.settingsManager = settingsManager; + this.messageBus = messageBus; + this.rust = rust; + + this.messageBus.RegisterComponent(this); + this.ApplyFilters([], [ Event.USER_SEARCH_FOR_UPDATE ]); + + this.updateInterval = settingsManager.ConfigurationData.UpdateBehavior switch + { + UpdateBehavior.NO_CHECK => Timeout.InfiniteTimeSpan, + UpdateBehavior.ONCE_STARTUP => Timeout.InfiniteTimeSpan, + + UpdateBehavior.HOURLY => TimeSpan.FromHours(1), + UpdateBehavior.DAILY => TimeSpan.FromDays(1), + UpdateBehavior.WEEKLY => TimeSpan.FromDays(7), + + _ => TimeSpan.FromHours(1) + }; + } + + #region Overrides of BackgroundService + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested && !IS_INITALIZED) + { + await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); + } + + await this.settingsManager.LoadSettings(); + if(this.settingsManager.ConfigurationData.UpdateBehavior != UpdateBehavior.NO_CHECK) + await this.CheckForUpdate(); + + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(this.updateInterval, stoppingToken); + await this.CheckForUpdate(); + } + } + + #endregion + + #region Implementation of IMessageBusReceiver + + public async Task ProcessMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) + { + switch (triggeredEvent) + { + case Event.USER_SEARCH_FOR_UPDATE: + await this.CheckForUpdate(); + break; + } + } + + #endregion + + #region Overrides of BackgroundService + + public override async Task StopAsync(CancellationToken cancellationToken) + { + this.messageBus.Unregister(this); + await base.StopAsync(cancellationToken); + } + + #endregion + + private async Task CheckForUpdate() + { + if(!IS_INITALIZED) + return; + + var response = await this.rust.CheckForUpdate(JS_RUNTIME!); + if (response.UpdateIsAvailable) + { + await this.messageBus.SendMessage(null, Event.UPDATE_AVAILABLE, response); + } + } + + public static void SetJsRuntime(IJSRuntime jsRuntime) + { + JS_RUNTIME = jsRuntime; + IS_INITALIZED = true; + } +} \ No newline at end of file diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index 3d46acbe..de5a363c 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -2319,6 +2319,7 @@ dependencies = [ "flexi_logger", "keyring", "log", + "once_cell", "reqwest 0.12.4", "serde", "serde_json", diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index e2bb8483..c5095fb6 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -18,6 +18,7 @@ arboard = "3.4.0" tokio = "1.37.0" flexi_logger = "0.28" log = "0.4" +once_cell = "1.19.0" [target.'cfg(target_os = "linux")'.dependencies] # See issue https://github.com/tauri-apps/tauri/issues/4470 diff --git a/runtime/src/main.rs b/runtime/src/main.rs index 0c63bc7a..57d9a4e7 100644 --- a/runtime/src/main.rs +++ b/runtime/src/main.rs @@ -5,6 +5,7 @@ extern crate core; use std::net::TcpListener; use std::sync::{Arc, Mutex}; +use once_cell::sync::Lazy; use arboard::Clipboard; use keyring::Entry; @@ -15,6 +16,11 @@ use tauri::utils::config::AppUrl; use tokio::time; use flexi_logger::{AdaptiveFormat, Logger}; use log::{debug, error, info, warn}; +use tauri::updater::UpdateResponse; + +static SERVER: Lazy>>> = Lazy::new(|| Arc::new(Mutex::new(None))); +static MAIN_WINDOW: Lazy>> = Lazy::new(|| Mutex::new(None)); +static CHECK_UPDATE_RESPONSE: Lazy>>> = Lazy::new(|| Mutex::new(None)); fn main() { @@ -30,7 +36,14 @@ fn main() { let tauri_version = metadata_lines.next().unwrap(); let app_commit_hash = metadata_lines.next().unwrap(); - Logger::try_with_str("debug").expect("Cannot create logging") + // Set the log level according to the environment: + // In debug mode, the log level is set to debug, in release mode to info. + let log_level = match is_dev() { + true => "debug", + false => "info", + }; + + Logger::try_with_str(log_level).expect("Cannot create logging") .log_to_stdout() .adaptive_format_for_stdout(AdaptiveFormat::Detailed) .start().expect("Cannot start logging"); @@ -69,8 +82,7 @@ fn main() { info!("Try to start the .NET server on {app_url_log}..."); // Arc for the server process to stop it later: - let server: Arc>> = Arc::new(Mutex::new(None)); - let server_spawn_clone = server.clone(); + let server_spawn_clone = SERVER.clone(); // Channel to communicate with the server process: let (sender, mut receiver) = tauri::async_runtime::channel(100); @@ -114,9 +126,8 @@ fn main() { warn!("Running in development mode, no .NET server will be started."); } - let main_window: Arc>> = Arc::new(Mutex::new(None)); - let main_window_spawn_clone = main_window.clone(); - let server_receive_clone = server.clone(); + let main_window_spawn_clone = &MAIN_WINDOW; + let server_receive_clone = SERVER.clone(); // Create a thread to handle server events: tauri::async_runtime::spawn(async move { @@ -181,29 +192,93 @@ fn main() { }); info!("Starting Tauri app..."); - tauri::Builder::default() + let app = tauri::Builder::default() .setup(move |app| { let window = app.get_window("main").expect("Failed to get main window."); - *main_window.lock().unwrap() = Some(window); + *MAIN_WINDOW.lock().unwrap() = Some(window); Ok(()) }) .plugin(tauri_plugin_window_state::Builder::default().build()) - .invoke_handler(tauri::generate_handler![store_secret, get_secret, delete_secret, set_clipboard]) - .run(tauri::generate_context!()) + .invoke_handler(tauri::generate_handler![ + store_secret, get_secret, delete_secret, set_clipboard, + check_for_update, install_update + ]) + .build(tauri::generate_context!()) .expect("Error while running Tauri application"); + + app.run(|app_handle, event| match event { + + tauri::RunEvent::WindowEvent { event, label, .. } => { + match event { + tauri::WindowEvent::CloseRequested { .. } => { + warn!("Window '{label}': close was requested."); + } + + tauri::WindowEvent::Destroyed => { + warn!("Window '{label}': was destroyed."); + } + + tauri::WindowEvent::FileDrop(files) => { + info!("Window '{label}': files were dropped: {files:?}"); + } + + _ => (), + } + } + + tauri::RunEvent::Updater(updater_event) => { + match updater_event { + + tauri::UpdaterEvent::UpdateAvailable { body, date, version } => { + let body_len = body.len(); + info!("Updater: update available: body size={body_len} time={date:?} version={version}"); + } + + tauri::UpdaterEvent::Pending => { + info!("Updater: update is pending!"); + } + + tauri::UpdaterEvent::DownloadProgress { chunk_length, content_length } => { + info!("Updater: downloaded {} of {:?}", chunk_length, content_length); + } + + tauri::UpdaterEvent::Downloaded => { + info!("Updater: update has been downloaded!"); + warn!("Try to stop the .NET server now..."); + stop_server(); + } + + tauri::UpdaterEvent::Updated => { + info!("Updater: app has been updated"); + warn!("Try to restart the app now..."); + app_handle.restart(); + } + + tauri::UpdaterEvent::AlreadyUpToDate => { + info!("Updater: app is already up to date"); + } + + tauri::UpdaterEvent::Error(error) => { + warn!("Updater: failed to update: {error}"); + } + } + } + + tauri::RunEvent::ExitRequested { .. } => { + warn!("Run event: exit was requested."); + } + + tauri::RunEvent::Ready => { + info!("Run event: Tauri app is ready."); + } + + _ => {} + }); info!("Tauri app was stopped."); if is_prod() { info!("Try to stop the .NET server as well..."); - if let Some(server_process) = server.lock().unwrap().take() { - let server_kill_result = server_process.kill(); - match server_kill_result { - Ok(_) => info!("The .NET server process was stopped."), - Err(e) => error!("Failed to stop the .NET server process: {e}."), - } - } else { - warn!("The .NET server process was not started or already stopped."); - } + stop_server(); } } @@ -230,6 +305,87 @@ fn get_available_port() -> Option { .ok() } +fn stop_server() { + if let Some(server_process) = SERVER.lock().unwrap().take() { + let server_kill_result = server_process.kill(); + match server_kill_result { + Ok(_) => info!("The .NET server process was stopped."), + Err(e) => error!("Failed to stop the .NET server process: {e}."), + } + } else { + warn!("The .NET server process was not started or already stopped."); + } +} + +#[tauri::command] +async fn check_for_update() -> CheckUpdateResponse { + let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().unwrap().app_handle(); + tauri::async_runtime::spawn(async move { + let response = app_handle.updater().check().await; + match response { + Ok(update_response) => match update_response.is_update_available() { + true => { + *CHECK_UPDATE_RESPONSE.lock().unwrap() = Some(update_response.clone()); + let new_version = update_response.latest_version(); + info!("Updater: update to version '{new_version}' is available."); + let changelog = update_response.body(); + CheckUpdateResponse { + update_is_available: true, + error: false, + new_version: new_version.to_string(), + changelog: match changelog { + Some(c) => c.to_string(), + None => String::from(""), + }, + } + }, + + false => { + info!("Updater: no updates available."); + CheckUpdateResponse { + update_is_available: false, + error: false, + new_version: String::from(""), + changelog: String::from(""), + } + }, + }, + + Err(e) => { + warn!("Failed to check updater: {e}."); + CheckUpdateResponse { + update_is_available: false, + error: true, + new_version: String::from(""), + changelog: String::from(""), + } + }, + } + }).await.unwrap() +} + +#[derive(Serialize)] +struct CheckUpdateResponse { + update_is_available: bool, + error: bool, + new_version: String, + changelog: String, +} + +#[tauri::command] +async fn install_update() { + let cloned_response_option = CHECK_UPDATE_RESPONSE.lock().unwrap().clone(); + match cloned_response_option { + Some(update_response) => { + update_response.download_and_install().await.unwrap(); + }, + + None => { + error!("Update installer: no update available to install. Did you check for updates first?"); + }, + } +} + #[tauri::command] fn store_secret(destination: String, user_name: String, secret: String) -> StoreSecretResponse { let service = format!("mindwork-ai-studio::{}", destination); diff --git a/runtime/tauri.conf.json b/runtime/tauri.conf.json index 1c7b2212..63788588 100644 --- a/runtime/tauri.conf.json +++ b/runtime/tauri.conf.json @@ -76,7 +76,7 @@ "endpoints": [ "https://github.com/MindWorkAI/AI-Studio/releases/latest/download/latest.json" ], - "dialog": true, + "dialog": false, "windows": { "installMode": "passive" },