Changed automatic updater to use in-app settings & dialog

This commit is contained in:
Thorsten Sommer 2024-06-30 14:29:23 +02:00
parent b5c3e94b18
commit f2343c7482
Signed by: tsommer
GPG Key ID: 371BBA77A02C0108
22 changed files with 596 additions and 50 deletions

View File

@ -1,3 +1,5 @@
@inherits AIStudio.Tools.MSGComponentBase
<div class="d-flex flex-column" style="@this.Height"> <div class="d-flex flex-column" style="@this.Height">
<div class="flex-auto overflow-auto"> <div class="flex-auto overflow-auto">
@this.ChildContent @this.ChildContent

View File

@ -1,12 +1,15 @@
using AIStudio.Components.Layout;
using AIStudio.Tools;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
namespace AIStudio.Components.Blocks; namespace AIStudio.Components.Blocks;
public partial class InnerScrolling : ComponentBase public partial class InnerScrolling : MSGComponentBase
{ {
/// <summary> /// <summary>
/// Set the height of anything above the scrolling content; usually a header. /// 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. /// 230px - 3em. Default is 3em.
/// </summary> /// </summary>
[Parameter] [Parameter]
@ -21,5 +24,34 @@ public partial class InnerScrolling : ComponentBase
[Parameter] [Parameter]
public RenderFragment? FooterContent { get; set; } 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<T>(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});";
} }

View File

@ -0,0 +1,14 @@
@using AIStudio.Tools
<MudDialog>
<DialogContent>
<MudText Typo="Typo.h4" Class="d-inline-flex align-center">
<MudIcon Icon="@Icons.Material.Filled.Update" Size="Size.Large" Class="mr-3"/>
Update from v@(META_DATA.Version) to v@(this.UpdateResponse.NewVersion)
</MudText>
<MudMarkdown Value="@this.UpdateResponse.Changelog" OverrideHeaderTypo="@Markdown.OverrideHeaderTypo"/>
</DialogContent>
<DialogActions>
<MudButton OnClick="@this.Cancel" Variant="Variant.Filled">Install later</MudButton>
<MudButton OnClick="@this.Confirm" Variant="Variant.Filled" Color="Color.Info">Install now</MudButton>
</DialogActions>
</MudDialog>

View File

@ -0,0 +1,26 @@
using System.Reflection;
using AIStudio.Tools;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components.CommonDialogs;
/// <summary>
/// The update dialog that is used to inform the user about an available update.
/// </summary>
public partial class UpdateDialog : ComponentBase
{
private static readonly Assembly ASSEMBLY = Assembly.GetExecutingAssembly();
private static readonly MetaDataAttribute META_DATA = ASSEMBLY.GetCustomAttribute<MetaDataAttribute>()!;
[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));
}

View File

@ -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("Modifier key + enter is sending the input", SendBehavior.MODIFER_ENTER_IS_SENDING);
yield return new("Enter is sending the input", SendBehavior.ENTER_IS_SENDING); yield return new("Enter is sending the input", SendBehavior.ENTER_IS_SENDING);
} }
public static IEnumerable<ConfigurationSelectData<UpdateBehavior>> 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);
}
} }

View File

