mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-31 22:30:53 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3007893843
|
||
|
|
d95ce28856
|
||
|
|
0845a3a4bb
|
||
|
|
9c9ca6e3a8
|
||
|
|
f06d2f553b
|
||
|
|
42ed1db4a4
|
||
|
|
97f3f97679
|
||
|
|
2a5da9fce1
|
||
|
|
a72dc90f31
|
||
|
|
410ed06920
|
No files matched your search
@@ -1,6 +1,9 @@
|
||||
# Ignore any startup.env file:
|
||||
startup.env
|
||||
|
||||
# Ignore the boot link of development builds:
|
||||
dev-boot-link.txt
|
||||
|
||||
# Ignore pdfium library:
|
||||
libpdfium.dylib
|
||||
libpdfium.so
|
||||
|
||||
@@ -116,8 +116,8 @@ else
|
||||
</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudButton Variant="Variant.Outlined" Size="Size.Small" Disabled="@this.IsSpecificationDownloadDisabled" Href="@this.selectedERIVersion.SpecificationURL()" Target="_blank">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Link" Class="mr-2"/> @T("Download specification")
|
||||
<MudButton Variant="Variant.Outlined" Size="Size.Small" Disabled="@this.IsSpecificationSavingDisabled" OnClick="@this.SaveSpecificationAsync">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Save" Class="mr-2"/> @T("Save specification")
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using AIStudio.Dialogs;
|
||||
using AIStudio.Dialogs.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.AssistantSessions;
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
@@ -13,7 +14,7 @@ namespace AIStudio.Assistants.ERI;
|
||||
public partial class AssistantERI : AssistantBaseCore<SettingsDialogERIServer>
|
||||
{
|
||||
[Inject]
|
||||
private HttpClient HttpClient { get; set; } = null!;
|
||||
private WebRootFiles WebRootFiles { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; init; } = null!;
|
||||
@@ -375,9 +376,43 @@ public partial class AssistantERI : AssistantBaseCore<SettingsDialogERIServer>
|
||||
private bool IsERIInputDisabled => this.IsNoneERIServerSelected || this.IsProcessing;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the selected ERI specification cannot be downloaded.
|
||||
/// Gets whether the selected ERI specification cannot be saved.
|
||||
/// </summary>
|
||||
private bool IsSpecificationDownloadDisabled => !this.selectedERIVersion.WasSpecificationSelected() || this.IsERIInputDisabled;
|
||||
private bool IsSpecificationSavingDisabled => !this.selectedERIVersion.WasSpecificationSelected() || this.IsERIInputDisabled;
|
||||
|
||||
/// <summary>
|
||||
/// Lets the user save the selected ERI specification, a copy of the one the app carries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The app server answers the app window only, so a link to the file would fail in the browser
|
||||
/// of the system. The copy also matches the installed version of AI Studio.
|
||||
/// </remarks>
|
||||
private async Task SaveSpecificationAsync()
|
||||
{
|
||||
var specificationPath = this.selectedERIVersion.SpecificationURL();
|
||||
var response = await this.RustService.SaveFile(T("Save the ERI specification"), [FileTypes.JSON], Path.GetFileName(specificationPath));
|
||||
if (response.UserCancelled)
|
||||
return;
|
||||
|
||||
await using var feedback = new FileExportFeedback();
|
||||
feedback.ShowRunning(response.SaveFilePath);
|
||||
try
|
||||
{
|
||||
if (await this.WebRootFiles.CopyToAsync(specificationPath, response.SaveFilePath))
|
||||
{
|
||||
await feedback.SucceededAsync(Icons.Material.Filled.CheckCircle, T("The ERI specification was saved."), response.SaveFilePath, true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.Logger.LogError("The app carries no ERI specification at '{SpecificationPath}'.", specificationPath);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
this.Logger.LogError(exception, "Failed to save the ERI specification {ERIVersion}.", this.selectedERIVersion);
|
||||
}
|
||||
|
||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, T("The ERI specification could not be saved.")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the generated-code target directory selection should be disabled.
|
||||
@@ -843,7 +878,7 @@ public partial class AssistantERI : AssistantBaseCore<SettingsDialogERIServer>
|
||||
var baseDirectorySnapshot = this.baseDirectory;
|
||||
var previouslyGeneratedFilesSnapshot = this.previouslyGeneratedFiles.ToArray();
|
||||
|
||||
this.eriSpecification = await this.selectedERIVersion.ReadSpecification(this.HttpClient);
|
||||
this.eriSpecification = await this.selectedERIVersion.ReadSpecification(this.WebRootFiles);
|
||||
if (string.IsNullOrWhiteSpace(this.eriSpecification))
|
||||
{
|
||||
this.AddInputIssue(T("The ERI specification could not be loaded. Please try again later."));
|
||||
|
||||
@@ -2,26 +2,14 @@ namespace AIStudio.Assistants.ERI;
|
||||
|
||||
public static class ERIVersionExtensions
|
||||
{
|
||||
public static async Task<string> ReadSpecification(this ERIVersion version, HttpClient httpClient)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = version.SpecificationURL();
|
||||
using var response = await httpClient.GetAsync(url);
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<string> ReadSpecification(this ERIVersion version, WebRootFiles webRootFiles) => webRootFiles.ReadTextAsync(version.SpecificationURL());
|
||||
|
||||
public static string SpecificationURL(this ERIVersion version)
|
||||
{
|
||||
var nameLower = version.ToString().ToLowerInvariant();
|
||||
var filename = $"{nameLower}.json";
|
||||
return $"specs/eri/{filename}";
|
||||
}
|
||||
|
||||
|
||||
public static bool WasSpecificationSelected(this ERIVersion version) => version != ERIVersion.NONE;
|
||||
}
|
||||
@@ -1537,12 +1537,21 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2205711729"] = "Opera
|
||||
-- You can integrate additional libraries. Perhaps you want to evaluate the prompts in advance using a machine learning method or analyze them with a text mining approach? Or maybe you want to preprocess images in the prompts? For such advanced scenarios, you can specify which libraries you want to use here. It's best to describe which library you want to integrate for which purpose. This way, the LLM that writes the ERI server for you can try to use these libraries effectively. This should result in less rework being necessary. If you don't know the necessary libraries, you can instead attempt to describe the intended use. The LLM can then attempt to choose suitable libraries. However, hallucinations can occur, and fictional libraries might be selected.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2241311898"] = "You can integrate additional libraries. Perhaps you want to evaluate the prompts in advance using a machine learning method or analyze them with a text mining approach? Or maybe you want to preprocess images in the prompts? For such advanced scenarios, you can specify which libraries you want to use here. It's best to describe which library you want to integrate for which purpose. This way, the LLM that writes the ERI server for you can try to use these libraries effectively. This should result in less rework being necessary. If you don't know the necessary libraries, you can instead attempt to describe the intended use. The LLM can then attempt to choose suitable libraries. However, hallucinations can occur, and fictional libraries might be selected."
|
||||
|
||||
-- The ERI specification was saved.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2258857117"] = "The ERI specification was saved."
|
||||
|
||||
-- Save the ERI specification
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2260814268"] = "Save the ERI specification"
|
||||
|
||||
-- The ERI specification could not be loaded. Please try again later.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2265640084"] = "The ERI specification could not be loaded. Please try again later."
|
||||
|
||||
-- You can imagine it like this: Hypothetically, when Wikipedia implemented the ERI, it would vectorize all pages using an embedding method. All of Wikipedia’s data would remain with Wikipedia, including the vector database (decentralized approach). Then, any AI Studio user could add Wikipedia as a data source to significantly reduce the hallucination of the LLM in knowledge questions.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2299793803"] = "You can imagine it like this: Hypothetically, when Wikipedia implemented the ERI, it would vectorize all pages using an embedding method. All of Wikipedia’s data would remain with Wikipedia, including the vector database (decentralized approach). Then, any AI Studio user could add Wikipedia as a data source to significantly reduce the hallucination of the LLM in knowledge questions."
|
||||
|
||||
-- The ERI specification could not be saved.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2361742875"] = "The ERI specification could not be saved."
|
||||
|
||||
-- Are you sure you want to delete the ERI server preset '{0}'?
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2417944396"] = "Are you sure you want to delete the ERI server preset '{0}'?"
|
||||
|
||||
@@ -1672,9 +1681,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3709185824"] = "Yes,
|
||||
-- Common ERI server settings
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T371718034"] = "Common ERI server settings"
|
||||
|
||||
-- Download specification
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3770339156"] = "Download specification"
|
||||
|
||||
-- Are you sure you want to delete the embedding '{0}'?
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3792084967"] = "Are you sure you want to delete the embedding '{0}'?"
|
||||
|
||||
@@ -1741,6 +1747,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T746826190"] = "ERI se
|
||||
-- Are you sure you want to delete the retrieval process '{0}'?
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T748762927"] = "Are you sure you want to delete the retrieval process '{0}'?"
|
||||
|
||||
-- Save specification
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T756799053"] = "Save specification"
|
||||
|
||||
-- Data source: hostname
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T761552889"] = "Data source: hostname"
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace AIStudio.Components;
|
||||
public partial class Changelog : MSGComponentBase
|
||||
{
|
||||
[Inject]
|
||||
private HttpClient HttpClient { get; set; } = null!;
|
||||
private WebRootFiles WebRootFiles { get; set; } = null!;
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
@@ -23,7 +23,6 @@ public partial class Changelog : MSGComponentBase
|
||||
|
||||
private async Task ReadLogAsync()
|
||||
{
|
||||
using var response = await this.HttpClient.GetAsync($"changelog/{this.SelectedLog.Filename}");
|
||||
this.LogContent = await response.Content.ReadAsStringAsync();
|
||||
this.LogContent = await this.WebRootFiles.ReadTextAsync($"changelog/{this.SelectedLog.Filename}");
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ namespace AIStudio.Pages;
|
||||
public partial class Home : MSGComponentBase
|
||||
{
|
||||
[Inject]
|
||||
private HttpClient HttpClient { get; init; } = null!;
|
||||
private WebRootFiles WebRootFiles { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private NavigationManager NavigationManager { get; init; } = null!;
|
||||
@@ -222,8 +222,7 @@ public partial class Home : MSGComponentBase
|
||||
private async Task ReadLastChangeAsync()
|
||||
{
|
||||
var latest = Changelog.LOGS.MaxBy(n => n.Build);
|
||||
using var response = await this.HttpClient.GetAsync($"changelog/{latest.Filename}");
|
||||
this.LastChangeContent = await response.Content.ReadAsStringAsync();
|
||||
this.LastChangeContent = await this.WebRootFiles.ReadTextAsync($"changelog/{latest.Filename}");
|
||||
|
||||
await this.InvokeAsync(this.StateHasChanged);
|
||||
}
|
||||
|
||||
+12
-3
@@ -1539,12 +1539,21 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2205711729"] = "Betri
|
||||
-- You can integrate additional libraries. Perhaps you want to evaluate the prompts in advance using a machine learning method or analyze them with a text mining approach? Or maybe you want to preprocess images in the prompts? For such advanced scenarios, you can specify which libraries you want to use here. It's best to describe which library you want to integrate for which purpose. This way, the LLM that writes the ERI server for you can try to use these libraries effectively. This should result in less rework being necessary. If you don't know the necessary libraries, you can instead attempt to describe the intended use. The LLM can then attempt to choose suitable libraries. However, hallucinations can occur, and fictional libraries might be selected.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2241311898"] = "Sie können zusätzliche Bibliotheken integrieren. Vielleicht möchten Sie die Prompts im Voraus mit einer Machine-Learning-Methode auswerten oder sie mithilfe eines Text-Mining-Ansatzes analysieren? Oder möchten Sie vielleicht Bilder in den Prompts vorverarbeiten? Für solche erweiterten Szenarien können Sie hier angeben, welche Bibliotheken Sie verwenden möchten. Beschreiben Sie am besten, welche Bibliothek Sie für welchen Zweck einbinden möchten. So kann das LLM, das den ERI-Server für Sie erstellt, versuchen, diese Bibliotheken effektiv zu nutzen. Dadurch sollte weniger Nacharbeit nötig sein. Falls Sie die benötigten Bibliotheken nicht kennen, können Sie stattdessen versuchen, den geplanten Einsatzzweck zu beschreiben. Das LLM kann dann versuchen, passende Bibliotheken auszuwählen. Allerdings können dabei Halluzinationen auftreten und fiktive Bibliotheken gewählt werden."
|
||||
|
||||
-- The ERI specification was saved.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2258857117"] = "Die ERI-Spezifikation wurde gespeichert."
|
||||
|
||||
-- Save the ERI specification
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2260814268"] = "ERI-Spezifikation speichern"
|
||||
|
||||
-- The ERI specification could not be loaded. Please try again later.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2265640084"] = "Die ERI-Spezifikation konnte nicht geladen werden. Bitte versuchen Sie es später erneut."
|
||||
|
||||
-- You can imagine it like this: Hypothetically, when Wikipedia implemented the ERI, it would vectorize all pages using an embedding method. All of Wikipedia’s data would remain with Wikipedia, including the vector database (decentralized approach). Then, any AI Studio user could add Wikipedia as a data source to significantly reduce the hallucination of the LLM in knowledge questions.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2299793803"] = "Sie können es sich so vorstellen: Hypothetisch gesehen würde die Wikipedia beim Einsatz von ERI alle Seiten mithilfe einer Einbettungsmethode „vektorisieren“. Alle Daten von Wikipedia, einschließlich der Vektordatenbank, würden bei der Wikipedia bleiben (dezentraler Ansatz). Danach könnte jeder Nutzer von MindWork AI Studio Wikipedia als Datenquelle hinzufügen, um Halluzinationen des LLM bei Wissensfragen deutlich zu reduzieren."
|
||||
|
||||
-- The ERI specification could not be saved.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2361742875"] = "Die ERI-Spezifikation konnte nicht gespeichert werden."
|
||||
|
||||
-- Are you sure you want to delete the ERI server preset '{0}'?
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2417944396"] = "Sind Sie sicher, dass Sie die ERI-Server-Voreinstellung „{0}“ löschen möchten?"
|
||||
|
||||
@@ -1674,9 +1683,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3709185824"] = "Ja, b
|
||||
-- Common ERI server settings
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T371718034"] = "Allgemeine ERI-Server-Einstellungen"
|
||||
|
||||
-- Download specification
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3770339156"] = "Spezifikation herunterladen"
|
||||
|
||||
-- Are you sure you want to delete the embedding '{0}'?
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3792084967"] = "Möchten Sie die Einbettung „{0}“ wirklich löschen?"
|
||||
|
||||
@@ -1743,6 +1749,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T746826190"] = "ERI-Se
|
||||
-- Are you sure you want to delete the retrieval process '{0}'?
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T748762927"] = "Sind Sie sicher, dass Sie den Abrufprozess „{0}“ löschen möchten?"
|
||||
|
||||
-- Save specification
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T756799053"] = "Spezifikation speichern"
|
||||
|
||||
-- Data source: hostname
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T761552889"] = "Datenquelle: Hostname"
|
||||
|
||||
|
||||
+12
-3
@@ -1539,12 +1539,21 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2205711729"] = "Opera
|
||||
-- You can integrate additional libraries. Perhaps you want to evaluate the prompts in advance using a machine learning method or analyze them with a text mining approach? Or maybe you want to preprocess images in the prompts? For such advanced scenarios, you can specify which libraries you want to use here. It's best to describe which library you want to integrate for which purpose. This way, the LLM that writes the ERI server for you can try to use these libraries effectively. This should result in less rework being necessary. If you don't know the necessary libraries, you can instead attempt to describe the intended use. The LLM can then attempt to choose suitable libraries. However, hallucinations can occur, and fictional libraries might be selected.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2241311898"] = "You can integrate additional libraries. Perhaps you want to evaluate the prompts in advance using a machine learning method or analyze them with a text mining approach? Or maybe you want to preprocess images in the prompts? For such advanced scenarios, you can specify which libraries you want to use here. It's best to describe which library you want to integrate for which purpose. This way, the LLM that writes the ERI server for you can try to use these libraries effectively. This should result in less rework being necessary. If you don't know the necessary libraries, you can instead attempt to describe the intended use. The LLM can then attempt to choose suitable libraries. However, hallucinations can occur, and fictional libraries might be selected."
|
||||
|
||||
-- The ERI specification was saved.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2258857117"] = "The ERI specification was saved."
|
||||
|
||||
-- Save the ERI specification
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2260814268"] = "Save the ERI specification"
|
||||
|
||||
-- The ERI specification could not be loaded. Please try again later.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2265640084"] = "The ERI specification could not be loaded. Please try again later."
|
||||
|
||||
-- You can imagine it like this: Hypothetically, when Wikipedia implemented the ERI, it would vectorize all pages using an embedding method. All of Wikipedia’s data would remain with Wikipedia, including the vector database (decentralized approach). Then, any AI Studio user could add Wikipedia as a data source to significantly reduce the hallucination of the LLM in knowledge questions.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2299793803"] = "You can imagine it like this: Hypothetically, when Wikipedia implemented the ERI, it would vectorize all pages using an embedding method. All of Wikipedia’s data would remain with Wikipedia, including the vector database (decentralized approach). Then, any AI Studio user could add Wikipedia as a data source to significantly reduce the hallucination of the LLM in knowledge questions."
|
||||
|
||||
-- The ERI specification could not be saved.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2361742875"] = "The ERI specification could not be saved."
|
||||
|
||||
-- Are you sure you want to delete the ERI server preset '{0}'?
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2417944396"] = "Are you sure you want to delete the ERI server preset '{0}'?"
|
||||
|
||||
@@ -1674,9 +1683,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3709185824"] = "Yes,
|
||||
-- Common ERI server settings
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T371718034"] = "Common ERI server settings"
|
||||
|
||||
-- Download specification
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3770339156"] = "Download specification"
|
||||
|
||||
-- Are you sure you want to delete the embedding '{0}'?
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3792084967"] = "Are you sure you want to delete the embedding '{0}'?"
|
||||
|
||||
@@ -1743,6 +1749,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T746826190"] = "ERI se
|
||||
-- Are you sure you want to delete the retrieval process '{0}'?
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T748762927"] = "Are you sure you want to delete the retrieval process '{0}'?"
|
||||
|
||||
-- Save specification
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T756799053"] = "Save specification"
|
||||
|
||||
-- Data source: hostname
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T761552889"] = "Data source: hostname"
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ using AIStudio.Tools.Web;
|
||||
|
||||
using Microsoft.AspNetCore.Components.Server.Circuits;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.HostFiltering;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Microsoft.Extensions.Logging.Console;
|
||||
|
||||
@@ -32,11 +33,6 @@ using MudBlazor.Services;
|
||||
|
||||
using MudExtensions.Services;
|
||||
|
||||
#if !DEBUG
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
#endif
|
||||
|
||||
namespace AIStudio;
|
||||
|
||||
internal sealed class Program
|
||||
@@ -96,7 +92,14 @@ internal sealed class Program
|
||||
}
|
||||
|
||||
API_TOKEN = apiToken;
|
||||
|
||||
|
||||
var uiToken = Environment.GetEnvironmentVariable(AppAccessPolicy.UI_TOKEN_ENVIRONMENT_VARIABLE);
|
||||
if(string.IsNullOrWhiteSpace(uiToken))
|
||||
{
|
||||
Console.WriteLine($"Error: The {AppAccessPolicy.UI_TOKEN_ENVIRONMENT_VARIABLE} environment variable is not set.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var rust = new RustService(rustApiPort, certificateFingerprint);
|
||||
var appPort = await rust.GetAppPort();
|
||||
if(appPort == 0)
|
||||
@@ -283,12 +286,18 @@ internal sealed class Program
|
||||
options.KeepAliveInterval = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton(new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri($"http://localhost:{appPort}")
|
||||
});
|
||||
// For absolute URLs only. The app reads its own files through WebRootFiles:
|
||||
builder.Services.AddSingleton(new HttpClient());
|
||||
builder.Services.AddSingleton<WebRootFiles>();
|
||||
|
||||
builder.WebHost.UseUrls($"http://localhost:{appPort}");
|
||||
//
|
||||
// We listen on the IPv4 loopback address only, where the app window goes. With localhost, the
|
||||
// server would listen on the IPv6 loopback address as well, and a client which resolves
|
||||
// localhost could end up with another process holding that port there.
|
||||
//
|
||||
builder.WebHost.UseUrls($"http://127.0.0.1:{appPort}");
|
||||
builder.Services.Configure<HostFilteringOptions>(AppAccessPolicy.ConfigureHostFiltering);
|
||||
builder.Services.AddSingleton(new AppAccessPolicy(appPort, uiToken));
|
||||
|
||||
#if DEBUG
|
||||
builder.WebHost.UseWebRoot("wwwroot");
|
||||
@@ -343,6 +352,13 @@ internal sealed class Program
|
||||
|
||||
DATABASE_CLIENT_PROVIDER = app.Services.GetRequiredService<DatabaseClientProvider>();
|
||||
|
||||
//
|
||||
// These two come first, so that no request reaches anything else without passing them. We add
|
||||
// the host filtering ourselves instead of relying on the defaults of ASP.NET Core to do so:
|
||||
//
|
||||
app.UseHostFiltering();
|
||||
app.UseMiddleware<AppAccessMiddleware>();
|
||||
|
||||
programLogger.LogInformation("Initialize internal file system.");
|
||||
app.Use(Redirect.HandlerContentAsync);
|
||||
app.Use(FileHandler.HandlerAsync);
|
||||
@@ -351,10 +367,9 @@ internal sealed class Program
|
||||
app.UseStaticFiles();
|
||||
app.UseDeveloperExceptionPage();
|
||||
#else
|
||||
var fileProvider = new ManifestEmbeddedFileProvider(Assembly.GetAssembly(type: typeof(Program))!, "wwwroot");
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
FileProvider = fileProvider,
|
||||
FileProvider = app.Services.GetRequiredService<WebRootFiles>().FileProvider,
|
||||
RequestPath = string.Empty,
|
||||
});
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace AIStudio.Tools.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Refuses every request to the app server which does not come from the app window.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The middleware runs before every other one, so it covers pages, static files, the Blazor hub
|
||||
/// with all of its transports, and every endpoint alike. A release build makes no exception: a
|
||||
/// request without the session cookie gets refused, whatever it asks for. Only a development build
|
||||
/// also takes its boot link, with which another browser gets the cookie.
|
||||
///
|
||||
/// A request which carries an Origin header must, in addition, come from a page of the app.
|
||||
/// Browsers send that header with requests made by a script, the WebSocket of the Blazor hub
|
||||
/// included, but not when they load a page. A sandboxed iframe sends "null", which gets refused as
|
||||
/// well.
|
||||
/// </remarks>
|
||||
/// <param name="next">The next middleware.</param>
|
||||
/// <param name="policy">Decides which requests may pass.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public sealed class AppAccessMiddleware(RequestDelegate next, AppAccessPolicy policy, ILogger<AppAccessMiddleware> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// The shortest interval between two log entries about refused requests. Any process on this
|
||||
/// machine can send requests, and an entry for each of them would flood the log.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan REFUSAL_LOG_INTERVAL = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// What a refused request gets to read. The app window only shows it when it lost its cookie,
|
||||
/// and a new start gives it a new one.
|
||||
/// </summary>
|
||||
private const string REFUSAL_TEXT = "This window cannot access AI Studio. Please restart AI Studio.";
|
||||
|
||||
private readonly Lock refusalLock = new();
|
||||
private DateTimeOffset lastRefusalLog = DateTimeOffset.MinValue;
|
||||
private int refusalsSinceLastLog;
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var origin = context.Request.Headers.Origin;
|
||||
if (!StringValues.IsNullOrEmpty(origin) && (origin.Count != 1 || !policy.IsAppOrigin(origin.ToString())))
|
||||
{
|
||||
await this.RefuseAsync(context, "another origin");
|
||||
return;
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
if (HttpMethods.IsGet(context.Request.Method) && context.Request.Path.Equals(DEV_BOOT_PATH, StringComparison.Ordinal))
|
||||
{
|
||||
await this.BootAsync(context);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!policy.HasSessionCookie(context.Request))
|
||||
{
|
||||
await this.RefuseAsync(context, "a client without the session cookie");
|
||||
return;
|
||||
}
|
||||
|
||||
await next(context);
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// <summary>
|
||||
/// The path of the boot link which development builds write for other browsers, such as the
|
||||
/// one of an agent. A release build knows neither the link nor this path.
|
||||
/// </summary>
|
||||
private const string DEV_BOOT_PATH = "/dev/boot";
|
||||
|
||||
/// <summary>
|
||||
/// Trades the token of a boot link for the session cookie and sends the browser on to the app.
|
||||
/// The browser opens the link by itself, so it sends the cookie along after the redirect.
|
||||
/// </summary>
|
||||
private async Task BootAsync(HttpContext context)
|
||||
{
|
||||
var token = context.Request.Query["token"].ToString();
|
||||
if (!policy.IsUIToken(token))
|
||||
{
|
||||
await this.RefuseAsync(context, "a boot link with a wrong token");
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.Cookies.Append(AppAccessPolicy.SESSION_COOKIE_NAME, token, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/",
|
||||
IsEssential = true
|
||||
});
|
||||
|
||||
context.Response.Redirect("/");
|
||||
}
|
||||
#endif
|
||||
|
||||
private async Task RefuseAsync(HttpContext context, string reason)
|
||||
{
|
||||
this.LogRefusal(context, reason);
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
context.Response.ContentType = "text/plain; charset=utf-8";
|
||||
await context.Response.WriteAsync(REFUSAL_TEXT);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs a refused request, at most once per interval, together with the number of requests
|
||||
/// refused since the last entry. The entry names the path but never the query or any header.
|
||||
/// </summary>
|
||||
private void LogRefusal(HttpContext context, string reason)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
int refusals;
|
||||
lock (this.refusalLock)
|
||||
{
|
||||
this.refusalsSinceLastLog++;
|
||||
if (now - this.lastRefusalLog < REFUSAL_LOG_INTERVAL)
|
||||
return;
|
||||
|
||||
refusals = this.refusalsSinceLastLog;
|
||||
this.refusalsSinceLastLog = 0;
|
||||
this.lastRefusalLog = now;
|
||||
}
|
||||
|
||||
logger.LogWarning("Refused {Refusals} request(s) to the app server since the last report. The latest one, '{Method} {Path}', came from {Reason}.", refusals, context.Request.Method, context.Request.Path.Value, reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
using Microsoft.AspNetCore.HostFiltering;
|
||||
|
||||
namespace AIStudio.Tools.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Decides which requests may reach the app server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Rust runtime makes a new token for every start. It gives the token to this server through
|
||||
/// an environment variable, and to the app window as the value of the session cookie. Nothing else
|
||||
/// on this machine has the cookie, so a request with it comes from the app window.
|
||||
/// </remarks>
|
||||
/// <param name="appPort">The port the app server listens on.</param>
|
||||
/// <param name="uiToken">The token of this start, which the session cookie carries.</param>
|
||||
public sealed class AppAccessPolicy(int appPort, string uiToken)
|
||||
{
|
||||
/// <summary>
|
||||
/// The name of the session cookie. The Rust runtime sets the cookie under the same name.
|
||||
/// </summary>
|
||||
public const string SESSION_COOKIE_NAME = "ai_studio_session";
|
||||
|
||||
/// <summary>
|
||||
/// The environment variable which carries the token from the Rust runtime to this server.
|
||||
/// </summary>
|
||||
public const string UI_TOKEN_ENVIRONMENT_VARIABLE = "AI_STUDIO_UI_TOKEN";
|
||||
|
||||
/// <summary>
|
||||
/// The host names under which the app server answers. A request for any other name, such as one
|
||||
/// which a website points at the loopback address, gets refused.
|
||||
/// </summary>
|
||||
private static readonly string[] ALLOWED_HOSTS = ["127.0.0.1", "localhost", "[::1]"];
|
||||
|
||||
private readonly HashSet<string> allowedOrigins = ALLOWED_HOSTS.Select(host => $"http://{host}:{appPort}").ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly byte[] uiTokenBytes = Encoding.UTF8.GetBytes(uiToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lets the host filtering of ASP.NET Core accept only the host names of the app server.
|
||||
/// </summary>
|
||||
/// <param name="options">The options of the host filtering.</param>
|
||||
public static void ConfigureHostFiltering(HostFilteringOptions options)
|
||||
{
|
||||
options.AllowedHosts = [..ALLOWED_HOSTS];
|
||||
options.AllowEmptyHosts = false;
|
||||
options.IncludeFailureMessage = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the given origin is the app itself.
|
||||
/// </summary>
|
||||
/// <param name="origin">The value of the Origin header of a request.</param>
|
||||
/// <returns>True when a page of the app made the request.</returns>
|
||||
public bool IsAppOrigin(string origin) => this.allowedOrigins.Contains(origin);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the given request carries the session cookie of this start.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The comparison takes the same time no matter where a wrong value differs, so the time of a
|
||||
/// refusal tells nothing about how much of a guess was right.
|
||||
/// </remarks>
|
||||
/// <param name="request">The request.</param>
|
||||
/// <returns>True when the request comes from the app window.</returns>
|
||||
public bool HasSessionCookie(HttpRequest request) => request.Cookies.TryGetValue(SESSION_COOKIE_NAME, out var value) && this.IsUIToken(value);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the given value is the token of this start, in a time which does not depend
|
||||
/// on where a wrong value differs.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to check.</param>
|
||||
/// <returns>True when the value is the token.</returns>
|
||||
public bool IsUIToken(string? value) => !string.IsNullOrEmpty(value) && CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(value), this.uiTokenBytes);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Reflection;
|
||||
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the files below wwwroot, which every build embeds into the app.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The app reads files such as its changelogs directly instead of requesting them over HTTP from
|
||||
/// its own server, which answers the app window only. The release build serves its static files
|
||||
/// from the same provider.
|
||||
/// </remarks>
|
||||
public sealed class WebRootFiles
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the files below wwwroot.
|
||||
/// </summary>
|
||||
public IFileProvider FileProvider { get; } = new ManifestEmbeddedFileProvider(Assembly.GetAssembly(type: typeof(Program))!, "wwwroot");
|
||||
|
||||
/// <summary>
|
||||
/// Reads a text file below wwwroot.
|
||||
/// </summary>
|
||||
/// <param name="path">The path below wwwroot, such as <c>changelog/v26.10.1.md</c>.</param>
|
||||
/// <returns>The text of the file, or an empty string when there is no such file.</returns>
|
||||
public async Task<string> ReadTextAsync(string path)
|
||||
{
|
||||
var file = this.FileProvider.GetFileInfo(path);
|
||||
if (!file.Exists)
|
||||
return string.Empty;
|
||||
|
||||
await using var fileStream = file.CreateReadStream();
|
||||
using var reader = new StreamReader(fileStream);
|
||||
return await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies a file below wwwroot to the given place, such as one the user chose to save it to.
|
||||
/// </summary>
|
||||
/// <param name="path">The path below wwwroot, such as <c>specs/eri/v1.json</c>.</param>
|
||||
/// <param name="destinationPath">Where to write the copy. An existing file there gets replaced.</param>
|
||||
/// <returns>True when the copy was written, false when there is no such file below wwwroot.</returns>
|
||||
public async Task<bool> CopyToAsync(string path, string destinationPath)
|
||||
{
|
||||
var file = this.FileProvider.GetFileInfo(path);
|
||||
if (!file.Exists)
|
||||
return false;
|
||||
|
||||
await using var source = file.CreateReadStream();
|
||||
await using var destination = File.Create(destinationPath);
|
||||
await source.CopyToAsync(destination);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
- Added a setting for the size of the interface, which you find in the app settings. You can choose a size for each of your screens, and AI Studio switches to it when you move its window to another screen. Thanks, Paul Koudelka (`PaulKoudelka`), for this contribution.
|
||||
- Improved the documentation for IT departments, which now lives in our [wiki](https://github.com/MindWorkAI/AI-Studio/wiki/Enterprise-IT). It is sorted by task, explains the setup on Windows, Linux, and macOS, and offers complete configurations for typical needs, such as using only local models. The link in the app settings leads there, and the wiki also holds the installation guide in English and German.
|
||||
- Improved the German translation throughout: we proofread all texts and corrected wrong labels as well as spelling and grammar mistakes. The texts now always address you formally and use the same terms everywhere.
|
||||
- Improved the security of the connection between the app window and AI Studio.
|
||||
- Changed the size of AI Studio on laptop screens: it now appears in the same size on every laptop, whatever the scaling of your operating system. If you prefer the previous size, choose "System scaling" for the interface size in the app settings. Contributed by Paul Koudelka.
|
||||
- Fixed AI Studio becoming unusable when an update failed after its download. The app now restarts with the version you have installed, so you can simply try the update again.
|
||||
- Fixed configuration plugins you installed yourself being able to change the tool settings of your organization, such as the minimum provider confidence of a tool. These settings now follow the same rules as all other settings, which also gives you back your own values once your organization removes its configuration.
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
using AIStudio.Tools.Security;
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AIStudio.Tests.Tools.AppAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Checks which requests the app server lets through to the app.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only the app window has the session cookie, so every request without it gets refused, whatever
|
||||
/// it asks for: pages, static files, the Blazor hub, the local files, and the preview of a visual
|
||||
/// briefing alike. A request which a script sends also carries the origin of its page. Only the
|
||||
/// pages of the app may send such requests: a page from anywhere else, and a sandboxed iframe with
|
||||
/// its origin "null", get refused even with the cookie.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class AppAccessMiddlewareTests
|
||||
{
|
||||
private const int APP_PORT = 61153;
|
||||
|
||||
private const string UI_TOKEN = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
/// <summary>
|
||||
/// The status a request gets when it passes the middleware.
|
||||
/// </summary>
|
||||
private const int PASSED = StatusCodes.Status204NoContent;
|
||||
|
||||
private static readonly string[] PATHS =
|
||||
[
|
||||
"/",
|
||||
"/chat",
|
||||
"/_blazor/negotiate",
|
||||
"/_blazor",
|
||||
"/_framework/blazor.web.js",
|
||||
"/local/file",
|
||||
"/visual-briefing/preview/0b6c1c43-6d2e-4f0b-9a35-6f6f3c1f2a10/6a4e7d6e-2a8c-4bb0-8c55-7a2b7b3c9e01",
|
||||
"/system/MudBlazor/MudBlazor.min.css",
|
||||
"/app.css",
|
||||
"/changelog/v26.10.1.md",
|
||||
"/does-not-exist"
|
||||
];
|
||||
|
||||
private static async Task<int> SendAsync(string path, string? sessionCookie, params string[] origins)
|
||||
{
|
||||
var middleware = new AppAccessMiddleware(Pass, new AppAccessPolicy(APP_PORT, UI_TOKEN), NullLogger<AppAccessMiddleware>.Instance);
|
||||
var context = new DefaultHttpContext { Request = { Method = HttpMethods.Get, Path = path } };
|
||||
if (sessionCookie is not null)
|
||||
context.Request.Headers.Cookie = $"{AppAccessPolicy.SESSION_COOKIE_NAME}={sessionCookie}";
|
||||
|
||||
if (origins.Length > 0)
|
||||
context.Request.Headers.Origin = origins;
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
return context.Response.StatusCode;
|
||||
}
|
||||
|
||||
private static Task Pass(HttpContext context)
|
||||
{
|
||||
context.Response.StatusCode = PASSED;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(PATHS))]
|
||||
public async Task ARequestWithTheSessionCookiePasses(string path)
|
||||
{
|
||||
Assert.That(await SendAsync(path, UI_TOKEN), Is.EqualTo(PASSED));
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(PATHS))]
|
||||
public async Task ARequestWithoutTheSessionCookieIsRefused(string path)
|
||||
{
|
||||
Assert.That(await SendAsync(path, null), Is.EqualTo(StatusCodes.Status403Forbidden));
|
||||
}
|
||||
|
||||
[TestCase("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdee")]
|
||||
[TestCase("0123456789abcdef")]
|
||||
[TestCase("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0")]
|
||||
[TestCase("")]
|
||||
public async Task ARequestWithAWrongSessionCookieIsRefused(string sessionCookie)
|
||||
{
|
||||
Assert.That(await SendAsync("/", sessionCookie), Is.EqualTo(StatusCodes.Status403Forbidden));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheTokenUnderAnotherCookieNameIsRefused()
|
||||
{
|
||||
var middleware = new AppAccessMiddleware(Pass, new AppAccessPolicy(APP_PORT, UI_TOKEN), NullLogger<AppAccessMiddleware>.Instance);
|
||||
var context = new DefaultHttpContext { Request = { Method = HttpMethods.Get, Path = "/", Headers = { Cookie = $"session={UI_TOKEN}" } } };
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
Assert.That(context.Response.StatusCode, Is.EqualTo(StatusCodes.Status403Forbidden));
|
||||
}
|
||||
|
||||
[TestCase("http://127.0.0.1:61153")]
|
||||
[TestCase("http://localhost:61153")]
|
||||
[TestCase("http://[::1]:61153")]
|
||||
public async Task ARequestFromTheAppPasses(string origin)
|
||||
{
|
||||
Assert.That(await SendAsync("/_blazor/negotiate", UI_TOKEN, origin), Is.EqualTo(PASSED));
|
||||
}
|
||||
|
||||
[TestCase("http://rebind.example:61153")]
|
||||
[TestCase("null")]
|
||||
[TestCase("http://127.0.0.1:61154")]
|
||||
[TestCase("https://127.0.0.1:61153")]
|
||||
[TestCase("http://127.0.0.1")]
|
||||
public async Task ARequestFromAnotherOriginIsRefusedEvenWithTheSessionCookie(string origin)
|
||||
{
|
||||
Assert.That(await SendAsync("/_blazor/negotiate", UI_TOKEN, origin), Is.EqualTo(StatusCodes.Status403Forbidden));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ARequestWithSeveralOriginsIsRefused()
|
||||
{
|
||||
Assert.That(await SendAsync("/_blazor/negotiate", UI_TOKEN, "http://127.0.0.1:61153", "http://rebind.example:61153"), Is.EqualTo(StatusCodes.Status403Forbidden));
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private static async Task<HttpResponse> BootAsync(string method, string? token)
|
||||
{
|
||||
var middleware = new AppAccessMiddleware(Pass, new AppAccessPolicy(APP_PORT, UI_TOKEN), NullLogger<AppAccessMiddleware>.Instance);
|
||||
var context = new DefaultHttpContext { Request = { Method = method, Path = "/dev/boot" } };
|
||||
if (token is not null)
|
||||
context.Request.QueryString = QueryString.Create("token", token);
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
return context.Response;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheBootLinkOfADevelopmentBuildHandsOutTheSessionCookie()
|
||||
{
|
||||
var response = await BootAsync(HttpMethods.Get, UI_TOKEN);
|
||||
var cookie = response.Headers.SetCookie.ToString().ToLowerInvariant();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(response.StatusCode, Is.EqualTo(StatusCodes.Status302Found));
|
||||
Assert.That(response.Headers.Location.ToString(), Is.EqualTo("/"));
|
||||
Assert.That(cookie, Does.StartWith($"{AppAccessPolicy.SESSION_COOKIE_NAME}={UI_TOKEN}"));
|
||||
Assert.That(cookie, Does.Contain("httponly"));
|
||||
Assert.That(cookie, Does.Contain("samesite=strict"));
|
||||
Assert.That(cookie, Does.Contain("path=/"));
|
||||
Assert.That(cookie, Does.Not.Contain("expires"));
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdee")]
|
||||
[TestCase("")]
|
||||
[TestCase(null)]
|
||||
public async Task TheBootLinkWithAWrongTokenIsRefused(string? token)
|
||||
{
|
||||
var response = await BootAsync(HttpMethods.Get, token);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(response.StatusCode, Is.EqualTo(StatusCodes.Status403Forbidden));
|
||||
Assert.That(response.Headers.SetCookie.ToString(), Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheBootLinkAnswersOnlyAGet()
|
||||
{
|
||||
var response = await BootAsync(HttpMethods.Post, UI_TOKEN);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(response.StatusCode, Is.EqualTo(StatusCodes.Status403Forbidden));
|
||||
Assert.That(response.Headers.SetCookie.ToString(), Is.Empty);
|
||||
});
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AIStudio.Tests.Tools.AppAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Checks that the access checks run before anything else in the request pipeline of the app.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A middleware or an endpoint which comes first would answer requests the checks never saw. The
|
||||
/// test reads the calls from the source of Program.cs, so that a new one is checked without anybody
|
||||
/// having to add it to a test.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class AppAccessPipelineTests
|
||||
{
|
||||
private static string ReadProgramSource([CallerFilePath] string sourceFilePath = "") => File.ReadAllText(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "..", "..", "..", "MindWork AI Studio", "Program.cs"));
|
||||
|
||||
[Test]
|
||||
public void TheAccessChecksComeBeforeEveryOtherMiddlewareAndEndpoint()
|
||||
{
|
||||
var calls = Regex.Matches(ReadProgramSource(), @"\bapp\.(Use|Map)\w*(<\w+>)?\(")
|
||||
.Select(match => match.Value)
|
||||
.ToList();
|
||||
|
||||
Assert.That(calls, Has.Count.GreaterThan(2), "The test found no middleware or endpoint after the access checks.");
|
||||
Assert.That(calls.Take(2), Is.EqualTo(new[] { "app.UseHostFiltering(", "app.UseMiddleware<AppAccessMiddleware>(" }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
using AIStudio.Tools.Security;
|
||||
|
||||
namespace AIStudio.Tests.Tools.AppAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Checks that the app server and the Rust runtime agree on the names around the session cookie.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The runtime sets the cookie and hands the token over in an environment variable; the server
|
||||
/// reads both under its own constants. Should the names drift apart, the server would refuse the
|
||||
/// app window, and nothing but a start of the app would tell. The test reads the constants of the
|
||||
/// runtime from its source.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class AppAccessRuntimeAgreementTests
|
||||
{
|
||||
private static string ReadRuntimeSource([CallerFilePath] string sourceFilePath = "") => File.ReadAllText(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "..", "..", "..", "..", "runtime", "src", "app_access.rs"));
|
||||
|
||||
private static string ReadRustConstant(string name)
|
||||
{
|
||||
var match = Regex.Match(ReadRuntimeSource(), $"""pub const {name}: &str = "([^"]+)";""");
|
||||
Assert.That(match.Success, Is.True, $"The runtime defines no constant {name}.");
|
||||
return match.Groups[1].Value;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BothUseTheSameCookieName()
|
||||
{
|
||||
Assert.That(ReadRustConstant("SESSION_COOKIE_NAME"), Is.EqualTo(AppAccessPolicy.SESSION_COOKIE_NAME));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BothUseTheSameEnvironmentVariable()
|
||||
{
|
||||
Assert.That(ReadRustConstant("DOTNET_ENV_UI_TOKEN"), Is.EqualTo(AppAccessPolicy.UI_TOKEN_ENVIRONMENT_VARIABLE));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using AIStudio.Tools.Security;
|
||||
|
||||
using Microsoft.AspNetCore.HostFiltering;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace AIStudio.Tests.Tools.AppAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Checks which host names the app server answers to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A website may point a name of its own at the loopback address. The browser then sends that name
|
||||
/// as the host, and the app server must refuse the request. The test runs the host filtering of
|
||||
/// ASP.NET Core with the options the app configures.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class HostFilteringTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The status a request gets when it passes the filter.
|
||||
/// </summary>
|
||||
private const int PASSED = StatusCodes.Status204NoContent;
|
||||
|
||||
private static async Task<int> SendAsync(string host)
|
||||
{
|
||||
await using var services = new ServiceCollection()
|
||||
.AddLogging()
|
||||
.Configure<HostFilteringOptions>(AppAccessPolicy.ConfigureHostFiltering)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var middleware = ActivatorUtilities.CreateInstance<HostFilteringMiddleware>(services, (RequestDelegate)Pass);
|
||||
var context = new DefaultHttpContext { Request = { Host = new HostString(host) } };
|
||||
|
||||
await middleware.Invoke(context);
|
||||
return context.Response.StatusCode;
|
||||
}
|
||||
|
||||
private static Task Pass(HttpContext context)
|
||||
{
|
||||
context.Response.StatusCode = PASSED;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[TestCase("127.0.0.1:61153")]
|
||||
[TestCase("localhost:61153")]
|
||||
[TestCase("[::1]:61153")]
|
||||
public async Task TheLoopbackNamesPass(string host)
|
||||
{
|
||||
Assert.That(await SendAsync(host), Is.EqualTo(PASSED));
|
||||
}
|
||||
|
||||
[TestCase("rebind.example:61153")]
|
||||
[TestCase("127.0.0.1.rebind.example:61153")]
|
||||
[TestCase("localhost.rebind.example")]
|
||||
public async Task AnyOtherNameIsRefused(string host)
|
||||
{
|
||||
Assert.That(await SendAsync(host), Is.EqualTo(StatusCodes.Status400BadRequest));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ARequestWithoutAHostIsRefused()
|
||||
{
|
||||
Assert.That(await SendAsync(string.Empty), Is.EqualTo(StatusCodes.Status400BadRequest));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using AIStudio.Assistants.ERI;
|
||||
using AIStudio.Tools;
|
||||
|
||||
using Changelog = AIStudio.Components.Changelog;
|
||||
|
||||
namespace AIStudio.Tests.Tools.AppAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Checks that the app reads the files below wwwroot which it shows itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The changelogs and the ERI specifications come from the files embedded into the app, not from a
|
||||
/// request to its own server. A file which is missing there would leave the changelog or the ERI
|
||||
/// assistant empty without any error.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class WebRootFilesTests
|
||||
{
|
||||
private static IEnumerable<string> ChangelogFiles() => Changelog.LOGS.Select(log => log.Filename);
|
||||
|
||||
private static IEnumerable<ERIVersion> SpecificationVersions() => Enum.GetValues<ERIVersion>().Where(version => version.WasSpecificationSelected());
|
||||
|
||||
[TestCaseSource(nameof(ChangelogFiles))]
|
||||
public async Task EveryChangelogCanBeRead(string filename)
|
||||
{
|
||||
var text = await new WebRootFiles().ReadTextAsync($"changelog/{filename}");
|
||||
Assert.That(text, Is.Not.Empty);
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(SpecificationVersions))]
|
||||
public async Task EveryERISpecificationCanBeRead(ERIVersion version)
|
||||
{
|
||||
var text = await version.ReadSpecification(new WebRootFiles());
|
||||
Assert.That(text, Is.Not.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AMissingFileReadsAsEmptyText()
|
||||
{
|
||||
var text = await new WebRootFiles().ReadTextAsync("changelog/does-not-exist.md");
|
||||
Assert.That(text, Is.Empty);
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(SpecificationVersions))]
|
||||
public async Task EveryERISpecificationCanBeSaved(ERIVersion version)
|
||||
{
|
||||
var webRootFiles = new WebRootFiles();
|
||||
var destinationPath = Path.Combine(Path.GetTempPath(), $"eri-specification-{Guid.NewGuid():N}.json");
|
||||
try
|
||||
{
|
||||
Assert.That(await webRootFiles.CopyToAsync(version.SpecificationURL(), destinationPath), Is.True);
|
||||
Assert.That(await File.ReadAllTextAsync(destinationPath), Is.EqualTo(await version.ReadSpecification(webRootFiles)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(destinationPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AMissingFileIsNotCopied()
|
||||
{
|
||||
var destinationPath = Path.Combine(Path.GetTempPath(), $"eri-specification-{Guid.NewGuid():N}.json");
|
||||
|
||||
var copied = await new WebRootFiles().CopyToAsync("specs/eri/does-not-exist.json", destinationPath);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(copied, Is.False);
|
||||
Assert.That(File.Exists(destinationPath), Is.False);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "Default capability for MindWork AI Studio",
|
||||
"remote": {
|
||||
"urls": [
|
||||
"http://localhost:*"
|
||||
"http://127.0.0.1:*"
|
||||
]
|
||||
},
|
||||
"windows": [
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
use log::{error, info};
|
||||
use once_cell::sync::Lazy;
|
||||
use tauri::webview::Cookie;
|
||||
use tauri::{Url, WebviewWindow};
|
||||
use crate::api_token::{generate_api_token, APIToken};
|
||||
|
||||
/// The token which the app window presents to the .NET server as the value of its session cookie.
|
||||
/// Every start makes a new one.
|
||||
pub static UI_ACCESS_TOKEN: Lazy<APIToken> = Lazy::new(generate_api_token);
|
||||
|
||||
/// The name of the session cookie. The .NET server expects the same name.
|
||||
pub const SESSION_COOKIE_NAME: &str = "ai_studio_session";
|
||||
|
||||
/// The environment variable which carries the token to the .NET server.
|
||||
pub const DOTNET_ENV_UI_TOKEN: &str = "AI_STUDIO_UI_TOKEN";
|
||||
|
||||
/// The host of the app. The .NET server listens on this address only, and the session cookie
|
||||
/// belongs to it.
|
||||
pub const APP_HOST: &str = "127.0.0.1";
|
||||
|
||||
/// The file in the root of the repository which holds the boot link of a development build.
|
||||
#[cfg(debug_assertions)]
|
||||
const DEV_BOOT_LINK_FILE: &str = "dev-boot-link.txt";
|
||||
|
||||
/// Builds the session cookie of the app window.
|
||||
///
|
||||
/// The cookie is for the app host alone and lives as long as the window: it has no expiry date,
|
||||
/// scripts cannot read it, and the window never sends it with a request which another site
|
||||
/// started. We parse it from text because the `cookie` crate, whose builder would need its
|
||||
/// `SameSite` type, is no direct dependency of ours.
|
||||
pub fn session_cookie() -> Cookie<'static> {
|
||||
let token = UI_ACCESS_TOKEN.to_hex_text();
|
||||
Cookie::parse(format!("{SESSION_COOKIE_NAME}={token}; Domain={APP_HOST}; Path=/; HttpOnly; SameSite=Strict"))
|
||||
.expect("The session cookie consists of fixed attributes and a hexadecimal token, which always parse.")
|
||||
}
|
||||
|
||||
/// Puts the session cookie into the cookie store of the given window, before the window opens
|
||||
/// the app at the given URL.
|
||||
///
|
||||
/// Setting the cookie does not wait for the webview, and WebView2 even adds it in the background.
|
||||
/// Reading the cookies back waits until the webview has handled everything sent before, so the
|
||||
/// cookie is in place once this function returns. That reading is also the only way to learn
|
||||
/// whether setting the cookie worked.
|
||||
///
|
||||
/// Blocks until the main thread has answered, so never call it on a worker of the async runtime.
|
||||
/// The log never shows the value of the cookie.
|
||||
pub fn hand_over_session_cookie(window: &WebviewWindow, app_url: &Url) {
|
||||
if let Err(error) = window.set_cookie(session_cookie()) {
|
||||
error!(Source = "App access"; "Failed to hand the session cookie to the app window: {error}");
|
||||
return;
|
||||
}
|
||||
|
||||
match app_host_cookies(window, app_url) {
|
||||
Ok(cookies) if cookies.iter().any(|cookie| cookie.name() == SESSION_COOKIE_NAME) => {
|
||||
info!(Source = "App access"; "The app window got its session cookie.");
|
||||
},
|
||||
|
||||
Ok(_) => {
|
||||
error!(Source = "App access"; "The app window lacks its session cookie after it was set.");
|
||||
},
|
||||
|
||||
Err(error) => {
|
||||
error!(Source = "App access"; "Failed to check the session cookie of the app window: {error}");
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the cookies of the app host from the given window.
|
||||
///
|
||||
/// On macOS, wry picks the cookies for a URL by comparing their domain with the domain of the
|
||||
/// URL. An IP address such as the app host has no domain in that sense, so wry would never find
|
||||
/// a cookie of the app there. We read all cookies instead and pick those of the app host
|
||||
/// ourselves.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn app_host_cookies(window: &WebviewWindow, _app_url: &Url) -> tauri::Result<Vec<Cookie<'static>>> {
|
||||
Ok(window.cookies()?.into_iter().filter(|cookie| cookie.domain() == Some(APP_HOST)).collect())
|
||||
}
|
||||
|
||||
/// Reads the cookies of the app host from the given window.
|
||||
///
|
||||
/// WebView2 and WebKitGTK pick the cookies for a URL by themselves. Reading all cookies instead
|
||||
/// would need WebKitGTK 2.42 or newer.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn app_host_cookies(window: &WebviewWindow, app_url: &Url) -> tauri::Result<Vec<Cookie<'static>>> {
|
||||
window.cookies_for_url(app_url.clone())
|
||||
}
|
||||
|
||||
/// Returns the link with which another browser gets the session cookie of a development build,
|
||||
/// such as the browser of an agent which checks a change in the running app.
|
||||
#[cfg(debug_assertions)]
|
||||
fn dev_boot_link(port: u16) -> String {
|
||||
format!("http://{APP_HOST}:{port}/dev/boot?token={}", UI_ACCESS_TOKEN.to_hex_text())
|
||||
}
|
||||
|
||||
/// Writes the boot link of a development build into a file of its own in the root of the
|
||||
/// repository. It never goes into the startup env file: that file holds the secrets of the IPC,
|
||||
/// which a browser needs none of.
|
||||
///
|
||||
/// Only development builds contain this function. A release has no boot link, and its .NET
|
||||
/// server knows no route which would take one. The log names the file, never the link.
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn write_dev_boot_link(port: u16) {
|
||||
let path = std::path::PathBuf::from("..").join(DEV_BOOT_LINK_FILE);
|
||||
match std::fs::write(&path, dev_boot_link(port)) {
|
||||
Ok(_) => info!(Source = "App access"; "Wrote the boot link of this development build to '{}'.", path.display()),
|
||||
Err(error) => error!(Source = "App access"; "Failed to write the boot link of this development build to '{}': {error}", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_session_cookie_carries_the_token() {
|
||||
let cookie = session_cookie();
|
||||
|
||||
assert_eq!(cookie.name(), SESSION_COOKIE_NAME);
|
||||
assert_eq!(cookie.value(), UI_ACCESS_TOKEN.to_hex_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_session_cookie_belongs_to_the_app_host_alone() {
|
||||
let cookie = session_cookie();
|
||||
|
||||
assert_eq!(cookie.domain(), Some(APP_HOST));
|
||||
assert_eq!(cookie.path(), Some("/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_session_cookie_is_hidden_from_scripts_and_other_sites() {
|
||||
let cookie = session_cookie();
|
||||
|
||||
assert_eq!(cookie.http_only(), Some(true));
|
||||
assert!(cookie.same_site().is_some_and(|same_site| same_site.is_strict()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_session_cookie_lives_only_as_long_as_the_window() {
|
||||
let cookie = session_cookie();
|
||||
|
||||
assert!(cookie.expires().is_none());
|
||||
assert!(cookie.max_age().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(debug_assertions)]
|
||||
fn the_dev_boot_link_leads_to_the_boot_route_of_the_app_host() {
|
||||
let link = Url::parse(dev_boot_link(61153).as_str()).unwrap();
|
||||
|
||||
assert_eq!(link.host_str(), Some(APP_HOST));
|
||||
assert_eq!(link.port(), Some(61153));
|
||||
assert_eq!(link.path(), "/dev/boot");
|
||||
|
||||
let token = link.query_pairs().find(|(key, _)| key == "token").map(|(_, value)| value.into_owned());
|
||||
assert_eq!(token.as_deref(), Some(UI_ACCESS_TOKEN.to_hex_text()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ui_token_differs_from_the_api_token() {
|
||||
assert!(!UI_ACCESS_TOKEN.validate(&APIToken::from_hex_text(crate::runtime_api_token::API_TOKEN.to_hex_text())));
|
||||
}
|
||||
}
|
||||
+28
-11
@@ -21,6 +21,7 @@ use tauri_plugin_updater::{UpdaterExt, Update};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::time;
|
||||
use crate::app_access::hand_over_session_cookie;
|
||||
use crate::clipboard::shutdown_clipboard;
|
||||
use crate::data_restore::apply_pending_restore;
|
||||
use crate::dotnet::{cleanup_dotnet_server, start_dotnet_server, stop_dotnet_server};
|
||||
@@ -312,7 +313,7 @@ fn is_tauri_asset_url(url: &tauri::Url) -> bool {
|
||||
matches!(url.scheme(), "http" | "https") && is_tauri_asset_host(url.host_str())
|
||||
}
|
||||
|
||||
fn is_local_http_url(url: &tauri::Url) -> bool {
|
||||
pub(crate) fn is_local_http_url(url: &tauri::Url) -> bool {
|
||||
matches!(url.scheme(), "http" | "https") && is_local_host(url.host_str())
|
||||
}
|
||||
|
||||
@@ -677,8 +678,9 @@ pub fn send_event(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes the location of the main window to the given URL.
|
||||
pub async fn change_location_to(url: &str) {
|
||||
/// Changes the location of the main window to the given URL. The window gets its session cookie
|
||||
/// first, so that the .NET server lets it in.
|
||||
pub async fn change_location_to(url: tauri::Url) {
|
||||
// Try to get the main window. If it is not available yet, wait for it:
|
||||
let mut main_window_ready = false;
|
||||
let mut main_window_status_reported = false;
|
||||
@@ -700,16 +702,31 @@ pub async fn change_location_to(url: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(parsed_url) = tauri::Url::parse(url) && is_local_http_url(&parsed_url) {
|
||||
*APPROVED_APP_URL.lock().unwrap() = Some(parsed_url);
|
||||
if is_local_http_url(&url) {
|
||||
*APPROVED_APP_URL.lock().unwrap() = Some(url.clone());
|
||||
}
|
||||
|
||||
let js_location_change = format!("window.location = '{url}';");
|
||||
let main_window = main_window_spawn_clone.lock().unwrap();
|
||||
let location_change_result = main_window.as_ref().unwrap().eval(js_location_change.as_str());
|
||||
match location_change_result {
|
||||
Ok(_) => info!("The app location was changed to {url}."),
|
||||
Err(e) => error!("Failed to change the app location to {url}: {e}."),
|
||||
//
|
||||
// The window navigates by itself instead of through a script of the page it shows. That page
|
||||
// comes from Tauri, which is another site than the app, so the app would count as opened by
|
||||
// another site, and the window would withhold the session cookie. We take the window out of
|
||||
// the lock first: the cookie and the navigation need the main thread, which may wait for this
|
||||
// lock meanwhile.
|
||||
//
|
||||
// Handing over the cookie waits for the main thread. This function serves a request of the
|
||||
// runtime API, so the waiting happens outside the async runtime, see runtime/AGENTS.md.
|
||||
//
|
||||
let main_window = main_window_spawn_clone.lock().unwrap().clone().unwrap();
|
||||
let navigation = tokio::task::spawn_blocking(move || {
|
||||
hand_over_session_cookie(&main_window, &url);
|
||||
match main_window.navigate(url.clone()) {
|
||||
Ok(_) => info!("The app location was changed to {url}."),
|
||||
Err(e) => error!("Failed to change the app location to {url}: {e}."),
|
||||
}
|
||||
});
|
||||
|
||||
if let Err(error) = navigation.await {
|
||||
error!("Failed to open the app in the main window: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+76
-23
@@ -9,6 +9,7 @@ use tauri::{Manager, Url};
|
||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent, TerminatedPayload};
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use crate::runtime_api_token::API_TOKEN;
|
||||
use crate::app_access::{APP_HOST, DOTNET_ENV_UI_TOKEN, UI_ACCESS_TOKEN};
|
||||
use crate::app_window::change_location_to;
|
||||
use crate::runtime_certificate::CERTIFICATE_FINGERPRINT;
|
||||
use crate::encryption::ENCRYPTION;
|
||||
@@ -177,46 +178,43 @@ fn dotnet_bundle_extraction_directory<R: tauri::Runtime>(app_handle: &tauri::App
|
||||
/// but also remove that code from any production build.
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn create_startup_env_file() {
|
||||
|
||||
// Get the secret password & salt and convert it to a base64 string:
|
||||
let secret_password = BASE64_STANDARD.encode(ENCRYPTION.secret_password);
|
||||
let secret_key_salt = BASE64_STANDARD.encode(ENCRYPTION.secret_key_salt);
|
||||
let api_port = *API_SERVER_PORT;
|
||||
|
||||
warn!(Source = "Bootloader .NET"; "Development environment detected; create the startup env file at '../startup.env'.");
|
||||
let env_file_path = std::path::PathBuf::from("..").join("startup.env");
|
||||
let mut env_file = std::fs::File::create(env_file_path).unwrap();
|
||||
let mut env_file_lines = vec![
|
||||
format!("AI_STUDIO_SECRET_PASSWORD={secret_password}"),
|
||||
format!("AI_STUDIO_SECRET_KEY_SALT={secret_key_salt}"),
|
||||
format!("AI_STUDIO_CERTIFICATE_FINGERPRINT={}", CERTIFICATE_FINGERPRINT.get().unwrap()),
|
||||
format!("AI_STUDIO_API_PORT={api_port}"),
|
||||
format!("AI_STUDIO_API_TOKEN={}", API_TOKEN.to_hex_text()),
|
||||
];
|
||||
for (key, value) in external_http_custom_root_certificate_policy_environment() {
|
||||
env_file_lines.push(format!("{key}={value}"));
|
||||
}
|
||||
let env_file_lines: Vec<String> = ipc_environment()
|
||||
.into_iter()
|
||||
.map(|(key, value)| format!("{key}={value}"))
|
||||
.collect();
|
||||
|
||||
std::io::Write::write_all(&mut env_file, env_file_lines.join("\n").as_bytes()).unwrap();
|
||||
info!(Source = "Bootloader .NET"; "The startup env file was created successfully.");
|
||||
}
|
||||
|
||||
/// Starts the .NET server in a separate process.
|
||||
pub fn start_dotnet_server<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>) {
|
||||
/// Returns the environment which the .NET server needs to talk to this runtime and to let only
|
||||
/// the app window in. The sidecar gets it, and so does the startup env file in the development
|
||||
/// environment, so neither of them can miss a variable.
|
||||
fn ipc_environment() -> Vec<(String, String)> {
|
||||
|
||||
// Get the secret password & salt and convert it to a base64 string:
|
||||
let secret_password = BASE64_STANDARD.encode(ENCRYPTION.secret_password);
|
||||
let secret_key_salt = BASE64_STANDARD.encode(ENCRYPTION.secret_key_salt);
|
||||
let api_port = *API_SERVER_PORT;
|
||||
|
||||
let mut dotnet_server_environment: HashMap<String, String> = HashMap::from_iter([
|
||||
let mut environment = vec![
|
||||
(String::from("AI_STUDIO_SECRET_PASSWORD"), secret_password),
|
||||
(String::from("AI_STUDIO_SECRET_KEY_SALT"), secret_key_salt),
|
||||
(String::from("AI_STUDIO_CERTIFICATE_FINGERPRINT"), CERTIFICATE_FINGERPRINT.get().unwrap().to_string()),
|
||||
(String::from("AI_STUDIO_API_PORT"), format!("{api_port}")),
|
||||
(String::from("AI_STUDIO_API_TOKEN"), API_TOKEN.to_hex_text().to_string()),
|
||||
]);
|
||||
dotnet_server_environment.extend(external_http_custom_root_certificate_policy_environment());
|
||||
(String::from(DOTNET_ENV_UI_TOKEN), UI_ACCESS_TOKEN.to_hex_text().to_string()),
|
||||
];
|
||||
environment.extend(external_http_custom_root_certificate_policy_environment());
|
||||
environment
|
||||
}
|
||||
|
||||
/// Starts the .NET server in a separate process.
|
||||
pub fn start_dotnet_server<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>) {
|
||||
let mut dotnet_server_environment: HashMap<String, String> = HashMap::from_iter(ipc_environment());
|
||||
if let Some(extraction_directory) = dotnet_bundle_extraction_directory(&app_handle) {
|
||||
dotnet_server_environment.insert(String::from(DOTNET_ENV_BUNDLE_EXTRACT_BASE_DIR), extraction_directory);
|
||||
}
|
||||
@@ -303,7 +301,12 @@ pub async fn dotnet_ready() {
|
||||
}
|
||||
|
||||
let dotnet_server_port = *DOTNET_SERVER_PORT;
|
||||
let url = match Url::parse(format!("http://localhost:{dotnet_server_port}").as_str())
|
||||
|
||||
// A development build lets other browsers in, too, such as the one of an agent:
|
||||
#[cfg(debug_assertions)]
|
||||
tokio::task::spawn_blocking(move || crate::app_access::write_dev_boot_link(dotnet_server_port));
|
||||
|
||||
let url = match Url::parse(app_address(dotnet_server_port).as_str())
|
||||
{
|
||||
Ok(url) => url,
|
||||
Err(msg) => {
|
||||
@@ -312,7 +315,14 @@ pub async fn dotnet_ready() {
|
||||
}
|
||||
};
|
||||
|
||||
change_location_to(url.as_str()).await;
|
||||
change_location_to(url).await;
|
||||
}
|
||||
|
||||
/// Returns the address of the app on the .NET server. The server listens on the IPv4 loopback
|
||||
/// address only, so the window goes there directly instead of resolving `localhost`, which may
|
||||
/// answer with the IPv6 loopback address first.
|
||||
fn app_address(port: u16) -> String {
|
||||
format!("http://{APP_HOST}:{port}/")
|
||||
}
|
||||
|
||||
/// Stops the .NET server process.
|
||||
@@ -336,4 +346,47 @@ pub fn cleanup_dotnet_server() {
|
||||
if let Err(e) = kill_stale_process(pid_path, SIDECAR_TYPE) {
|
||||
warn!(Source = ".NET"; "Error during the cleanup of .NET: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_window::is_local_http_url;
|
||||
|
||||
#[test]
|
||||
fn the_app_address_is_the_ipv4_loopback_address() {
|
||||
let url = Url::parse(app_address(61153).as_str()).unwrap();
|
||||
|
||||
assert_eq!(url.host_str(), Some("127.0.0.1"));
|
||||
assert_eq!(url.port(), Some(61153));
|
||||
assert_eq!(url.path(), "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_app_address_counts_as_local() {
|
||||
let url = Url::parse(app_address(61153).as_str()).unwrap();
|
||||
assert!(is_local_http_url(&url));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ipc_environment_carries_every_variable_the_dotnet_server_needs() {
|
||||
// Only the start of the app generates the certificate this fingerprint belongs to:
|
||||
let _ = CERTIFICATE_FINGERPRINT.set(String::from("test-fingerprint"));
|
||||
|
||||
let environment = ipc_environment();
|
||||
let keys: Vec<&str> = environment.iter().map(|(key, _)| key.as_str()).collect();
|
||||
for expected in [
|
||||
"AI_STUDIO_SECRET_PASSWORD",
|
||||
"AI_STUDIO_SECRET_KEY_SALT",
|
||||
"AI_STUDIO_CERTIFICATE_FINGERPRINT",
|
||||
"AI_STUDIO_API_PORT",
|
||||
"AI_STUDIO_API_TOKEN",
|
||||
DOTNET_ENV_UI_TOKEN,
|
||||
] {
|
||||
assert!(keys.contains(&expected), "The environment lacks {expected}.");
|
||||
}
|
||||
|
||||
let ui_token = environment.iter().find(|(key, _)| key == DOTNET_ENV_UI_TOKEN).map(|(_, value)| value.as_str());
|
||||
assert_eq!(ui_token, Some(UI_ACCESS_TOKEN.to_hex_text()));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod environment;
|
||||
pub mod dotnet;
|
||||
pub mod network;
|
||||
pub mod api_token;
|
||||
pub mod app_access;
|
||||
pub mod app_window;
|
||||
pub mod secret;
|
||||
pub mod clipboard;
|
||||
|
||||
Reference in new issue
Block a user