From 8fa0c4db013b717915cbdc793f2b90fe15b48134 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 19 Jul 2026 19:56:30 +0200 Subject: [PATCH] Improve Linux clipboard support and file handling (#872) --- .../Components/AttachDocuments.razor.cs | 35 ++++- .../Components/ConfigurationFile.razor | 2 +- .../Components/ConfigurationFile.razor.cs | 22 ++- .../Components/ReadFileContent.razor.cs | 32 +++- .../Components/SelectDirectory.razor | 2 +- .../Components/SelectDirectory.razor.cs | 20 ++- .../Components/SelectFile.razor | 2 +- .../Components/SelectFile.razor.cs | 20 ++- .../Settings/SettingsDialogChatTemplate.razor | 2 +- .../SettingsDialogChatTemplate.razor.cs | 20 ++- .../Tools/Services/RustService.FileSystem.cs | 142 +++++++++++------- .../Tools/Services/RustService.cs | 1 + .../wwwroot/changelog/v26.7.3.md | 2 + runtime/Cargo.lock | 128 ++++++++-------- runtime/Cargo.toml | 4 +- runtime/src/clipboard.rs | 75 ++++++++- 16 files changed, 343 insertions(+), 166 deletions(-) diff --git a/app/MindWork AI Studio/Components/AttachDocuments.razor.cs b/app/MindWork AI Studio/Components/AttachDocuments.razor.cs index c52bd115..87289024 100644 --- a/app/MindWork AI Studio/Components/AttachDocuments.razor.cs +++ b/app/MindWork AI Studio/Components/AttachDocuments.razor.cs @@ -102,13 +102,14 @@ public partial class AttachDocuments : MSGComponentBase private uint numDropAreasAboveThis; private bool isComponentHovered; private bool isDraggingOver; + private bool isFileDialogOpen; private MediaImportOwner EffectiveImportOwner => this.OwnerChat is not null ? MediaImportOwner.ForChat(this.OwnerChat.ChatId) : this.ImportOwner ?? this.fallbackMediaImportOwner; private MediaImportTarget EffectiveMediaImportTarget => new(this.EffectiveImportOwner, string.IsNullOrWhiteSpace(this.Name) ? "attachments" : this.Name); - private bool IsUnavailable => this.Disabled || this.MediaTranscriptionService.IsBusy(this.EffectiveImportOwner); + private bool IsUnavailable => this.Disabled || this.isFileDialogOpen || this.MediaTranscriptionService.IsBusy(this.EffectiveImportOwner); #region Overrides of MSGComponentBase @@ -310,13 +311,21 @@ public partial class AttachDocuments : MSGComponentBase if (this.IsUnavailable) return; - var selectFiles = await this.RustService.SelectFiles(T("Select files to attach")); - if (selectFiles.UserCancelled) - return; + this.isFileDialogOpen = true; + try + { + var selectFiles = await this.RustService.SelectFiles(T("Select files to attach")); + if (selectFiles.UserCancelled) + return; - await this.AddFileBatchAsync(selectFiles.SelectedFilePaths); - await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths); - await this.OnChange(this.DocumentPaths); + await this.AddFileBatchAsync(selectFiles.SelectedFilePaths); + await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths); + await this.OnChange(this.DocumentPaths); + } + finally + { + this.isFileDialogOpen = false; + } } private async Task OpenAttachmentsDialog() @@ -397,7 +406,17 @@ public partial class AttachDocuments : MSGComponentBase private async Task AddFileBatchAsync(IEnumerable paths) { - var existingPaths = paths.Where(File.Exists).ToList(); + var pathList = paths.ToList(); + var inaccessiblePaths = pathList.Where(path => !File.Exists(path)).ToList(); + if (inaccessiblePaths.Count > 0) + { + this.Logger.LogWarning("Could not access {Count} dropped or selected file(s): {Paths}", inaccessiblePaths.Count, string.Join(", ", inaccessiblePaths)); + await this.MessageBus.SendWarning(new( + Icons.Material.Filled.Warning, + this.T("Some files could not be accessed. Please select them with the file chooser instead."))); + } + + var existingPaths = pathList.Except(inaccessiblePaths).ToList(); var mediaPaths = existingPaths.Where(IsTranscribableMedia).ToList(); var regularPaths = existingPaths.Except(mediaPaths).ToList(); diff --git a/app/MindWork AI Studio/Components/ConfigurationFile.razor b/app/MindWork AI Studio/Components/ConfigurationFile.razor index ed2f9be2..06ec26b0 100644 --- a/app/MindWork AI Studio/Components/ConfigurationFile.razor +++ b/app/MindWork AI Studio/Components/ConfigurationFile.razor @@ -19,7 +19,7 @@ Variant="Variant.Outlined" Color="Color.Primary" Size="Size.Small" - Disabled="@this.IsDisabled" + Disabled="@(this.IsDisabled || this.isFileDialogOpen)" Class="mb-1" OnClick="@this.OpenFileDialog"> @T("Choose File") diff --git a/app/MindWork AI Studio/Components/ConfigurationFile.razor.cs b/app/MindWork AI Studio/Components/ConfigurationFile.razor.cs index 82d56d18..b9042586 100644 --- a/app/MindWork AI Studio/Components/ConfigurationFile.razor.cs +++ b/app/MindWork AI Studio/Components/ConfigurationFile.razor.cs @@ -49,6 +49,7 @@ public partial class ConfigurationFile : ConfigurationBaseCore private RustService RustService { get; init; } = null!; private string internalText = string.Empty; + private bool isFileDialogOpen; private readonly Timer timer = new(TimeSpan.FromMilliseconds(500)) { AutoReset = false @@ -90,13 +91,24 @@ public partial class ConfigurationFile : ConfigurationBaseCore private async Task OpenFileDialog() { - var response = await this.RustService.SelectFile(this.FileDialogTitle, this.Filter, string.IsNullOrWhiteSpace(this.internalText) ? null : this.internalText); - if (response.UserCancelled) + if (this.isFileDialogOpen) return; - this.timer.Stop(); - this.internalText = response.SelectedFilePath; - await this.OptionChanged(response.SelectedFilePath); + this.isFileDialogOpen = true; + try + { + var response = await this.RustService.SelectFile(this.FileDialogTitle, this.Filter, string.IsNullOrWhiteSpace(this.internalText) ? null : this.internalText); + if (response.UserCancelled) + return; + + this.timer.Stop(); + this.internalText = response.SelectedFilePath; + await this.OptionChanged(response.SelectedFilePath); + } + finally + { + this.isFileDialogOpen = false; + } } private async Task OptionChanged(string updatedText) diff --git a/app/MindWork AI Studio/Components/ReadFileContent.razor.cs b/app/MindWork AI Studio/Components/ReadFileContent.razor.cs index 4a200f1f..dd2887b0 100644 --- a/app/MindWork AI Studio/Components/ReadFileContent.razor.cs +++ b/app/MindWork AI Studio/Components/ReadFileContent.razor.cs @@ -74,9 +74,10 @@ public partial class ReadFileContent : MSGComponentBase private string dragClass = DEFAULT_DRAG_CLASS; private uint numDropAreasAboveThis; private bool isComponentHovered; + private bool isFileDialogOpen; private bool IsCurrentTargetBusy => this.MediaTranscriptionService.GetSnapshot(this.EffectiveImportOwner) is { IsBusy: true } snapshot && snapshot.Target == this.EffectiveMediaImportTarget; - private bool IsUnavailable => this.Disabled || this.MediaTranscriptionService.IsBusy(this.EffectiveImportOwner); + private bool IsUnavailable => this.Disabled || this.isFileDialogOpen || this.MediaTranscriptionService.IsBusy(this.EffectiveImportOwner); #region Overrides of MSGComponentBase @@ -217,14 +218,22 @@ public partial class ReadFileContent : MSGComponentBase if (this.IsUnavailable) return; - var selectedFile = await this.RustService.SelectFile(T("Select file to read its content")); - if (selectedFile.UserCancelled) + this.isFileDialogOpen = true; + try { - this.Logger.LogInformation("User cancelled the file selection"); - return; - } + var selectedFile = await this.RustService.SelectFile(T("Select file to read its content")); + if (selectedFile.UserCancelled) + { + this.Logger.LogInformation("User cancelled the file selection"); + return; + } - await this.LoadFileIfValid(selectedFile.SelectedFilePath); + await this.LoadFileIfValid(selectedFile.SelectedFilePath); + } + finally + { + this.isFileDialogOpen = false; + } } private async Task EnsurePandocAvailability() @@ -246,6 +255,15 @@ public partial class ReadFileContent : MSGComponentBase private async Task LoadFirstValidFile(List paths) { + var inaccessiblePaths = paths.Where(path => !File.Exists(path)).ToList(); + if (inaccessiblePaths.Count > 0) + { + this.Logger.LogWarning("Could not access {Count} dropped file(s): {Paths}", inaccessiblePaths.Count, string.Join(", ", inaccessiblePaths)); + await this.MessageBus.SendWarning(new( + Icons.Material.Filled.Warning, + this.T("Some dropped files could not be accessed. Please select them with the file chooser instead."))); + } + foreach (var path in paths) { if (await this.LoadFileIfValid(path)) diff --git a/app/MindWork AI Studio/Components/SelectDirectory.razor b/app/MindWork AI Studio/Components/SelectDirectory.razor index 1cf19ec4..096db371 100644 --- a/app/MindWork AI Studio/Components/SelectDirectory.razor +++ b/app/MindWork AI Studio/Components/SelectDirectory.razor @@ -13,7 +13,7 @@ Variant="Variant.Outlined" /> - + @T("Choose Directory") \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/SelectDirectory.razor.cs b/app/MindWork AI Studio/Components/SelectDirectory.razor.cs index a305f2b7..6f576435 100644 --- a/app/MindWork AI Studio/Components/SelectDirectory.razor.cs +++ b/app/MindWork AI Studio/Components/SelectDirectory.razor.cs @@ -31,6 +31,7 @@ public partial class SelectDirectory : MSGComponentBase protected ILogger Logger { get; init; } = null!; private static readonly Dictionary SPELLCHECK_ATTRIBUTES = new(); + private bool isDirectoryDialogOpen; #region Overrides of ComponentBase @@ -51,10 +52,21 @@ public partial class SelectDirectory : MSGComponentBase private async Task OpenDirectoryDialog() { - var response = await this.RustService.SelectDirectory(this.DirectoryDialogTitle, string.IsNullOrWhiteSpace(this.Directory) ? null : this.Directory); - this.Logger.LogInformation($"The user selected the directory '{response.SelectedDirectory}'."); + if (this.isDirectoryDialogOpen) + return; - if (!response.UserCancelled) - this.InternalDirectoryChanged(response.SelectedDirectory); + this.isDirectoryDialogOpen = true; + try + { + var response = await this.RustService.SelectDirectory(this.DirectoryDialogTitle, string.IsNullOrWhiteSpace(this.Directory) ? null : this.Directory); + this.Logger.LogInformation("The user selected the directory '{SelectedDirectory}'.", response.SelectedDirectory); + + if (!response.UserCancelled) + this.InternalDirectoryChanged(response.SelectedDirectory); + } + finally + { + this.isDirectoryDialogOpen = false; + } } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/SelectFile.razor b/app/MindWork AI Studio/Components/SelectFile.razor index de3971e5..726965fd 100644 --- a/app/MindWork AI Studio/Components/SelectFile.razor +++ b/app/MindWork AI Studio/Components/SelectFile.razor @@ -13,7 +13,7 @@ Variant="Variant.Outlined" /> - + @T("Choose File") \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/SelectFile.razor.cs b/app/MindWork AI Studio/Components/SelectFile.razor.cs index 91c7a667..de1f89a3 100644 --- a/app/MindWork AI Studio/Components/SelectFile.razor.cs +++ b/app/MindWork AI Studio/Components/SelectFile.razor.cs @@ -35,6 +35,7 @@ public partial class SelectFile : MSGComponentBase protected ILogger Logger { get; init; } = null!; private static readonly Dictionary SPELLCHECK_ATTRIBUTES = new(); + private bool isFileDialogOpen; #region Overrides of ComponentBase @@ -55,10 +56,21 @@ public partial class SelectFile : MSGComponentBase private async Task OpenFileDialog() { - var response = await this.RustService.SelectFile(this.FileDialogTitle, this.Filter, string.IsNullOrWhiteSpace(this.File) ? null : this.File); - this.Logger.LogInformation($"The user selected the file '{response.SelectedFilePath}'."); + if (this.isFileDialogOpen) + return; - if (!response.UserCancelled) - this.InternalFileChanged(response.SelectedFilePath); + this.isFileDialogOpen = true; + try + { + var response = await this.RustService.SelectFile(this.FileDialogTitle, this.Filter, string.IsNullOrWhiteSpace(this.File) ? null : this.File); + this.Logger.LogInformation("The user selected the file '{SelectedFilePath}'.", response.SelectedFilePath); + + if (!response.UserCancelled) + this.InternalFileChanged(response.SelectedFilePath); + } + finally + { + this.isFileDialogOpen = false; + } } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor index 19680575..69483493 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor @@ -63,7 +63,7 @@ @T("Use shared attachment paths") - + @T("Copy attachments into plugin") diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor.cs b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor.cs index 54a2f631..d6dbb2da 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor.cs +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor.cs @@ -6,6 +6,8 @@ namespace AIStudio.Dialogs.Settings; public partial class SettingsDialogChatTemplate : SettingsDialogBase { + private bool isPluginDirectoryDialogOpen; + [Parameter] public bool CreateTemplateFromExistingChatThread { get; set; } @@ -131,7 +133,7 @@ public partial class SettingsDialogChatTemplate : SettingsDialogBase private async Task ExportChatTemplateWithPackagedAttachments(ChatTemplate chatTemplate) { - if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings) + if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings || this.isPluginDirectoryDialogOpen) return; if (chatTemplate == ChatTemplate.NO_CHAT_TEMPLATE || chatTemplate.IsEnterpriseConfiguration) @@ -143,11 +145,19 @@ public partial class SettingsDialogChatTemplate : SettingsDialogBase return; } - var pluginDirectoryResponse = await this.RustService.SelectDirectory(T("Select configuration plugin folder")); - if (pluginDirectoryResponse.UserCancelled) - return; + this.isPluginDirectoryDialogOpen = true; + try + { + var pluginDirectoryResponse = await this.RustService.SelectDirectory(T("Select configuration plugin folder")); + if (pluginDirectoryResponse.UserCancelled) + return; - await this.CopyPackagedChatTemplateLuaToClipboard(chatTemplate, pluginDirectoryResponse.SelectedDirectory); + await this.CopyPackagedChatTemplateLuaToClipboard(chatTemplate, pluginDirectoryResponse.SelectedDirectory); + } + finally + { + this.isPluginDirectoryDialogOpen = false; + } } private async Task CopyChatTemplateLuaToClipboard(ChatTemplate chatTemplate) diff --git a/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs b/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs index 89fef1f4..81a64e8c 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; + using AIStudio.Tools.Rust; namespace AIStudio.Tools.Services; @@ -6,56 +8,90 @@ public sealed partial class RustService { public async Task SelectDirectory(string title, string? initialDirectory = null) { - var encodedTitle = Uri.EscapeDataString(title); - var result = initialDirectory is null - ? await this.http.PostAsync($"/select/directory?title={encodedTitle}", null) - : await this.http.PostAsJsonAsync($"/select/directory?title={encodedTitle}", new PreviousDirectory(initialDirectory), this.jsonRustSerializerOptions); - - if (!result.IsSuccessStatusCode) + return await this.RunFileDialog( + "select directory", + async () => + { + var encodedTitle = Uri.EscapeDataString(title); + var result = initialDirectory is null + ? await this.http.PostAsync($"/select/directory?title={encodedTitle}", null) + : await this.http.PostAsJsonAsync($"/select/directory?title={encodedTitle}", new PreviousDirectory(initialDirectory), this.jsonRustSerializerOptions); + + if (result.IsSuccessStatusCode) + return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + + this.logger!.LogError("Failed to select a directory: '{StatusCode}'", result.StatusCode); + return new DirectorySelectionResponse(true, string.Empty); + }, + new DirectorySelectionResponse(true, string.Empty)); + } + + private async Task RunFileDialog(string operation, Func> showDialog, T cancelledResult) + { + if (!await this.fileDialogLock.WaitAsync(0)) { - this.logger!.LogError($"Failed to select a directory: '{result.StatusCode}'"); - return new DirectorySelectionResponse(true, string.Empty); + this.logger!.LogInformation("Ignored duplicate file dialog request for '{Operation}'.", operation); + return cancelledResult; + } + + var stopwatch = Stopwatch.StartNew(); + this.logger!.LogInformation("Opening file dialog for '{Operation}'.", operation); + try + { + return await showDialog(); + } + finally + { + stopwatch.Stop(); + this.fileDialogLock.Release(); + this.logger!.LogInformation("File dialog for '{Operation}' completed after {ElapsedMilliseconds} ms.", operation, stopwatch.ElapsedMilliseconds); } - - return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); } public async Task SelectFile(string title, FileTypeFilter[]? filter = null, string? initialFile = null) { - var payload = new SelectFileOptions - { - Title = title, - PreviousFile = initialFile is null ? null : new (initialFile), - Filter = FileTypes.AsOneFileType(filter) - }; + return await this.RunFileDialog( + "select file", + async () => + { + var payload = new SelectFileOptions + { + Title = title, + PreviousFile = initialFile is null ? null : new (initialFile), + Filter = FileTypes.AsOneFileType(filter) + }; - var result = await this.http.PostAsJsonAsync("/select/file", payload, this.jsonRustSerializerOptions); - if (!result.IsSuccessStatusCode) - { - this.logger!.LogError($"Failed to select a file: '{result.StatusCode}'"); - return new FileSelectionResponse(true, string.Empty); - } + var result = await this.http.PostAsJsonAsync("/select/file", payload, this.jsonRustSerializerOptions); + if (result.IsSuccessStatusCode) + return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); - return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + this.logger!.LogError("Failed to select a file: '{StatusCode}'", result.StatusCode); + return new FileSelectionResponse(true, string.Empty); + }, + new FileSelectionResponse(true, string.Empty)); } public async Task SelectFiles(string title, FileTypeFilter[]? filter = null, string? initialFile = null) { - var payload = new SelectFileOptions - { - Title = title, - PreviousFile = initialFile is null ? null : new (initialFile), - Filter = FileTypes.AsOneFileType(filter) - }; + return await this.RunFileDialog( + "select files", + async () => + { + var payload = new SelectFileOptions + { + Title = title, + PreviousFile = initialFile is null ? null : new (initialFile), + Filter = FileTypes.AsOneFileType(filter) + }; - var result = await this.http.PostAsJsonAsync("/select/files", payload, this.jsonRustSerializerOptions); - if (!result.IsSuccessStatusCode) - { - this.logger!.LogError($"Failed to select files: '{result.StatusCode}'"); - return new FilesSelectionResponse(true, Array.Empty()); - } + var result = await this.http.PostAsJsonAsync("/select/files", payload, this.jsonRustSerializerOptions); + if (result.IsSuccessStatusCode) + return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); - return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + this.logger!.LogError("Failed to select files: '{StatusCode}'", result.StatusCode); + return new FilesSelectionResponse(true, Array.Empty()); + }, + new FilesSelectionResponse(true, Array.Empty())); } /// @@ -68,21 +104,25 @@ public sealed partial class RustService /// operation and whether the select operation was successful. public async Task SaveFile(string title, FileTypeFilter[]? filter = null, string? initialFile = null) { - var payload = new SaveFileOptions - { - Title = title, - PreviousFile = initialFile is null ? null : new (initialFile), - Filter = FileTypes.AsOneFileType(filter) - }; - - var result = await this.http.PostAsJsonAsync("/save/file", payload, this.jsonRustSerializerOptions); - if (!result.IsSuccessStatusCode) - { - this.logger!.LogError($"Failed to select a file for writing operation '{result.StatusCode}'"); - return new FileSaveResponse(true, string.Empty); - } - - return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + return await this.RunFileDialog( + "save file", + async () => + { + var payload = new SaveFileOptions + { + Title = title, + PreviousFile = initialFile is null ? null : new (initialFile), + Filter = FileTypes.AsOneFileType(filter) + }; + + var result = await this.http.PostAsJsonAsync("/save/file", payload, this.jsonRustSerializerOptions); + if (result.IsSuccessStatusCode) + return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + + this.logger!.LogError("Failed to select a file for writing operation: '{StatusCode}'", result.StatusCode); + return new FileSaveResponse(true, string.Empty); + }, + new FileSaveResponse(true, string.Empty)); } public async Task TryOpenPathInRuntimeFileManager(string path) diff --git a/app/MindWork AI Studio/Tools/Services/RustService.cs b/app/MindWork AI Studio/Tools/Services/RustService.cs index 6bcef10c..6e979bb1 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.cs @@ -17,6 +17,7 @@ public sealed partial class RustService : BackgroundService private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(RustService).Namespace, nameof(RustService)); private readonly HttpClient http; + private readonly SemaphoreSlim fileDialogLock = new(1, 1); private readonly SemaphoreSlim userLanguageLock = new(1, 1); private readonly SemaphoreSlim userNameLock = new(1, 1); diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md b/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md index f17a9a46..a251a490 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md @@ -8,12 +8,14 @@ - Improved the "My Tasks Assistant": you can now provide one or more documents in addition to text or use documents alone when asking to identify tasks. - Improved update guidance for Flatpak installations and added an enterprise option that lets organizations manage updates entirely through their IT department. - Improved secure API-key storage diagnostics on Linux. AI Studio now provides specific guidance when the default password collection is missing or locked, a password-manager prompt is dismissed, or no compatible Secret Service is available. +- Improved the file dialogs to prevent opening multiple times when you click "Open" or "Save" multiple times in a row. - Fixed an issue that could leave AI Studio unresponsive after waking the computer from sleep. Yes, we know this was an annoying bug, and we apologize for the inconvenience. - Fixed connections to internal HTTPS services and enterprise configuration servers that use organization-provided root certificates on Linux. - Fixed enterprise configuration plugins from Windows-created ZIP files may not load correctly on Linux when the ZIP contained plugin files inside a folder. - Fixed the global voice recording shortcut on Linux so it also works outside AI Studio on supported Wayland desktops. - Fixed voice recording and transcription on Linux. - Fixed copied content from AI Studio not remaining available on the clipboard on Linux. +- Fixed dragging and dropping files from the home folder into the Linux Flatpak version. - Fixed being able to switch document analysis policies while an analysis or media transcription was still in progress. - Fixed file extension handling so files are recognized correctly regardless of uppercase or lowercase letters in their extensions. Thanks, Paul Schweiß, for reporting this issue. - Fixed AI Studio failing to start on Linux systems & showing an outdated version on the Flatpak page. diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index f9c42d98..606bbdb8 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -149,7 +149,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -160,7 +160,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -215,6 +215,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -242,27 +243,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "ashpd" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" -dependencies = [ - "enumflags2", - "futures-channel", - "futures-util", - "rand 0.9.4", - "raw-window-handle", - "serde", - "serde_repr", - "tokio", - "url", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "zbus", -] - [[package]] name = "ashpd" version = "0.13.12" @@ -1885,7 +1865,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1911,15 +1891,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dlib" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" -dependencies = [ - "libloading 0.7.4", -] - [[package]] name = "dlopen2" version = "0.8.2" @@ -2226,7 +2197,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4165,7 +4136,7 @@ dependencies = [ "aes 0.9.1", "apple-native-keyring-store", "arboard", - "ashpd 0.13.12", + "ashpd", "async-stream", "axum", "axum-server", @@ -4280,7 +4251,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5230,12 +5201,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "pollster" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" - [[package]] name = "portable-atomic" version = "1.13.1" @@ -5897,18 +5862,18 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" dependencies = [ - "ashpd 0.11.1", "block2 0.6.2", "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", "js-sys", "log", "objc2 0.6.4", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation 0.3.2", - "pollster", "raw-window-handle", - "urlencoding", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -6080,7 +6045,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6139,7 +6104,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6252,12 +6217,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" @@ -6748,7 +6707,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7675,10 +7634,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.1", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8137,7 +8096,19 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", +] + +[[package]] +name = "tree_magic_mini" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f943391d896cdfe8eec03a04d7110332d445be7df856db382dd96a730667562c" +dependencies = [ + "memchr", + "nom 7.1.3", + "once_cell", + "petgraph", ] [[package]] @@ -8178,7 +8149,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8286,12 +8257,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "urlpattern" version = "0.3.0" @@ -8639,7 +8604,6 @@ dependencies = [ "cc", "downcast-rs", "rustix 1.1.4", - "scoped-tls", "smallvec", "wayland-sys", ] @@ -8668,6 +8632,19 @@ dependencies = [ "wayland-scanner", ] +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + [[package]] name = "wayland-scanner" version = "0.31.8" @@ -8685,8 +8662,6 @@ version = "0.31.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" dependencies = [ - "dlib", - "log", "pkg-config", ] @@ -9574,6 +9549,24 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.4", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "write16" version = "1.0.0" @@ -10054,7 +10047,6 @@ dependencies = [ "endi", "enumflags2", "serde", - "url", "winnow 1.0.2", "zvariant_derive", "zvariant_utils", diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index dce0b5c6..c5fc79dc 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -12,13 +12,13 @@ tauri-build = { version = "2.6.3", features = [] } tauri = { version = "2.11.5", features = [] } tauri-plugin-window-state = { version = "2.4.1" } tauri-plugin-shell = "2.3.5" -tauri-plugin-dialog = { version = "2.7.1", default-features = false, features = ["xdg-portal"] } +tauri-plugin-dialog = "2.7.1" tauri-plugin-opener = "2.5.4" tauri-plugin-single-instance = "2" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" keyring-core = "1.0.0" -arboard = "3.6.1" +arboard = { version = "3.6.1", features = ["wayland-data-control"] } tokio = { version = "1.52.3", features = ["rt", "rt-multi-thread", "macros", "process"] } tokio-stream = { version = "0.1.18", features = ["sync"] } futures = "0.3.32" diff --git a/runtime/src/clipboard.rs b/runtime/src/clipboard.rs index de280cfc..19ff138e 100644 --- a/runtime/src/clipboard.rs +++ b/runtime/src/clipboard.rs @@ -1,9 +1,9 @@ use std::fmt::Display; use std::sync::Mutex; use arboard::Clipboard; +use axum::Json; use log::{debug, error, warn}; use once_cell::sync::Lazy; -use axum::Json; use serde::Serialize; use crate::api_token::APIToken; use crate::encryption::{EncryptedText, ENCRYPTION}; @@ -26,17 +26,32 @@ impl ClipboardBackend for Clipboard { } } +#[derive(Debug, PartialEq, Eq)] +enum ClipboardOperationError { + Initialization(E), + Write(E), +} + +impl Display for ClipboardOperationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Initialization(error) => write!(formatter, "Failed to initialize the clipboard backend: {error}"), + Self::Write(error) => write!(formatter, "Failed to write to the clipboard: {error}"), + } + } +} + fn set_text_with_retry( clipboard: &mut Option, text: String, mut create_clipboard: F, -) -> Result<(), B::Error> +) -> Result<(), ClipboardOperationError> where B: ClipboardBackend, F: FnMut() -> Result, { if clipboard.is_none() { - *clipboard = Some(create_clipboard()?); + *clipboard = Some(create_clipboard().map_err(ClipboardOperationError::Initialization)?); } let first_result = clipboard.as_mut().unwrap().set_text(text.clone()); @@ -44,10 +59,10 @@ where warn!(Source = "Clipboard"; "Failed to set text using the current clipboard backend; reinitializing it once: {first_error}."); *clipboard = None; - let mut retry_clipboard = create_clipboard()?; + let mut retry_clipboard = create_clipboard().map_err(ClipboardOperationError::Initialization)?; if let Err(retry_error) = retry_clipboard.set_text(text) { error!(Source = "Clipboard"; "Failed to set text after reinitializing the clipboard backend: {retry_error}."); - return Err(retry_error); + return Err(ClipboardOperationError::Write(retry_error)); } *clipboard = Some(retry_clipboard); @@ -87,7 +102,7 @@ pub async fn set_clipboard(_token: APIToken, encrypted_text: String) -> Json { - error!(Source = "Clipboard"; "Failed to set text to the clipboard: {e}."); + error!(Source = "Clipboard"; "Clipboard operation failed: {e}."); Json(SetClipboardResponse { success: false, issue: e.to_string(), @@ -116,7 +131,7 @@ mod tests { use std::collections::VecDeque; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; - use super::{release_clipboard, set_text_with_retry, ClipboardBackend}; + use super::{ClipboardOperationError, release_clipboard, set_text_with_retry, ClipboardBackend}; struct MockClipboard { id: usize, @@ -186,6 +201,30 @@ mod tests { assert!(clipboard.is_some()); } + #[test] + fn reports_initialization_failures_and_retries_on_the_next_request() { + let mut clipboard: Option = None; + let mut factory = MockFactory::new([false]); + let mut fail_initialization = true; + + let error = set_text_with_retry(&mut clipboard, "first".to_string(), || { + if fail_initialization { + fail_initialization = false; + Err("initialization failed".to_string()) + } else { + factory.create() + } + }).unwrap_err(); + + assert_eq!(error, ClipboardOperationError::Initialization("initialization failed".to_string())); + assert!(clipboard.is_none()); + + set_text_with_retry(&mut clipboard, "second".to_string(), || factory.create()).unwrap(); + + assert_eq!(factory.created, 1); + assert!(clipboard.is_some()); + } + #[test] fn reuses_the_same_instance_for_multiple_writes() { let mut clipboard = None; @@ -210,6 +249,26 @@ mod tests { assert_eq!(*factory.writes.lock().unwrap(), vec![(0, "text".to_string()), (1, "text".to_string())]); } + #[test] + fn reports_reinitialization_failures_and_discards_the_failed_instance() { + let mut clipboard = None; + let mut factory = MockFactory::new([true]); + let mut initialization_attempts = 0; + + let error = set_text_with_retry(&mut clipboard, "text".to_string(), || { + initialization_attempts += 1; + if initialization_attempts == 1 { + factory.create() + } else { + Err("reinitialization failed".to_string()) + } + }).unwrap_err(); + + assert_eq!(error, ClipboardOperationError::Initialization("reinitialization failed".to_string())); + assert_eq!(initialization_attempts, 2); + assert!(clipboard.is_none()); + } + #[test] fn returns_the_retry_error_and_discards_the_failed_instance() { let mut clipboard = None; @@ -217,7 +276,7 @@ mod tests { let error = set_text_with_retry(&mut clipboard, "text".to_string(), || factory.create()).unwrap_err(); - assert_eq!(error, "backend 1 failed"); + assert_eq!(error, ClipboardOperationError::Write("backend 1 failed".to_string())); assert_eq!(factory.created, 2); assert!(clipboard.is_none()); }