@ -2,6 +2,8 @@
<MudPaper Height="calc(100vh);" Elevation="0"> <MudPaper Height="calc(100vh);" Elevation="0">
<MudLayout> <MudLayout>
@if (!this.performingUpdate)
{
<MudDrawerContainer Class="mud-height-full absolute"> <MudDrawerContainer Class="mud-height-full absolute">
<MudDrawer Elevation="0" Variant="@DrawerVariant.Mini" OpenMiniOnHover="@true" Color="Color.Default"> <MudDrawer Elevation="0" Variant="@DrawerVariant.Mini" OpenMiniOnHover="@true" Color="Color.Default">
<MudNavMenu> <MudNavMenu>
@ -23,10 +25,34 @@
</MudNavMenu> </MudNavMenu>
</MudDrawer> </MudDrawer>
</MudDrawerContainer> </MudDrawerContainer>
}
<MudMainContent Class="mud-height-full pt-1"> <MudMainContent Class="mud-height-full pt-1">
<MudContainer Fixed="@true" Class="mud-height-full" Style="margin-left: 5em; width: calc(100% - 5em);"> <MudContainer Fixed="@true" Class="mud-height-full" Style="margin-left: 5em; width: calc(100% - 5em);">
@if (!this.performingUpdate && this.IsUpdateAlertVisible)
{
<MudAlert NoIcon="@true" Severity="Severity.Info" Variant="Variant.Filled" ShowCloseIcon="@true" Dense="@true" CloseIconClicked="() => this.DismissUpdate()" Class="mt-2 mb-2">
<div class="d-inline-flex align-center">
<MudIcon Icon="@Icons.Material.Filled.Update" Size="Size.Medium" Class="mr-3"/>
An update to version @this.updateToVersion is available.
<MudButton Variant="Variant.Filled" Color="Color.Dark" Size="Size.Small" Class="ml-3" OnClick="() => this.ShowUpdateDialog()">
Show details
</MudButton>
</div>
</MudAlert>
}
@if (!this.performingUpdate)
{
<CascadingValue Value="@this" IsFixed="true">
@this.Body @this.Body
</CascadingValue>
}
<MudOverlay Visible="@this.performingUpdate" DarkBackground="@true" LockScroll="@true">
<MudText Typo="Typo.h3">Please wait for the update to complete...</MudText>
<MudProgressLinear Color="Color.Primary" Indeterminate="@true" Size="Size.Large" Rounded="@true"/>
</MudOverlay>
</MudContainer> </MudContainer>
</MudMainContent> </MudMainContent>
</MudLayout> </MudLayout>

View File

@ -1,12 +1,37 @@
using AIStudio.Components.CommonDialogs;
using AIStudio.Settings; using AIStudio.Settings;
using AIStudio.Tools;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using DialogOptions = AIStudio.Components.CommonDialogs.DialogOptions;
namespace AIStudio.Components.Layout; namespace AIStudio.Components.Layout;
public partial class MainLayout : LayoutComponentBase public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver
{ {
[Inject] [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 #region Overrides of ComponentBase
@ -23,8 +48,98 @@ public partial class MainLayout : LayoutComponentBase
SettingsManager.ConfigDirectory = configDir; SettingsManager.ConfigDirectory = configDir;
SettingsManager.DataDirectory = dataDir; 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(); await base.OnInitializedAsync();
} }
#endregion #endregion
#region Implementation of IMessageBusReceiver
public async Task ProcessMessage<T>(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<bool>(Event.STATE_HAS_CHANGED);
}
break;
}
}
#endregion
private async Task DismissUpdate()
{
this.userDismissedUpdate = true;
this.AdditionalHeight = "0em";
await this.SendMessage<bool>(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<UpdateDialog>
{
{ x => x.UpdateResponse, updatedResponse }
};
var dialogReference = await this.DialogService.ShowAsync<UpdateDialog>("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);
}
} }

View File

@ -18,6 +18,9 @@
<MudListItem Icon="@Icons.Material.Outlined.Widgets" Text="@MudBlazorVersion"/> <MudListItem Icon="@Icons.Material.Outlined.Widgets" Text="@MudBlazorVersion"/>
<MudListItem Icon="@Icons.Material.Outlined.Memory" Text="@TauriVersion"/> <MudListItem Icon="@Icons.Material.Outlined.Memory" Text="@TauriVersion"/>
</MudList> </MudList>
<MudButton Variant="Variant.Filled" Color="Color.Info" StartIcon="@Icons.Material.Filled.Update" OnClick="() => this.CheckForUpdate()">
Check for updates
</MudButton>
</ExpansionPanel> </ExpansionPanel>
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.EventNote" HeaderText="Changelog"> <ExpansionPanel HeaderIcon="@Icons.Material.Filled.EventNote" HeaderText="Changelog">

View File

