Added update system to the app (#13)

This commit is contained in:
Thorsten Sommer 2024-06-30 15:26:28 +02:00 committed by GitHub
parent 50a03e6c9d
commit d6e80a4563
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 786 additions and 81 deletions

View File

@ -1,2 +1,4 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=AI/@EntryIndexedValue">AI</s:String></wpf:ResourceDictionary>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=AI/@EntryIndexedValue">AI</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=MSG/@EntryIndexedValue">MSG</s:String>
<s:Boolean x:Key="/Default/UserDictionary/Words/=tauri_0027s/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View File

@ -13,8 +13,9 @@ public partial class Changelog
public static readonly Log[] LOGS =
[
new (156, "v0.6.0, build 156 (2024-06-30 12:49 UTC)", "v0.6.0.md"),
new (155, "v0.5.2, build 155 (2024-06-25 18:07 UTC)", "v0.5.2.md"),
new (154, "v0.5.1, build 154 (2024-06-25 15:35 UTC)", "v0.5.1.md"),
new (154, "v0.5.2, build 154 (2024-06-25 15:35 UTC)", "v0.5.2.md"),
new (149, "v0.5.0, build 149 (2024-06-02 18:51 UTC)", "v0.5.0.md"),
new (138, "v0.4.0, build 138 (2024-05-26 13:26 UTC)", "v0.4.0.md"),
new (120, "v0.3.0, build 120 (2024-05-18 21:57 UTC)", "v0.3.0.md"),

View File

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

View File

@ -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
{
/// <summary>
/// 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.
/// </summary>
[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<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,17 @@
namespace AIStudio.Components.CommonDialogs;
public static class DialogOptions
{
public static readonly MudBlazor.DialogOptions FULLSCREEN = new()
{
CloseOnEscapeKey = true,
FullWidth = true, MaxWidth = MaxWidth.Medium,
};
public static readonly MudBlazor.DialogOptions FULLSCREEN_NO_HEADER = new()
{
NoHeader = true,
CloseOnEscapeKey = true,
FullWidth = true, MaxWidth = MaxWidth.Medium,
};
}

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("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,31 +2,57 @@
<MudPaper Height="calc(100vh);" Elevation="0">
<MudLayout>
<MudDrawerContainer Class="mud-height-full absolute">
<MudDrawer Elevation="0" Variant="@DrawerVariant.Mini" OpenMiniOnHover="@true" Color="Color.Default">
<MudNavMenu>
<MudTooltip Text="Home" Placement="Placement.Right">
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Home">Home</MudNavLink>
</MudTooltip>
<MudTooltip Text="Chats" Placement="Placement.Right">
<MudNavLink Href="/chat" Icon="@Icons.Material.Filled.Chat">Chats</MudNavLink>
</MudTooltip>
<MudTooltip Text="Supporters" Placement="Placement.Right">
<MudNavLink Href="/supporters" Icon="@Icons.Material.Filled.Favorite" IconColor="Color.Error">Supporters</MudNavLink>
</MudTooltip>
<MudTooltip Text="About" Placement="Placement.Right">
<MudNavLink Href="/about" Icon="@Icons.Material.Filled.Info">About</MudNavLink>
</MudTooltip>
<MudTooltip Text="Settings" Placement="Placement.Right">
<MudNavLink Href="/settings" Icon="@Icons.Material.Filled.Settings">Settings</MudNavLink>
</MudTooltip>
</MudNavMenu>
</MudDrawer>
</MudDrawerContainer>
@if (!this.performingUpdate)
{
<MudDrawerContainer Class="mud-height-full absolute">
<MudDrawer Elevation="0" Variant="@DrawerVariant.Mini" OpenMiniOnHover="@true" Color="Color.Default">
<MudNavMenu>
<MudTooltip Text="Home" Placement="Placement.Right">
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Home">Home</MudNavLink>
</MudTooltip>
<MudTooltip Text="Chats" Placement="Placement.Right">
<MudNavLink Href="/chat" Icon="@Icons.Material.Filled.Chat">Chats</MudNavLink>
</MudTooltip>
<MudTooltip Text="Supporters" Placement="Placement.Right">
<MudNavLink Href="/supporters" Icon="@Icons.Material.Filled.Favorite" IconColor="Color.Error">Supporters</MudNavLink>
</MudTooltip>
<MudTooltip Text="About" Placement="Placement.Right">
<MudNavLink Href="/about" Icon="@Icons.Material.Filled.Info">About</MudNavLink>
</MudTooltip>
<MudTooltip Text="Settings" Placement="Placement.Right">
<MudNavLink Href="/settings" Icon="@Icons.Material.Filled.Settings">Settings</MudNavLink>
</MudTooltip>
</MudNavMenu>
</MudDrawer>
</MudDrawerContainer>
}
<MudMainContent Class="mud-height-full pt-1">
<MudContainer Fixed="@true" Class="mud-height-full" Style="margin-left: 5em; width: calc(100% - 5em);">
@this.Body
@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
</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>
</MudMainContent>
</MudLayout>

View File

@ -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<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.Memory" Text="@TauriVersion"/>
</MudList>
<MudButton Variant="Variant.Filled" Color="Color.Info" StartIcon="@Icons.Material.Filled.Update" OnClick="() => this.CheckForUpdate()">
Check for updates
</MudButton>
</ExpansionPanel>
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.EventNote" HeaderText="Changelog">

View File

@ -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<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
SOFTWARE.
""";
private async Task CheckForUpdate()
{
await this.MessageBus.SendMessage<bool>(this, Event.USER_SEARCH_FOR_UPDATE);
}
}

View File

@ -37,9 +37,6 @@ public partial class Chat : ComponentBase
protected override async Task OnInitializedAsync()
{
// Ensure that the settings are loaded:
await this.SettingsManager.LoadSettings();
// Configure the spellchecking for the user input:
this.SettingsManager.InjectSpellchecking(USER_INPUT_ATTRIBUTES);

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="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="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>
</InnerScrolling>

View File

@ -3,6 +3,8 @@ using AIStudio.Provider;
using AIStudio.Settings;
using Microsoft.AspNetCore.Components;
using DialogOptions = AIStudio.Components.CommonDialogs.DialogOptions;
// ReSharper disable ClassNeverInstantiated.Global
namespace AIStudio.Components.Pages;
@ -18,22 +20,6 @@ public partial class Settings : ComponentBase
[Inject]
public IJSRuntime JsRuntime { get; init; } = null!;
private static readonly DialogOptions DIALOG_OPTIONS = new()
{
CloseOnEscapeKey = true,
FullWidth = true, MaxWidth = MaxWidth.Medium,
};
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
await this.SettingsManager.LoadSettings();
await base.OnInitializedAsync();
}
#endregion
#region Provider related
private async Task AddProvider()
@ -43,7 +29,7 @@ public partial class Settings : ComponentBase
{ x => x.IsEditing, false },
};
var dialogReference = await this.DialogService.ShowAsync<ProviderDialog>("Add Provider", dialogParameters, DIALOG_OPTIONS);
var dialogReference = await this.DialogService.ShowAsync<ProviderDialog>("Add Provider", dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult.Canceled)
return;
@ -67,7 +53,7 @@ public partial class Settings : ComponentBase
{ x => x.IsEditing, true },
};
var dialogReference = await this.DialogService.ShowAsync<ProviderDialog>("Edit Provider", dialogParameters, DIALOG_OPTIONS);
var dialogReference = await this.DialogService.ShowAsync<ProviderDialog>("Edit Provider", dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult.Canceled)
return;
@ -90,7 +76,7 @@ public partial class Settings : ComponentBase
{ "Message", $"Are you sure you want to delete the provider '{provider.InstanceName}'?" },
};
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>("Delete Provider", dialogParameters, DIALOG_OPTIONS);
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>("Delete Provider", dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult.Canceled)
return;

View File

@ -25,10 +25,12 @@ builder.Services.AddMudServices(config =>
});
builder.Services.AddMudMarkdownServices();
builder.Services.AddSingleton(MessageBus.INSTANCE);
builder.Services.AddSingleton<Rust>();
builder.Services.AddMudMarkdownClipboardService<MarkdownClipboardService>();
builder.Services.AddSingleton<SettingsManager>();
builder.Services.AddSingleton<Random>();
builder.Services.AddHostedService<UpdateService>();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddHubOptions(options =>

View File

@ -6,7 +6,7 @@ namespace AIStudio.Settings;
public sealed class Data
{
/// <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.
/// </summary>
public Version Version { get; init; } = Version.V1;
@ -36,4 +36,9 @@ public sealed class Data
/// Should we enable spellchecking for all input fields?
/// </summary>
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

@ -0,0 +1,13 @@
namespace AIStudio.Tools;
public enum Event
{
NONE,
// Common events:
STATE_HAS_CHANGED,
// Update events:
USER_SEARCH_FOR_UPDATE,
UPDATE_AVAILABLE,
}

View File

@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Tools;
public interface IMessageBusReceiver
{
public Task ProcessMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data);
}

View File

@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Tools;
public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBusReceiver
{
[Inject]
protected MessageBus MessageBus { get; init; } = null!;
#region Overrides of ComponentBase
protected override void OnInitialized()
{
this.MessageBus.RegisterComponent(this);
base.OnInitialized();
}
#endregion
#region Implementation of IMessageBusReceiver
public abstract Task ProcessMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data);
#endregion
#region Implementation of IDisposable
public void Dispose()
{
this.MessageBus.Unregister(this);
}
#endregion
protected async Task SendMessage<T>(Event triggeredEvent, T? data = default)
{
await this.MessageBus.SendMessage(this, triggeredEvent, data);
}
protected void ApplyFilters(ComponentBase[] components, Event[] events)
{
this.MessageBus.ApplyFilters(this, components, events);
}
}

View File

@ -0,0 +1,67 @@
using System.Collections.Concurrent;
using Microsoft.AspNetCore.Components;
// ReSharper disable RedundantRecordClassKeyword
namespace AIStudio.Tools;
public sealed class MessageBus
{
public static readonly MessageBus INSTANCE = new();
private readonly ConcurrentDictionary<IMessageBusReceiver, ComponentBase[]> componentFilters = new();
private readonly ConcurrentDictionary<IMessageBusReceiver, Event[]> componentEvents = new();
private readonly ConcurrentQueue<Message> messageQueue = new();
private readonly SemaphoreSlim sendingSemaphore = new(1, 1);
private MessageBus()
{
}
public void ApplyFilters(IMessageBusReceiver receiver, ComponentBase[] components, Event[] events)
{
this.componentFilters[receiver] = components;
this.componentEvents[receiver] = events;
}
public void RegisterComponent(IMessageBusReceiver receiver)
{
this.componentFilters.TryAdd(receiver, []);
this.componentEvents.TryAdd(receiver, []);
}
public void Unregister(IMessageBusReceiver receiver)
{
this.componentFilters.TryRemove(receiver, out _);
this.componentEvents.TryRemove(receiver, out _);
}
private record class Message(ComponentBase? SendingComponent, Event TriggeredEvent, object? Data);
public async Task SendMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data = default)
{
this.messageQueue.Enqueue(new Message(sendingComponent, triggeredEvent, data));
try
{
await this.sendingSemaphore.WaitAsync();
while (this.messageQueue.TryDequeue(out var message))
{
foreach (var (receiver, componentFilter) in this.componentFilters)
{
if (componentFilter.Length > 0 && sendingComponent is not null && !componentFilter.Contains(sendingComponent))
continue;
var eventFilter = this.componentEvents[receiver];
if (eventFilter.Length == 0 || eventFilter.Contains(triggeredEvent))
// We don't await the task here because we don't want to block the message bus:
_ = receiver.ProcessMessage(message.SendingComponent, message.TriggeredEvent, message.Data);
}
}
}
finally
{
this.sendingSemaphore.Release();
}
}
}

View File

@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Tools;
public static class MessageBusExtensions
{
public static async Task SendMessage<T>(this ComponentBase component, Event triggeredEvent, T? data = default)
{
await MessageBus.INSTANCE.SendMessage(component, triggeredEvent, data);
}
public static void ApplyFilters(this IMessageBusReceiver component, ComponentBase[] components, Event[] events)
{
MessageBus.INSTANCE.ApplyFilters(component, components, events);
}
}

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>
/// The response from the set clipboard operation.
/// </summary>
/// <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="Success">True, when the operation was 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);

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;
}
}

View File

@ -0,0 +1,8 @@
# v0.6.0, build 156 (2024-06-30 12:49 UTC)
- Added a setting to determine whether and how often to check for updates
- Added a bidirectional message bus for inter-component communication
- Added an update dialog for the app
- Added an update banner to show when a new version is available
- Added an option to manually check for updates to the about page
- Fixed an issue with previous automatic updates on Windows, where background processes were not terminated
- Disabled Tauri's built-in update dialog

View File

@ -1,9 +1,9 @@
0.5.2
2024-06-25 18:07:06 UTC
155
0.6.0
2024-06-30 12:49:38 UTC
156
8.0.206 (commit bb12410699)
8.0.6 (commit 3b8b000a0e)
1.79.0 (commit 129f3b996)
6.20.0
1.6.1
2818aa93411, release
dab121a7217, release

3
runtime/Cargo.lock generated
View File

@ -2313,12 +2313,13 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mindwork-ai-studio"
version = "0.5.2"
version = "0.6.0"
dependencies = [
"arboard",
"flexi_logger",
"keyring",
"log",
"once_cell",
"reqwest 0.12.4",
"serde",
"serde_json",

View File

@ -1,6 +1,6 @@
[package]
name = "mindwork-ai-studio"
version = "0.5.2"
version = "0.6.0"
edition = "2021"
description = "MindWork AI Studio"
authors = ["Thorsten Sommer"]
@ -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

View File

@ -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<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() {
@ -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<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:
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<Mutex<Option<Window>>> = 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<u16> {
.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);

View File

@ -6,7 +6,7 @@
},
"package": {
"productName": "MindWork AI Studio",
"version": "0.5.2"
"version": "0.6.0"
},
"tauri": {
"allowlist": {
@ -76,7 +76,7 @@
"endpoints": [
"https://github.com/MindWorkAI/AI-Studio/releases/latest/download/latest.json"
],
"dialog": true,
"dialog": false,
"windows": {
"installMode": "passive"
},