@ -1,11 +1,16 @@
using System.Reflection; using System.Reflection;
using AIStudio.Tools;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
namespace AIStudio.Components.Pages; namespace AIStudio.Components.Pages;
public partial class About : ComponentBase public partial class About : ComponentBase
{ {
[Inject]
private MessageBus MessageBus { get; init; } = null!;
private static readonly Assembly ASSEMBLY = Assembly.GetExecutingAssembly(); private static readonly Assembly ASSEMBLY = Assembly.GetExecutingAssembly();
private static readonly MetaDataAttribute META_DATA = ASSEMBLY.GetCustomAttribute<MetaDataAttribute>()!; private static readonly MetaDataAttribute META_DATA = ASSEMBLY.GetCustomAttribute<MetaDataAttribute>()!;
@ -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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
"""; """;
private async Task CheckForUpdate()
{
await this.MessageBus.SendMessage<bool>(this, Event.USER_SEARCH_FOR_UPDATE);
}
} }

View File

@ -52,5 +52,6 @@
<ConfigurationOption OptionDescription="Save energy?" LabelOn="Energy saving is enabled" LabelOff="Energy saving is disabled" State="@(() => this.SettingsManager.ConfigurationData.IsSavingEnergy)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.IsSavingEnergy = updatedState)" OptionHelp="When enabled, streamed content from the AI is updated once every third second. When disabled, streamed content will be updated as soon as it is available."/> <ConfigurationOption OptionDescription="Save energy?" LabelOn="Energy saving is enabled" LabelOff="Energy saving is disabled" State="@(() => this.SettingsManager.ConfigurationData.IsSavingEnergy)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.IsSavingEnergy = updatedState)" OptionHelp="When enabled, streamed content from the AI is updated once every third second. When disabled, streamed content will be updated as soon as it is available."/>
<ConfigurationOption OptionDescription="Enable spellchecking?" LabelOn="Spellchecking is enabled" LabelOff="Spellchecking is disabled" State="@(() => this.SettingsManager.ConfigurationData.EnableSpellchecking)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.EnableSpellchecking = updatedState)" OptionHelp="When enabled, spellchecking will be active in all input fields. Depending on your operating system, errors may not be visually highlighted, but right-clicking may still offer possible corrections." /> <ConfigurationOption OptionDescription="Enable spellchecking?" LabelOn="Spellchecking is enabled" LabelOff="Spellchecking is disabled" State="@(() => this.SettingsManager.ConfigurationData.EnableSpellchecking)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.EnableSpellchecking = updatedState)" OptionHelp="When enabled, spellchecking will be active in all input fields. Depending on your operating system, errors may not be visually highlighted, but right-clicking may still offer possible corrections." />
<ConfigurationSelect OptionDescription="Shortcut to send input" SelectedValue="@(() => this.SettingsManager.ConfigurationData.ShortcutSendBehavior)" Data="@ConfigurationSelectDataFactory.GetSendBehaviorData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.ShortcutSendBehavior = selectedValue)" OptionHelp="Do you want to use any shortcut to send your input?"/> <ConfigurationSelect OptionDescription="Shortcut to send input" SelectedValue="@(() => this.SettingsManager.ConfigurationData.ShortcutSendBehavior)" Data="@ConfigurationSelectDataFactory.GetSendBehaviorData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.ShortcutSendBehavior = selectedValue)" OptionHelp="Do you want to use any shortcut to send your input?"/>
<ConfigurationSelect OptionDescription="Check for updates" SelectedValue="@(() => this.SettingsManager.ConfigurationData.UpdateBehavior)" Data="@ConfigurationSelectDataFactory.GetUpdateBehaviorData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.UpdateBehavior = selectedValue)" OptionHelp="How often should we check for app updates?"/>
</MudPaper> </MudPaper>
</InnerScrolling> </InnerScrolling>

View File

@ -30,6 +30,7 @@ builder.Services.AddSingleton<Rust>();
builder.Services.AddMudMarkdownClipboardService<MarkdownClipboardService>(); builder.Services.AddMudMarkdownClipboardService<MarkdownClipboardService>();
builder.Services.AddSingleton<SettingsManager>(); builder.Services.AddSingleton<SettingsManager>();
builder.Services.AddSingleton<Random>(); builder.Services.AddSingleton<Random>();
builder.Services.AddHostedService<UpdateService>();
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents() .AddInteractiveServerComponents()
.AddHubOptions(options => .AddHubOptions(options =>

View File

@ -6,7 +6,7 @@ namespace AIStudio.Settings;
public sealed class Data public sealed class Data
{ {
/// <summary> /// <summary>
/// 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. /// when a new version is available.
/// </summary> /// </summary>
public Version Version { get; init; } = Version.V1; public Version Version { get; init; } = Version.V1;
@ -36,4 +36,9 @@ public sealed class Data
/// Should we enable spellchecking for all input fields? /// Should we enable spellchecking for all input fields?
/// </summary> /// </summary>
public bool EnableSpellchecking { get; set; } public bool EnableSpellchecking { get; set; }
/// <summary>
/// If and when we should look for updates.
/// </summary>
public UpdateBehavior UpdateBehavior { get; set; } = UpdateBehavior.ONCE_STARTUP;
} }

View File

@ -0,0 +1,10 @@
namespace AIStudio.Settings;
public enum UpdateBehavior
{
NO_CHECK,
ONCE_STARTUP,
HOURLY,
DAILY,
WEEKLY,
}

View File

@ -1,6 +1,7 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
// ReSharper disable RedundantRecordClassKeyword
namespace AIStudio.Tools; namespace AIStudio.Tools;

View File

@ -39,4 +39,16 @@ public sealed class Rust
}; };
}); });
} }
public async Task<UpdateResponse> CheckForUpdate(IJSRuntime jsRuntime)
{
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(16));
return await jsRuntime.InvokeAsync<UpdateResponse>("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");
}
} }

View File

@ -3,6 +3,6 @@ namespace AIStudio.Tools;
/// <summary> /// <summary>
/// The response from the set clipboard operation. /// The response from the set clipboard operation.
/// </summary> /// </summary>
/// <param name="Success">True when the operation was successful.</param> /// <param name="Success">True, when the operation was successful.</param>
/// <param name="Issue">The issue that occurred during the operation, empty when successful.</param> /// <param name="Issue">The issues, which occurred during the operation, empty when successful.</param>
public readonly record struct SetClipboardResponse(bool Success, string Issue); public readonly record struct SetClipboardResponse(bool Success, string Issue);

View File

@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace AIStudio.Tools;
/// <summary>
/// The response of the update check.
/// </summary>
/// <param name="UpdateIsAvailable">True if an update is available.</param>
/// <param name="NewVersion">The new version, when available.</param>
/// <param name="Changelog">The changelog of the new version, when available.</param>
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
);

View File

@ -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<T>(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;
}
}

1
runtime/Cargo.lock generated
View File

@ -2319,6 +2319,7 @@ dependencies = [
"flexi_logger", "flexi_logger",
"keyring", "keyring",
"log", "log",
"once_cell",
"reqwest 0.12.4", "reqwest 0.12.4",
"serde", "serde",
"serde_json", "serde_json",

View File

@ -18,6 +18,7 @@ arboard = "3.4.0"
tokio = "1.37.0" tokio = "1.37.0"
flexi_logger = "0.28" flexi_logger = "0.28"
log = "0.4" log = "0.4"
once_cell = "1.19.0"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
# See issue https://github.com/tauri-apps/tauri/issues/4470 # See issue https://github.com/tauri-apps/tauri/issues/4470

View File

@ -5,6 +5,7 @@ extern crate core;
use std::net::TcpListener; use std::net::TcpListener;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use once_cell::sync::Lazy;
use arboard::Clipboard; use arboard::Clipboard;
use keyring::Entry; use keyring::Entry;
@ -15,6 +16,11 @@ use tauri::utils::config::AppUrl;
use tokio::time; use tokio::time;
use flexi_logger::{AdaptiveFormat, Logger}; use flexi_logger::{AdaptiveFormat, Logger};
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use tauri::updater::UpdateResponse;
static SERVER: Lazy<Arc<Mutex<Option<CommandChild>>>> = Lazy::new(|| Arc::new(Mutex::new(None)));
static MAIN_WINDOW: Lazy<Mutex<Option<Window>>> = Lazy::new(|| Mutex::new(None));
static CHECK_UPDATE_RESPONSE: Lazy<Mutex<Option<UpdateResponse<tauri::Wry>>>> = Lazy::new(|| Mutex::new(None));
fn main() { fn main() {
@ -30,7 +36,14 @@ fn main() {
let tauri_version = metadata_lines.next().unwrap(); let tauri_version = metadata_lines.next().unwrap();
let app_commit_hash = 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() .log_to_stdout()
.adaptive_format_for_stdout(AdaptiveFormat::Detailed) .adaptive_format_for_stdout(AdaptiveFormat::Detailed)
.start().expect("Cannot start logging"); .start().expect("Cannot start logging");
@ -69,8 +82,7 @@ fn main() {
info!("Try to start the .NET server on {app_url_log}..."); info!("Try to start the .NET server on {app_url_log}...");
// Arc for the server process to stop it later: // Arc for the server process to stop it later:
let server: Arc<Mutex<Option<CommandChild>>> = Arc::new(Mutex::new(None)); let server_spawn_clone = SERVER.clone();
let server_spawn_clone = server.clone();
// Channel to communicate with the server process: // Channel to communicate with the server process:
let (sender, mut receiver) = tauri::async_runtime::channel(100); 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."); warn!("Running in development mode, no .NET server will be started.");
} }
let main_window: Arc<Mutex<Option<Window>>> = Arc::new(Mutex::new(None)); let main_window_spawn_clone = &MAIN_WINDOW;
let main_window_spawn_clone = main_window.clone(); let server_receive_clone = SERVER.clone();
let server_receive_clone = server.clone();
// Create a thread to handle server events: // Create a thread to handle server events:
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
@ -181,29 +192,93 @@ fn main() {
}); });
info!("Starting Tauri app..."); info!("Starting Tauri app...");
tauri::Builder::default() let app = tauri::Builder::default()
.setup(move |app| { .setup(move |app| {
let window = app.get_window("main").expect("Failed to get main window."); 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(()) Ok(())
}) })
.plugin(tauri_plugin_window_state::Builder::default().build()) .plugin(tauri_plugin_window_state::Builder::default().build())
.invoke_handler(tauri::generate_handler![store_secret, get_secret, delete_secret, set_clipboard]) .invoke_handler(tauri::generate_handler![
.run(tauri::generate_context!()) store_secret, get_secret, delete_secret, set_clipboard,
check_for_update, install_update
])
.build(tauri::generate_context!())
.expect("Error while running Tauri application"); .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."); info!("Tauri app was stopped.");
if is_prod() { if is_prod() {
info!("Try to stop the .NET server as well..."); info!("Try to stop the .NET server as well...");
if let Some(server_process) = server.lock().unwrap().take() { stop_server();
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.");
}
} }
} }
@ -230,6 +305,87 @@ fn get_available_port() -> Option<u16> {
.ok() .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] #[tauri::command]
fn store_secret(destination: String, user_name: String, secret: String) -> StoreSecretResponse { fn store_secret(destination: String, user_name: String, secret: String) -> StoreSecretResponse {
let service = format!("mindwork-ai-studio::{}", destination); let service = format!("mindwork-ai-studio::{}", destination);

View File

@ -76,7 +76,7 @@
"endpoints": [ "endpoints": [
"https://github.com/MindWorkAI/AI-Studio/releases/latest/download/latest.json" "https://github.com/MindWorkAI/AI-Studio/releases/latest/download/latest.json"
], ],
"dialog": true, "dialog": false,
"windows": { "windows": {
"installMode": "passive" "installMode": "passive"
}, },