Added export and import of plugin archives (#885)
Some checks are pending
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
nilskruthoff 2026-08-06 10:42:20 +02:00 committed by GitHub
parent e7407ce60a
commit 8a9e6aeb87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 2041 additions and 256 deletions

View File

@ -5359,6 +5359,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGRESULTDIALOG::T1173984541"] = "Embe
-- Close
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGRESULTDIALOG::T3448155331"] = "Close"
-- Close
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::INFORMATIONDIALOG::T3448155331"] = "Close"
-- Unfortunately, Pandoc's GPL license isn't compatible with the AI Studios licenses. However, software under the GPL is free to use and free of charge. You'll need to accept the GPL license before we can download and install Pandoc for you automatically (recommended). Alternatively, you might download it yourself using the instructions below or install it otherwise, e.g., by using a package manager of your operating system.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PANDOCDIALOG::T1001483402"] = "Unfortunately, Pandoc's GPL license isn't compatible with the AI Studios licenses. However, software under the GPL is free to use and free of charge. You'll need to accept the GPL license before we can download and install Pandoc for you automatically (recommended). Alternatively, you might download it yourself using the instructions below or install it otherwise, e.g., by using a package manager of your operating system."
@ -5449,6 +5452,57 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PANDOCDIALOG::T504404155"] = "Accept the ter
-- Pandoc is distributed under the GNU General Public License v2 (GPL). By clicking "Accept GPL and archive," you agree to the terms of the GPL license. Software under GPL is free of charge and free to use.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PANDOCDIALOG::T523908375"] = "Pandoc is distributed under the GNU General Public License v2 (GPL). By clicking \"Accept GPL and archive,\" you agree to the terms of the GPL license. Software under GPL is free of charge and free to use."
-- Install plugin
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1525735539"] = "Install plugin"
-- Version
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1573770551"] = "Version"
-- Source
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1642243064"] = "Source"
-- You are about to install a language plugin from a file.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1974491324"] = "You are about to install a language plugin from a file."
-- Authors
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1985367263"] = "Authors"
-- Plugins contain code that runs inside AI Studio. Install plugins only when you trust their source.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2053517490"] = "Plugins contain code that runs inside AI Studio. Install plugins only when you trust their source."
-- You are about to install an assistant plugin from a file.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2063808316"] = "You are about to install an assistant plugin from a file."
-- You are about to install a configuration plugin from a file.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T21052500"] = "You are about to install a configuration plugin from a file."
-- You are about to install a theme plugin from a file.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2163853103"] = "You are about to install a theme plugin from a file."
-- Support contact
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2434966596"] = "Support contact"
-- This replaces the already installed plugin '{0}'. Version {1} gets replaced by version {2}.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3415610475"] = "This replaces the already installed plugin '{0}'. Version {1} gets replaced by version {2}."
-- Unknown
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3424652889"] = "Unknown"
-- Type
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3512062061"] = "Type"
-- Replace plugin
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T4068580334"] = "Replace plugin"
-- The authors marked this plugin as deprecated: {0}
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T497068698"] = "The authors marked this plugin as deprecated: {0}"
-- You are about to install a plugin from a file.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T841685558"] = "You are about to install a plugin from a file."
-- Cancel
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T900713019"] = "Cancel"
-- Tell the AI what you want it to do for you. What are your goals or are you trying to achieve? Like having the AI address you informally.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T1458195391"] = "Tell the AI what you want it to do for you. What are your goals or are you trying to achieve? Like having the AI address you informally."
@ -7552,6 +7606,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1924365263"] = "This library is
-- Encryption secret: is configured
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1931141322"] = "Encryption secret: is configured"
-- The objc2 project provides access to Apple's Objective-C frameworks from Rust. On macOS, we use the libraries objc2, objc2-app-kit, and objc2-foundation to open the native macOS share sheet, e.g., when you share a plugin with others.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1985806792"] = "The objc2 project provides access to Apple's Objective-C frameworks from Rust. On macOS, we use the libraries objc2, objc2-app-kit, and objc2-foundation to open the native macOS share sheet, e.g., when you share a plugin with others."
-- Copies the number of loaded root certificates to the clipboard
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2015329654"] = "Copies the number of loaded root certificates to the clipboard"
@ -7561,6 +7618,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2029659664"] = "Copies the follo
-- Copies the server URL to the clipboard
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2037899437"] = "Copies the server URL to the clipboard"
-- The windows-rs project provides access to Windows APIs from Rust. We use several libraries from this project: windows-registry is used to read the desired configuration in Windows enterprise environments. The windows and windows-collections libraries are used to open the native Windows share dialog, e.g., when you share a plugin with others.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2146481269"] = "The windows-rs project provides access to Windows APIs from Rust. We use several libraries from this project: windows-registry is used to read the desired configuration in Windows enterprise environments. The windows and windows-collections libraries are used to open the native Windows share dialog, e.g., when you share a plugin with others."
-- This library is used to create temporary folders in runtime tests and supporting filesystem operations.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2160280545"] = "This library is used to create temporary folders in runtime tests and supporting filesystem operations."
@ -7777,9 +7837,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3813932670"] = "this version doe
-- On Linux, ashpd provides access to desktop portals, allowing AI Studio to open folders and files for the user.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3871176264"] = "On Linux, ashpd provides access to desktop portals, allowing AI Studio to open folders and files for the user."
-- This library is used to access the Windows registry. We use this for Windows enterprise environments to read the desired configuration.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3874337003"] = "This library is used to access the Windows registry. We use this for Windows enterprise environments to read the desired configuration."
-- Now we have multiple systems, some developed in .NET and others in Rust. The data format JSON is responsible for translating data between both worlds (called data serialization and deserialization). Serde takes on this task in the Rust world. The counterpart in the .NET world is an integral part of .NET and is located in System.Text.Json.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3908558992"] = "Now we have multiple systems, some developed in .NET and others in Rust. The data format JSON is responsible for translating data between both worlds (called data serialization and deserialization). Serde takes on this task in the Rust world. The counterpart in the .NET world is an integral part of .NET and is located in System.Text.Json."
@ -7900,6 +7957,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1229643769"] = "Potentially Dangerou
-- Disable plugin
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1430375822"] = "Disable plugin"
-- Import
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1463683828"] = "Import"
-- Assistant Audit
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1506922856"] = "Assistant Audit"
@ -7921,6 +7981,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2057806005"] = "Enable plugin"
-- No source url available
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2058912565"] = "No source url available"
-- Assistant installed.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2069785341"] = "Assistant installed."
-- Plugins
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2222816203"] = "Plugins"
@ -7933,18 +7996,51 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2738444034"] = "Enabled Plugins"
-- Revise Assistant Plugin
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T284393424"] = "Revise Assistant Plugin"
-- Import not possible
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3051566124"] = "Import not possible"
-- The assistant plugin '{0}' has been successfully saved.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3143506997"] = "The assistant plugin '{0}' has been successfully saved."
-- An error occurred while sharing the plugin.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3184210266"] = "An error occurred while sharing the plugin."
-- Import assistant plugin
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3246593895"] = "Import assistant plugin"
-- Your organization has disabled exporting plugins.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3342440765"] = "Your organization has disabled exporting plugins."
-- Share plugin archive
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3355474457"] = "Share plugin archive"
-- Your organization has disabled sharing plugins.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3379469503"] = "Your organization has disabled sharing plugins."
-- Close
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3448155331"] = "Close"
-- Please drop a plugin archive with the extension {0} or .zip.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3785427568"] = "Please drop a plugin archive with the extension {0} or .zip."
-- Revise assistant plugin with AI
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3801095542"] = "Revise assistant plugin with AI"
-- Actions
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3865031940"] = "Actions"
-- Export plugin archive
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3872669664"] = "Export plugin archive"
-- Install Plugin
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3902690643"] = "Install Plugin"
-- Please drop only one plugin archive at a time.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3974628410"] = "Please drop only one plugin archive at a time."
-- Assistant updated.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T40397082"] = "Assistant updated."
-- The automatic security audit for the assistant plugin '{0}' failed. Please run it manually.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4066679817"] = "The automatic security audit for the assistant plugin '{0}' failed. Please run it manually."
@ -7957,6 +8053,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4239378936"] = "Open website"
-- The assistant plugin '{0}' was audited with the level '{1}', which is below the required minimum level \"{2}\". Your current settings allow activation anyway, but this may be potentially dangerous. Do you really want to enable this plugin?
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T448946658"] = "The assistant plugin '{0}' was audited with the level '{1}', which is below the required minimum level \\\"{2}\\\". Your current settings allow activation anyway, but this may be potentially dangerous. Do you really want to enable this plugin?"
-- The plugin archive was exported to '{0}'.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T659549952"] = "The plugin archive was exported to '{0}'."
-- An error occurred while exporting the plugin.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T759681732"] = "An error occurred while exporting the plugin."
-- The plugin could not be imported: {0}
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T837269472"] = "The plugin could not be imported: {0}"
-- Settings
UI_TEXT_CONTENT["AISTUDIO::PAGES::SETTINGS::T1258653480"] = "Settings"
@ -9370,6 +9475,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source like p
-- Document
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T4165204724"] = "Document"
-- Plugin archive
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T927001356"] = "Plugin archive"
-- The Assistant Builder context could not be loaded.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T108292972"] = "The Assistant Builder context could not be loaded."
@ -9475,6 +9583,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T4
-- Internal assistant plugins cannot be deleted.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1084244321"] = "Internal assistant plugins cannot be deleted."
-- This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1138181282"] = "This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins."
-- The assistant plugin directory is outside the local assistant plugin directory.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1211881977"] = "The assistant plugin directory is outside the local assistant plugin directory."
@ -9484,12 +9595,18 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1288
-- The assistant cannot be deleted while background work is still running.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1318944584"] = "The assistant cannot be deleted while background work is still running."
-- Currently, only assistant plugins can be imported.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T139615196"] = "Currently, only assistant plugins can be imported."
-- Please select a plugin archive with the extension .mwplugin or .zip.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1809137998"] = "Please select a plugin archive with the extension .mwplugin or .zip."
-- The selected plugin archive does not exist.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1821013825"] = "The selected plugin archive does not exist."
-- No Lua plugin code was generated.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1839013358"] = "No Lua plugin code was generated."
-- The edited assistant plugin uses the ID of an internal AI Studio plugin.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2061233834"] = "The edited assistant plugin uses the ID of an internal AI Studio plugin."
-- The assistant plugin directory does not exist.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2148384567"] = "The assistant plugin directory does not exist."
@ -9499,20 +9616,35 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2223
-- Unexpected error: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2350673880"] = "Unexpected error: {0}"
-- The generated assistant plugin uses the ID of another installed plugin.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2441747251"] = "The generated assistant plugin uses the ID of another installed plugin."
-- Config server managed assistant plugins cannot be replaced.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2594571117"] = "Config server managed assistant plugins cannot be replaced."
-- The assistant plugin has no local directory.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2682912892"] = "The assistant plugin has no local directory."
-- The AI Studio data directory is not initialized yet.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2712481762"] = "The AI Studio data directory is not initialized yet."
-- The imported assistant plugin is invalid. Issue: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2777304537"] = "The imported assistant plugin is invalid. Issue: {0}"
-- Only assistant plugins can be deleted.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2864597027"] = "Only assistant plugins can be deleted."
-- The generated plugin is not an assistant plugin. Issue: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2955055168"] = "The generated plugin is not an assistant plugin. Issue: {0}"
-- The generated assistant plugin uses the ID of an internal AI Studio plugin.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3162363526"] = "The generated assistant plugin uses the ID of an internal AI Studio plugin."
-- The imported assistant plugin uses the ID of another installed plugin.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2971411166"] = "The imported assistant plugin uses the ID of another installed plugin."
-- Your organization has disabled importing plugins.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3212529834"] = "Your organization has disabled importing plugins."
-- The plugin archive must contain exactly one plugin.lua file.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3355918609"] = "The plugin archive must contain exactly one plugin.lua file."
-- Config Server managed assistant plugins cannot be deleted.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3751820312"] = "Config Server managed assistant plugins cannot be deleted."
@ -9532,6 +9664,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T4062
-- The edited assistant plugin is invalid. Issue: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T554567780"] = "The edited assistant plugin is invalid. Issue: {0}"
-- The edited assistant plugin uses the ID of another installed plugin.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T584770023"] = "The edited assistant plugin uses the ID of another installed plugin."
-- The edited assistant plugin must keep the same plugin ID.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T693124809"] = "The edited assistant plugin must keep the same plugin ID."
@ -9592,6 +9727,36 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T18544701
-- Pandoc may be required for importing files.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T2596465560"] = "Pandoc may be required for importing files."
-- Internal plugins cannot be shared.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T1668534561"] = "Internal plugins cannot be shared."
-- Config Server managed plugins cannot be shared.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2077776546"] = "Config Server managed plugins cannot be shared."
-- The native share dialog could not be opened.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2101116016"] = "The native share dialog could not be opened."
-- The plugin directory does not exist.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2221093487"] = "The plugin directory does not exist."
-- Unexpected error: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2350673880"] = "Unexpected error: {0}"
-- The plugin has no local directory.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T3284289028"] = "The plugin has no local directory."
-- Your organization has disabled sharing plugins.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T3379469503"] = "Your organization has disabled sharing plugins."
-- The plugin directory is invalid: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T3774594541"] = "The plugin directory is invalid: {0}"
-- Export plugin archive
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T3872669664"] = "Export plugin archive"
-- The plugin directory does not contain a plugin.lua file.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T409411078"] = "The plugin directory does not contain a plugin.lua file."
-- Failed to store the secret data due to an API issue.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1110203516"] = "Failed to store the secret data due to an API issue."

View File

@ -222,6 +222,11 @@ public partial class AttachDocuments : MSGComponentBase
protected override void DisposeResources()
{
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
// Release the drop area. Without this, drop areas below this one would count this component
// forever and would stop catching dropped files:
_ = this.MessageBus.SendMessage(this, Event.UNREGISTER_FILE_DROP_AREA, this.Layer);
base.DisposeResources();
}

View File

@ -56,7 +56,13 @@ public abstract partial class ConfigurationBase : MSGComponentBase
protected bool IsDisabled => this.Disabled() || this.IsLocked();
private string Classes => $"{this.GetClassForBase} {JUSTIFIED_HELP_CLASS} {MARGIN_CLASS}";
private string Classes => $"{this.GetClassForBase} {JUSTIFIED_HELP_CLASS} {this.MarginClass}";
/// <summary>
/// The bottom margin of the option. Options inside settings panels need the default
/// spacing; standalone usages like toolbar buttons can remove it.
/// </summary>
protected virtual string MarginClass => MARGIN_CLASS;
private protected virtual RenderFragment? Body => null;

View File

@ -1,5 +1,8 @@
@inherits ConfigurationBaseCore
<MudButton Variant="Variant.Filled" Color="@Color.Primary" StartIcon="@this.Icon" Disabled="@this.IsDisabled" OnClick="@(async () => await this.ClickAsync())">
@* The tooltip is suppressed while the button is locked, so that the lock icon's tooltip is the only one shown: *@
<MudTooltip Text="@this.Tooltip" Disabled="@(this.IsLocked() || string.IsNullOrWhiteSpace(this.Tooltip))" RootStyle="display:inline-flex;">
<MudButton Variant="@this.ButtonVariant" Color="@this.ButtonColor" StartIcon="@this.Icon" Disabled="@this.IsDisabled" OnClick="@(async () => await this.ClickAsync())">
@this.Text
</MudButton>
</MudButton>
</MudTooltip>

View File

@ -19,6 +19,32 @@ public partial class LockableButton : ConfigurationBaseCore
[Parameter]
public string Class { get; set; } = string.Empty;
/// <summary>
/// An optional tooltip for the button. It is not shown while the button is locked,
/// because the lock icon explains the situation in that case.
/// </summary>
[Parameter]
public string Tooltip { get; set; } = string.Empty;
/// <summary>
/// The visual variant of the button.
/// </summary>
[Parameter]
public Variant ButtonVariant { get; set; } = Variant.Filled;
/// <summary>
/// The color of the button.
/// </summary>
[Parameter]
public Color ButtonColor { get; set; } = Color.Primary;
/// <summary>
/// Should the default bottom margin be removed? Useful when the button is placed in a
/// toolbar instead of a settings panel.
/// </summary>
[Parameter]
public bool NoMargin { get; set; }
#region Overrides of ConfigurationBase
/// <inheritdoc />
@ -26,6 +52,8 @@ public partial class LockableButton : ConfigurationBaseCore
protected override string GetClassForBase => this.Class;
protected override string MarginClass => this.NoMargin ? string.Empty : base.MarginClass;
#endregion
private async Task ClickAsync()

View File

@ -174,10 +174,16 @@ public partial class ReadFileContent : MSGComponentBase
this.MediaTranscriptionService.AcknowledgeDelivery(delivery);
}
/// <summary>Unsubscribes from the singleton media service.</summary>
/// <summary>Unsubscribes from the singleton media service and releases the drop area.</summary>
protected override void DisposeResources()
{
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
// Release the drop area. Without this, drop areas below this one would count this component
// forever and would stop catching dropped files:
if (this.EnableDragDrop)
_ = this.MessageBus.SendMessage(this, Event.UNREGISTER_FILE_DROP_AREA, this.Layer);
base.DisposeResources();
}

View File

@ -0,0 +1,16 @@
@inherits MSGComponentBase
<MudDialog>
<DialogContent>
<MudStack Row="true" AlignItems="AlignItems.Start" Spacing="3">
<MudIcon Icon="@this.Icon" Color="@this.IconColor" Size="Size.Large"/>
<MudJustifiedText Typo="Typo.body1">
@this.Message
</MudJustifiedText>
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="@this.Close" Variant="Variant.Filled" Color="Color.Primary">
@T("Close")
</MudButton>
</DialogActions>
</MudDialog>

View File

@ -0,0 +1,35 @@
using AIStudio.Components;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Dialogs;
/// <summary>
/// A dialog that informs the user about something without asking for a decision. Use it when a
/// message must not be missed, e.g., when an action was refused.
/// </summary>
public partial class InformationDialog : MSGComponentBase
{
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
/// <summary>
/// The message shown to the user.
/// </summary>
[Parameter]
public string Message { get; set; } = string.Empty;
/// <summary>
/// The icon shown next to the message.
/// </summary>
[Parameter]
public string Icon { get; set; } = Icons.Material.Filled.Info;
/// <summary>
/// The color of the icon.
/// </summary>
[Parameter]
public Color IconColor { get; set; } = Color.Info;
private void Close() => this.MudDialog.Close(DialogResult.Ok(true));
}

View File

@ -0,0 +1,60 @@
@inherits MSGComponentBase
<MudDialog>
<DialogContent>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@this.IntroductionText @T("Plugins contain code that runs inside AI Studio. Install plugins only when you trust their source.")
</MudJustifiedText>
<MudPaper Class="pa-3 mb-3 border-dashed border rounded-lg">
<MudText Typo="Typo.h6">
@this.Preview.Plugin.Name
</MudText>
<MudText Typo="Typo.body2" Class="mb-2">
@this.Preview.Plugin.Description
</MudText>
<MudText Typo="Typo.body2">
@T("Type"): <strong>@this.TypeLabel</strong>
</MudText>
<MudText Typo="Typo.body2">
@T("Version"): <strong>@this.Preview.Plugin.Version</strong>
</MudText>
<MudText Typo="Typo.body2">
@T("Authors"): <strong>@this.AuthorsLabel</strong>
</MudText>
@if (!string.IsNullOrWhiteSpace(this.Preview.Plugin.SourceURL))
{
<MudText Typo="Typo.body2">
@T("Source"): <strong>@this.Preview.Plugin.SourceURL</strong>
</MudText>
}
@if (!string.IsNullOrWhiteSpace(this.Preview.Plugin.SupportContact))
{
<MudText Typo="Typo.body2">
@T("Support contact"): <strong>@this.Preview.Plugin.SupportContact</strong>
</MudText>
}
</MudPaper>
@if (!string.IsNullOrWhiteSpace(this.Preview.Plugin.DeprecationMessage))
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Class="mb-3">
@string.Format(T("The authors marked this plugin as deprecated: {0}"), this.Preview.Plugin.DeprecationMessage)
</MudAlert>
}
@if (this.Preview.ExistingPlugin is { } existingPlugin)
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Class="mb-3">
@string.Format(T("This replaces the already installed plugin '{0}'. Version {1} gets replaced by version {2}."), existingPlugin.Name, existingPlugin.Version, this.Preview.Plugin.Version)
</MudAlert>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="@this.Cancel" Variant="Variant.Filled">
@T("Cancel")
</MudButton>
<MudButton OnClick="@this.Confirm" Variant="Variant.Filled" Color="Color.Warning">
@(this.Preview.ReplacesExisting ? T("Replace plugin") : T("Install plugin"))
</MudButton>
</DialogActions>
</MudDialog>

View File

@ -0,0 +1,47 @@
using AIStudio.Components;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Dialogs;
/// <summary>
/// Asks the user whether a plugin archive may be installed. It shows the metadata the archive
/// declares about itself, so the user can judge the plugin before its code runs.
/// </summary>
public partial class PluginImportDialog : MSGComponentBase
{
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
/// <summary>
/// The metadata of the plugin archive about to be installed.
/// </summary>
[Parameter]
public PluginImportPreview Preview { get; set; } = null!;
/// <summary>
/// Names the kind of plugin the user is about to install. Each plugin type gets its own
/// sentence instead of a placeholder because articles and word order differ between languages.
/// </summary>
private string IntroductionText => this.Preview.Plugin.Type switch
{
PluginType.LANGUAGE => this.T("You are about to install a language plugin from a file."),
PluginType.ASSISTANT => this.T("You are about to install an assistant plugin from a file."),
PluginType.CONFIGURATION => this.T("You are about to install a configuration plugin from a file."),
PluginType.THEME => this.T("You are about to install a theme plugin from a file."),
_ => this.T("You are about to install a plugin from a file."),
};
private string TypeLabel => this.Preview.Plugin.Type.GetName();
private string AuthorsLabel => this.Preview.Plugin.Authors.Length > 0
? string.Join(", ", this.Preview.Plugin.Authors)
: this.T("Unknown");
private void Cancel() => this.MudDialog.Cancel();
private void Confirm() => this.MudDialog.Close(DialogResult.Ok(true));
}

View File

@ -312,7 +312,8 @@
<ThirdPartyComponent Name="base64" Developer="Marshall Pierce, Alice Maz & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/marshallpierce/rust-base64/blob/master/LICENSE-MIT" RepositoryUrl="https://github.com/marshallpierce/rust-base64" UseCase="@T("For some data transfers, we need to encode the data in base64. This Rust library is great for this purpose.")"/>
<ThirdPartyComponent Name="Rust Crypto" Developer="Artyom Pavlov, Tony Arcieri, Brian Warner, Arthur Gautier, Vlad Filippov, Friedel Ziegelmayer, Nicolas Stalder & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/RustCrypto/traits/blob/master/cipher/LICENSE-MIT" RepositoryUrl="https://github.com/RustCrypto" UseCase="@T("When transferring sensitive data between Rust runtime and .NET app, we encrypt the data. We use some libraries from the Rust Crypto project for this purpose: cipher, aes, cbc, pbkdf2, hmac, and sha2. We are thankful for the great work of the Rust Crypto project.")"/>
<ThirdPartyComponent Name="rcgen" Developer="RustTLS developers, est31 & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/rustls/rcgen/blob/main/LICENSE" RepositoryUrl="https://github.com/rustls/rcgen" UseCase="@T("For the secure communication between the user interface and the runtime, we need to create certificates. This Rust library is great for this purpose.")"/>
<ThirdPartyComponent Name="windows-registry" Developer="Microsoft, Kenny Kerr, Ryan Levick, Rafael Rivera, sivadeilra, Marijn Suijten & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/microsoft/windows-rs/blob/master/license-mit" RepositoryUrl="https://github.com/microsoft/windows-rs" UseCase="@T("This library is used to access the Windows registry. We use this for Windows enterprise environments to read the desired configuration.")"/>
<ThirdPartyComponent Name="windows-rs" Developer="Microsoft, Kenny Kerr, Ryan Levick, Rafael Rivera, sivadeilra, Marijn Suijten & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/microsoft/windows-rs/blob/master/license-mit" RepositoryUrl="https://github.com/microsoft/windows-rs" UseCase="@T("The windows-rs project provides access to Windows APIs from Rust. We use several libraries from this project: windows-registry is used to read the desired configuration in Windows enterprise environments. The windows and windows-collections libraries are used to open the native Windows share dialog, e.g., when you share a plugin with others.")"/>
<ThirdPartyComponent Name="objc2" Developer="Steven Sheldon, Mads Marquart, silvanshade, Dzmitry Malyshau, Felix Nemo Kaaman, adamnemecek, Samuel Sleight, Paul Mabileau & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/madsmtm/objc2/blob/main/LICENSE-MIT.txt" RepositoryUrl="https://github.com/madsmtm/objc2" UseCase="@T("The objc2 project provides access to Apple's Objective-C frameworks from Rust. On macOS, we use the libraries objc2, objc2-app-kit, and objc2-foundation to open the native macOS share sheet, e.g., when you share a plugin with others.")"/>
<ThirdPartyComponent Name="file-format" Developer="Mickaël Malécot & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/mmalecot/file-format/blob/main/LICENSE-MIT" RepositoryUrl="https://github.com/mmalecot/file-format" UseCase="@T("This library identifies files by their content. It is used for document streaming and as the first safety and media classification step before local audio processing.")"/>
<ThirdPartyComponent Name="Symphonia" Developer="Philip Deljanov & Open Source Community" LicenseName="MPL-2.0" LicenseUrl="https://github.com/pdeljanov/Symphonia/blob/v0.6.0/LICENSE" RepositoryUrl="https://github.com/pdeljanov/Symphonia" UseCase="@T("Symphonia is used for media container demuxing and audio decoding. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio.")"/>
<ThirdPartyComponent Name="Ropus" Developer="0x4D44, Xiph.Org, Skype Limited, Octasic, Jean-Marc Valin, Timothy B. Terriberry, CSIRO, Gregory Maxwell, Mark Borgerding, Erik de Castro Lopo, Mozilla, Amazon & Open Source Community" LicenseName="BSD-3-Clause" LicenseUrl="https://github.com/0x4D44/ropus/blob/main/LICENSE" RepositoryUrl="https://github.com/0x4d44/ropus" UseCase="@T("Ropus provides the Opus encoder and decoder used by the media pipeline.")"/>

View File

@ -5,13 +5,26 @@
@attribute [Route(Routes.PLUGINS)]
<div class="inner-scrolling-context">
<MudText Typo="Typo.h3" Class="mb-2">
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h3">
@T("Plugins")
</MudText>
<MudSpacer />
<LockableButton Text="@T("Import")"
Tooltip="@T("Import assistant plugin")"
Icon="@IMPORT_ICON"
ButtonVariant="Variant.Outlined"
ButtonColor="Color.Default"
NoMargin="@true"
Class="flex-none"
Disabled="@(() => this.isImportingAssistantPlugin)"
IsLocked="@(() => !this.AllowPluginImport)"
OnClickAsync="@this.ImportAssistantPluginAsync"/>
</MudStack>
<InnerScrolling>
<MudTable Items="@PluginFactory.AvailablePlugins" Hover="@true" GroupBy="@this.groupConfig" Class="border-dashed border rounded-lg">
<MudTable Items="@PluginFactory.AvailablePlugins" Hover="@true" GroupBy="@this.groupConfig" Class="@this.PluginTableClass">
<ColGroup>
<col style="width: 2em;" />
<col style="width: 2.1em; "/>
@ -65,12 +78,13 @@
</MudStack>
</MudTd>
<MudTd>
<MudStack Row="true" Spacing="0" AlignItems="AlignItems.Center">
<MudStack Row="true" Spacing="0" AlignItems="AlignItems.Center" Justify="Justify.FlexEnd">
@if (context.Type is PluginType.ASSISTANT)
{
var assistantPlugin = PluginFactory.RunningPlugins.OfType<PluginAssistants>().FirstOrDefault(x => x.Id == context.Id);
<AssistantPluginSecurityCard Plugin="@assistantPlugin" Compact="@true"/>
}
@if (context is { IsInternal: false, Type: not PluginType.CONFIGURATION })
{
var isEnabled = this.SettingsManager.IsPluginEnabled(context);
@ -80,7 +94,7 @@
</MudTooltip>
}
<MudButtonGroup Class="ms-3">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0" Class="ms-3" Style="gap: 4px;">
@if (context is { IsInternal: false } && !string.IsNullOrWhiteSpace(context.SourceURL))
{
var sourceUrl = context.SourceURL;
@ -108,6 +122,13 @@
</MudTooltip>
}
@if (context is IAvailablePlugin shareablePlugin && CanSharePlugin(shareablePlugin))
{
<MudTooltip Text="@(this.AllowPluginSharing ? this.SharePluginTooltip : this.SharePluginLockText)">
<MudIconButton Icon="@SharePluginIcon" Size="Size.Medium" OnClick="@(() => this.SharePluginAsync(shareablePlugin))" Disabled="@(this.isSharingPlugin || !this.AllowPluginSharing)"/>
</MudTooltip>
}
@if (context is IAvailablePlugin revisionPlugin && CanReviseAssistantPlugin(revisionPlugin))
{
<MudTooltip Text="@T("Revise assistant plugin with AI")">
@ -119,7 +140,7 @@
{
<AssistantPluginDeleteAction Plugin="@availablePlugin" />
}
</MudButtonGroup>
</MudStack>
</MudStack>
</MudTd>
</RowTemplate>

View File

@ -4,7 +4,8 @@ using AIStudio.Dialogs;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem.Assistants;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
using DialogOptions = AIStudio.Dialogs.DialogOptions;
@ -16,6 +17,7 @@ public partial class Plugins : MSGComponentBase
private const string GROUP_DISABLED = "Disabled";
private const string GROUP_INTERNAL = "Internal";
private bool isAutoAuditing;
private bool isImportingAssistantPlugin;
private DataAssistantPluginAudit AssistantPluginAuditSettings => this.SettingsManager.ConfigurationData.AssistantPluginAudit;
@ -27,13 +29,42 @@ public partial class Plugins : MSGComponentBase
[Inject]
private AssistantPluginAuditService AssistantPluginAuditService { get; init; } = null!;
[Inject]
private PluginShareService PluginShareService { get; init; } = null!;
[Inject]
private RustService RustService { get; init; } = null!;
[Inject]
private AssistantPluginInstallService AssistantPluginInstallService { get; init; } = null!;
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(Plugins));
private bool isSharingPlugin;
/// <summary>
/// Number of active drop areas above this page. While there is any, another component owns the
/// dropped files and this page must not catch them.
/// </summary>
private uint numDropAreasAboveThis;
private bool isDraggingOverPage;
private const string IMPORT_ICON =
@"<svg class=""mud-icon-root mud-svg-icon mud-dark-text mud-icon-size-medium"" focusable=""false"" viewBox=""0 0 24 24"" aria-hidden=""true"" role=""img"">
<path d=""M0 0h24v24H0V0z"" fill=""none""></path>
<path d=""M16 5l-1.42 1.42-1.59-1.59V16h-1.98V4.83L9.42 6.42 8 5l4-4 4 4z"" transform=""rotate(180 12 10)""></path>
<path d=""M20 10v11c0 1.1-.9 2-2 2H6c-1.11 0-2-.9-2-2V10c0-1.11.89-2 2-2h3v2H6v11h12V10h-3V8h3c1.1 0 2 .89 2 2z""></path>
</svg>";
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
this.ApplyFilters([], [ Event.PLUGINS_RELOADED ]);
this.ApplyFilters([], [ Event.PLUGINS_RELOADED, Event.CONFIGURATION_CHANGED, Event.TAURI_EVENT_RECEIVED, Event.REGISTER_FILE_DROP_AREA, Event.UNREGISTER_FILE_DROP_AREA ]);
// Register the whole page as a drop area, so users can drop a plugin archive anywhere on it:
await this.MessageBus.SendMessage(this, Event.REGISTER_FILE_DROP_AREA, DropLayers.PAGES);
this.groupConfig = new TableGroupDefinition<IPluginMetadata>
{
@ -59,6 +90,13 @@ public partial class Plugins : MSGComponentBase
await this.TryAutoAuditAssistantsAsync();
}
protected override void DisposeResources()
{
// Release the drop area again, so lower layers can catch dropped files:
_ = this.MessageBus.SendMessage(this, Event.UNREGISTER_FILE_DROP_AREA, DropLayers.PAGES);
base.DisposeResources();
}
#endregion
private async Task PluginActivationStateChanged(IPluginMetadata pluginMeta)
@ -184,16 +222,54 @@ public partial class Plugins : MSGComponentBase
: this.T("Enable plugin");
}
//
// These methods decide whether an action exists for a plugin at all. They must not depend on
// transient state like an ongoing share: they gate the markup, so a transient value would make
// the action buttons disappear and reappear. Transient state belongs into the buttons' Disabled.
//
private static bool CanEditAssistantPlugin(IAvailablePlugin plugin) => plugin is { IsInternal: false, Type: PluginType.ASSISTANT } && !string.IsNullOrWhiteSpace(plugin.LocalPath);
private static bool CanReviseAssistantPlugin(IAvailablePlugin plugin)
{
var assistantPlugin = PluginFactory.RunningPlugins.OfType<PluginAssistants>().FirstOrDefault(x => x.Id == plugin.Id);
return plugin is { IsInternal: false, IsManagedByConfigServer: false, Type: PluginType.ASSISTANT } &&
!string.IsNullOrWhiteSpace(plugin.LocalPath) &&
assistantPlugin?.IsManagedByConfigServer is false;
return plugin is { IsInternal: false, IsManagedByConfigServer: false, Type: PluginType.ASSISTANT } && !string.IsNullOrWhiteSpace(plugin.LocalPath) && assistantPlugin?.IsManagedByConfigServer is false;
}
/// <summary>
/// Sharing is limited to assistant plugins because the import accepts only those. Otherwise,
/// users would create archives nobody can install. Widen this once the import supports more
/// plugin types.
/// </summary>
private static bool CanSharePlugin(IAvailablePlugin plugin) => plugin is { IsInternal: false, IsManagedByConfigServer: false, Type: PluginType.ASSISTANT } && !string.IsNullOrWhiteSpace(plugin.LocalPath);
/// <summary>
/// Highlights the plugin table while the user drags a file over the page, so it is visible
/// where the file would land.
/// </summary>
private string PluginTableClass => this.isDraggingOverPage
? "border-dashed border rounded-lg mud-border-primary border-4"
: "border-dashed border rounded-lg";
/// <summary>
/// Organizations may disable importing plugin archives by using a configuration plugin.
/// </summary>
private bool AllowPluginImport => this.SettingsManager.ConfigurationData.App.AllowUserToImportPlugins;
/// <summary>
/// Organizations may disable sharing and exporting plugins by using a configuration plugin.
/// </summary>
private bool AllowPluginSharing => this.SettingsManager.ConfigurationData.App.AllowUserToSharePlugins;
/// <summary>
/// Linux has no native share sheet, hence the plugin archive is exported to a location of the
/// user's choice there. The action must be labeled accordingly.
/// </summary>
private static string SharePluginIcon => OperatingSystem.IsLinux() ? Icons.Material.Filled.FileDownload : Icons.Material.Filled.IosShare;
private string SharePluginTooltip => OperatingSystem.IsLinux() ? this.T("Export plugin archive") : this.T("Share plugin archive");
private string SharePluginLockText => OperatingSystem.IsLinux() ? this.T("Your organization has disabled exporting plugins.") : this.T("Your organization has disabled sharing plugins.");
private async Task OpenAssistantPluginEditorDialogAsync(IAvailablePlugin plugin)
{
var parameters = new DialogParameters<AssistantPluginEditorDialog>
@ -209,7 +285,9 @@ public partial class Plugins : MSGComponentBase
await this.MessageBus.SendSuccess(new(Icons.Material.Filled.Save, string.Format(this.T("The assistant plugin '{0}' has been successfully saved."), result.PluginName)));
LOG.LogInformation($"The assistant plugin '{result.PluginName}' ({result.PluginId}) has been successfully updated.");
await this.MessageBus.SendMessage<bool>(this, Event.PLUGINS_RELOADED);
// Saving the plugin ran LoadAll, which already sent PLUGINS_RELOADED. Editing the plugin
// code changes no settings, so there is nothing else to announce:
await this.InvokeAsync(this.StateHasChanged);
}
@ -228,11 +306,139 @@ public partial class Plugins : MSGComponentBase
await this.MessageBus.SendSuccess(new(Icons.Material.Filled.AutoFixHigh, string.Format(this.T("The assistant plugin '{0}' has been successfully revised."), result.PluginName)));
LOG.LogInformation($"The assistant plugin '{result.PluginName}' ({result.PluginId}) has been successfully revised.");
await this.MessageBus.SendMessage<bool>(this, Event.PLUGINS_RELOADED);
// Saving the revision ran LoadAll, which already sent PLUGINS_RELOADED. We still announce the
// configuration change: with automatic audits enabled, the dialog stored an audit result:
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
await this.InvokeAsync(this.StateHasChanged);
}
private async Task SharePluginAsync(IAvailablePlugin plugin)
{
if (this.isSharingPlugin)
return;
this.isSharingPlugin = true;
// invoke a state change right away to guard action buttons
await this.InvokeAsync(this.StateHasChanged);
try
{
var shareResult = await this.PluginShareService.ShareAsync(plugin, CancellationToken.None);
if (shareResult.Cancelled)
return;
if (!shareResult.Success)
{
LOG.LogError($"Sharing the plugin '{shareResult.PluginName}' from archive '{shareResult.ArchivePath}' failed with Issue: '{shareResult.Issue}'.");
await this.MessageBus.SendError(new(Icons.Material.Filled.ReportProblem, OperatingSystem.IsLinux() ? T("An error occurred while exporting the plugin.") : T("An error occurred while sharing the plugin.")));
return;
}
// On Linux, the user chose the target location, so we confirm where the archive was stored:
if (OperatingSystem.IsLinux())
await this.MessageBus.SendSuccess(new(Icons.Material.Filled.FileDownload, string.Format(T("The plugin archive was exported to '{0}'."), shareResult.ArchivePath)));
}
finally
{
this.isSharingPlugin = false;
await this.InvokeAsync(this.StateHasChanged);
}
}
private async Task ImportAssistantPluginAsync()
{
if (this.isImportingAssistantPlugin)
return;
if (!this.AllowPluginImport)
return;
var selection = await this.RustService.SelectFile(this.T("Import assistant plugin"), [FileTypes.PLUGIN_ARCHIVE]);
if (selection.UserCancelled)
return;
await this.ImportPluginArchiveAsync(selection.SelectedFilePath);
}
/// <summary>
/// Installs a plugin archive, no matter whether the user picked it through the import button or
/// dropped it onto the page.
/// </summary>
/// <param name="archivePath">The local plugin archive to install.</param>
private async Task ImportPluginArchiveAsync(string archivePath)
{
if (this.isImportingAssistantPlugin)
return;
if (!this.AllowPluginImport)
return;
this.isImportingAssistantPlugin = true;
await this.InvokeAsync(this.StateHasChanged);
try
{
var result = await this.AssistantPluginInstallService.InstallArchiveAsync(archivePath, this.ConfirmPluginImportAsync, CancellationToken.None);
if (result.Cancelled)
return;
if (!result.Success)
{
LOG.LogError("Failed to import assistant plugin archive '{ArchivePath}': {Issue}", archivePath, result.Issue);
// The user actively started this import, so we report the reason in a dialog
// instead of a snackbar. Refused imports must not be missed:
await this.ShowImportRefusedDialogAsync(result.Issue);
return;
}
var message = result.ReplacedExisting
? this.T("Assistant updated.")
: this.T("Assistant installed.");
// We do not announce the reload ourselves: a successful installation ran LoadAll, which
// already sent PLUGINS_RELOADED. The import changes no settings either, so there is
// nothing to report as a configuration change:
await this.MessageBus.SendSuccess(new(Icons.Material.Filled.Extension, message));
}
finally
{
this.isImportingAssistantPlugin = false;
await this.InvokeAsync(this.StateHasChanged);
}
}
/// <summary>
/// Shows the metadata of a validated plugin archive and asks whether it may be installed.
/// </summary>
/// <param name="preview">The metadata the archive declares about itself.</param>
/// <returns>True when the user confirmed the installation.</returns>
private async Task<bool> ConfirmPluginImportAsync(PluginImportPreview preview)
{
var dialogParameters = new DialogParameters<PluginImportDialog>
{
{ x => x.Preview, preview },
};
var dialogReference = await this.DialogService.ShowAsync<PluginImportDialog>(this.T("Install Plugin"), dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
return dialogResult is { Canceled: false };
}
private async Task ShowImportRefusedDialogAsync(string issue)
{
var dialogParameters = new DialogParameters<InformationDialog>
{
{ x => x.Message, string.Format(this.T("The plugin could not be imported: {0}"), issue) },
{ x => x.Icon, Icons.Material.Filled.ReportProblem },
{ x => x.IconColor, Color.Error },
};
var dialogReference = await this.DialogService.ShowAsync<InformationDialog>(this.T("Import not possible"), dialogParameters, DialogOptions.FULLSCREEN);
await dialogReference.Result;
}
private static bool IsSendingMail(string sourceUrl) => sourceUrl.TrimStart().StartsWith("mailto:", StringComparison.OrdinalIgnoreCase);
private PluginAssistants? TryGetAssistantPlugin(Guid pluginId) => PluginFactory.RunningPlugins.OfType<PluginAssistants>().FirstOrDefault(x => x.Id == pluginId);
@ -302,8 +508,71 @@ public partial class Plugins : MSGComponentBase
case Event.CONFIGURATION_CHANGED:
await this.InvokeAsync(this.StateHasChanged);
break;
case Event.REGISTER_FILE_DROP_AREA when sendingComponent != this:
if (data is int registeredLayer && registeredLayer > DropLayers.PAGES)
this.numDropAreasAboveThis++;
break;
case Event.UNREGISTER_FILE_DROP_AREA when sendingComponent != this:
if (data is int unregisteredLayer && unregisteredLayer > DropLayers.PAGES && this.numDropAreasAboveThis > 0)
this.numDropAreasAboveThis--;
break;
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_HOVERED }:
if (!this.CanCatchDroppedFile())
return;
this.isDraggingOverPage = true;
await this.InvokeAsync(this.StateHasChanged);
break;
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_CANCELED }:
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.WINDOW_NOT_FOCUSED }:
this.isDraggingOverPage = false;
await this.InvokeAsync(this.StateHasChanged);
break;
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.FILE_DROP_DROPPED, Payload: var droppedPaths }:
this.isDraggingOverPage = false;
await this.InvokeAsync(this.StateHasChanged);
if (!this.CanCatchDroppedFile())
return;
await this.ImportDroppedPluginArchiveAsync(droppedPaths);
break;
}
}
#endregion
/// <summary>
/// Decides whether this page may process dropped files: only when no drop area above it is
/// active and when the organization allows importing plugins at all.
/// </summary>
private bool CanCatchDroppedFile() => this.numDropAreasAboveThis is 0 && this.AllowPluginImport && !this.isImportingAssistantPlugin;
/// <summary>
/// Imports a plugin archive the user dropped onto the page. Anything that is not exactly one
/// plugin archive is reported instead of guessing what the user meant.
/// </summary>
/// <param name="droppedPaths">The paths of the dropped files.</param>
private async Task ImportDroppedPluginArchiveAsync(IReadOnlyList<string> droppedPaths)
{
var archivePaths = droppedPaths.Where(path => FileTypes.IsAllowedPath(path, FileTypes.PLUGIN_ARCHIVE)).ToList();
switch (archivePaths.Count)
{
case 0:
await this.MessageBus.SendWarning(new(Icons.Material.Filled.ReportProblem, string.Format(this.T("Please drop a plugin archive with the extension {0} or .zip."), PluginArchive.PLUGIN_FILE_EXTENSION)));
return;
case > 1:
await this.MessageBus.SendWarning(new(Icons.Material.Filled.ReportProblem, this.T("Please drop only one plugin archive at a time.")));
return;
}
await this.ImportPluginArchiveAsync(archivePaths[0]);
}
}

View File

@ -235,6 +235,15 @@ CONFIG["SETTINGS"] = {}
-- Configure the user permission to add providers:
-- CONFIG["SETTINGS"]["DataApp.AllowUserToAddProvider"] = false
-- Configure the user permission to import plugin archives from disk.
-- When set to false, the import button on the plugins page stays visible but is disabled.
-- CONFIG["SETTINGS"]["DataApp.AllowUserToImportPlugins"] = false
-- Configure the user permission to share or export plugins as archives.
-- When set to false, the share button on the plugins page stays visible but is disabled.
-- On Linux, this button exports the plugin archive instead of using a native share sheet.
-- CONFIG["SETTINGS"]["DataApp.AllowUserToSharePlugins"] = false
-- Configure whether administration settings are visible in the UI:
-- CONFIG["SETTINGS"]["DataApp.ShowAdminSettings"] = true

View File

@ -5361,6 +5361,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGRESULTDIALOG::T1173984541"] = "Einb
-- Close
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGRESULTDIALOG::T3448155331"] = "Schließen"
-- Close
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::INFORMATIONDIALOG::T3448155331"] = "Schließen"
-- Unfortunately, Pandoc's GPL license isn't compatible with the AI Studios licenses. However, software under the GPL is free to use and free of charge. You'll need to accept the GPL license before we can download and install Pandoc for you automatically (recommended). Alternatively, you might download it yourself using the instructions below or install it otherwise, e.g., by using a package manager of your operating system.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PANDOCDIALOG::T1001483402"] = "Leider ist die GPL-Lizenz von Pandoc nicht mit der Lizenz von AI Studio kompatibel. Software unter der GPL-Lizenz ist jedoch kostenlos und frei nutzbar. Sie müssen die GPL-Lizenz akzeptieren, bevor wir Pandoc automatisch für Sie herunterladen und installieren können (empfohlen). Alternativ können Sie Pandoc auch selbst herunterladen entweder mit den untenstehenden Anweisungen oder auf anderem Weg, zum Beispiel über den Paketmanager Ihres Betriebssystems."
@ -5451,6 +5454,57 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PANDOCDIALOG::T504404155"] = "Akzeptieren Si
-- Pandoc is distributed under the GNU General Public License v2 (GPL). By clicking "Accept the GPL and download the archive," you agree to the terms of the GPL license. Software under GPL is free of charge and free to use.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PANDOCDIALOG::T523908375"] = "Pandoc wird unter der GNU General Public License v2 (GPL) vertrieben. Wenn Sie auf „GPL akzeptieren und Archiv herunterladen“ klicken, stimmen Sie den Bedingungen der GPL-Lizenz zu. Software unter der GPL ist kostenlos und frei nutzbar."
-- Install plugin
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1525735539"] = "Plugin installieren"
-- Version
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1573770551"] = "Version"
-- Source
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1642243064"] = "Quelle"
-- You are about to install a language plugin from a file.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1974491324"] = "Sie sind dabei, ein Sprach-Plugin aus einer Datei zu installieren."
-- Authors
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1985367263"] = "Autor:innen"
-- Plugins contain code that runs inside AI Studio. Install plugins only when you trust their source.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2053517490"] = "Plugins enthalten Code, der innerhalb von AI Studio ausgeführt wird. Installieren Sie Plugins nur, wenn Sie der Quelle vertrauen."
-- You are about to install an assistant plugin from a file.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2063808316"] = "Sie sind dabei, ein Assistenten-Plugin aus einer Datei zu installieren."
-- You are about to install a configuration plugin from a file.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T21052500"] = "Sie sind dabei, ein Konfigurations-Plugin aus einer Datei zu installieren."
-- You are about to install a theme plugin from a file.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2163853103"] = "Sie sind dabei, ein Design-Plugin aus einer Datei zu installieren."
-- Support contact
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2434966596"] = "Supportkontakt"
-- This replaces the already installed plugin '{0}'. Version {1} gets replaced by version {2}.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3415610475"] = "Dies ersetzt das bereits installierte Plugin „{0}“. Version {1} wird durch Version {2} ersetzt."
-- Unknown
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3424652889"] = "Unbekannt"
-- Type
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3512062061"] = "Typ"
-- Replace plugin
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T4068580334"] = "Plugin ersetzen"
-- The authors marked this plugin as deprecated: {0}
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T497068698"] = "Die Autoren haben dieses Plugin als veraltet gekennzeichnet: {0}"
-- You are about to install a plugin from a file.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T841685558"] = "Sie sind dabei, ein Plugin aus einer Datei zu installieren."
-- Cancel
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T900713019"] = "Abbrechen"
-- Tell the AI what you want it to do for you. What are your goals or are you trying to achieve? Like having the AI address you informally.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T1458195391"] = "Teilen Sie der KI mit, was sie machen soll. Was sind ihre Ziele oder was möchten Sie erreichen? Zum Beispiel, dass die KI Sie duzt."
@ -7554,6 +7608,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1924365263"] = "Diese Bibliothek
-- Encryption secret: is configured
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1931141322"] = "Geheimnis für die Verschlüsselung: ist konfiguriert"
-- The objc2 project provides access to Apple's Objective-C frameworks from Rust. On macOS, we use the libraries objc2, objc2-app-kit, and objc2-foundation to open the native macOS share sheet, e.g., when you share a plugin with others.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1985806792"] = "Das Projekt objc2 ermöglicht den Zugriff auf die Objective-C-Frameworks von Apple aus Rust. Unter macOS verwenden wir die Bibliotheken objc2, objc2-app-kit und objc2-foundation, um den nativen macOS-Teilen-Dialog zu öffnen, beispielsweise wenn Sie ein Plugin mit anderen teilen."
-- Copies the number of loaded root certificates to the clipboard
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2015329654"] = "Kopiert die Anzahl der geladenen Stammzertifikate in die Zwischenablage"
@ -7563,6 +7620,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2029659664"] = "Kopiert Folgende
-- Copies the server URL to the clipboard
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2037899437"] = "Kopiert die Server-URL in die Zwischenablage"
-- The windows-rs project provides access to Windows APIs from Rust. We use several libraries from this project: windows-registry is used to read the desired configuration in Windows enterprise environments. The windows and windows-collections libraries are used to open the native Windows share dialog, e.g., when you share a plugin with others.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2146481269"] = "Das Projekt windows-rs ermöglicht den Zugriff auf Windows-APIs aus Rust. Wir verwenden mehrere Bibliotheken aus diesem Projekt: windows-registry wird verwendet, um die gewünschte Konfiguration in Windows-Unternehmensumgebungen auszulesen. Die Bibliotheken windows und windows-collections werden verwendet, um den nativen Windows-Dialog zum Teilen zu öffnen, zum Beispiel wenn Sie ein Plugin mit anderen teilen."
-- This library is used to create temporary folders in runtime tests and supporting filesystem operations.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2160280545"] = "Diese Bibliothek wird verwendet, um temporäre Ordner bei Laufzeittests zu erstellen und Dateisystemoperationen zu unterstützen."
@ -7779,9 +7839,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3813932670"] = "diese Version er
-- On Linux, ashpd provides access to desktop portals, allowing AI Studio to open folders and files for the user.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3871176264"] = "Unter Linux ermöglicht ashpd den Zugriff auf Desktop-Portale, sodass AI Studio Ordner und Dateien für den Nutzer öffnen kann."
-- This library is used to access the Windows registry. We use this for Windows enterprise environments to read the desired configuration.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3874337003"] = "Diese Bibliothek wird verwendet, um auf die Windows-Registry zuzugreifen. Wir nutzen sie in Windows-Unternehmensumgebungen, um die gewünschte Konfiguration auszulesen."
-- Now we have multiple systems, some developed in .NET and others in Rust. The data format JSON is responsible for translating data between both worlds (called data serialization and deserialization). Serde takes on this task in the Rust world. The counterpart in the .NET world is an integral part of .NET and is located in System.Text.Json.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3908558992"] = "Jetzt haben wir mehrere Systeme, einige entwickelt in .NET und andere in Rust. Das Datenformat JSON ist dafür zuständig, Daten zwischen beiden Welten zu übersetzen (dies nennt man Serialisierung und Deserialisierung von Daten). In der Rust-Welt übernimmt Serde diese Aufgabe. Das Pendant in der .NET-Welt ist ein fester Bestandteil von .NET und findet sich in System.Text.Json."
@ -7902,6 +7959,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1229643769"] = "Potenziell gefährli
-- Disable plugin
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1430375822"] = "Plugin deaktivieren"
-- Import
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1463683828"] = "Importieren"
-- Assistant Audit
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1506922856"] = "Assistentenprüfung"
@ -7923,6 +7983,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2057806005"] = "Plugin aktivieren"
-- No source url available
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2058912565"] = "Keine Quell-URL verfügbar"
-- Assistant installed.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2069785341"] = "Assistent installiert."
-- Plugins
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2222816203"] = "Plugins"
@ -7935,18 +7998,51 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2738444034"] = "Aktivierte Plugins"
-- Revise Assistant Plugin
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T284393424"] = "Assistenten-Plugin überarbeiten"
-- Import not possible
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3051566124"] = "Import nicht möglich"
-- The assistant plugin '{0}' has been successfully saved.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3143506997"] = "Das Assistent-Plugin „{0}“ wurde erfolgreich gespeichert."
-- An error occurred while sharing the plugin.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3184210266"] = "Beim Teilen des Plugins ist ein Fehler aufgetreten."
-- Import assistant plugin
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3246593895"] = "Assistenten-Plugin importieren"
-- Your organization has disabled exporting plugins.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3342440765"] = "Ihre Organisation hat das Exportieren von Plugins deaktiviert."
-- Share plugin archive
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3355474457"] = "Plugin-Archiv teilen"
-- Your organization has disabled sharing plugins
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3379469503"] = "Ihre Organisation hat das Teilen von Plugins deaktiviert"
-- Close
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3448155331"] = "Schließen"
-- Please drop a plugin archive with the extension {0} or .zip.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3785427568"] = "Bitte legen Sie ein Plugin-Archiv mit der Erweiterung {0} oder .zip hier ab."
-- Revise assistant plugin with AI
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3801095542"] = "Assistenten-Plugin mit KI überarbeiten"
-- Actions
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3865031940"] = "Aktionen"
-- Export plugin archive
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3872669664"] = "Plugin-Archiv exportieren"
-- Install Plugin
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3902690643"] = "Plugin installieren"
-- Please drop only one plugin archive at a time.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3974628410"] = "Bitte legen Sie jeweils nur ein Plugin-Archiv gleichzeitig ab."
-- Assistant updated.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T40397082"] = "Assistent aktualisiert."
-- The automatic security audit for the assistant plugin '{0}' failed. Please run it manually.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4066679817"] = "Die automatische Sicherheitsprüfung für das Assistenten-Plugin „{0}“ ist fehlgeschlagen. Bitte führen Sie sie manuell aus."
@ -7959,6 +8055,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4239378936"] = "Website öffnen"
-- The assistant plugin '{0}' was audited with the level '{1}', which is below the required minimum level '{2}'. Your current settings allow activation anyway, but this may be potentially dangerous. Do you really want to enable this plugin?
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T448946658"] = "Das Assistenten-Plugin „{0}“ wurde mit der Stufe „{1}“ geprüft, die unter der erforderlichen Mindeststufe „{2}“ liegt. Ihre aktuellen Einstellungen erlauben die Aktivierung dennoch, dies kann jedoch potenziell gefährlich sein. Möchten Sie dieses Plugin wirklich aktivieren?"
-- The plugin archive was exported to '{0}'.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T659549952"] = "Das Plugin-Archiv wurde nach „{0}“ exportiert."
-- An error occurred while exporting the plugin.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T759681732"] = "Beim Exportieren des Plugins ist ein Fehler aufgetreten."
-- The plugin could not be imported: {0}
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T837269472"] = "Das Plugin konnte nicht importiert werden: {0}"
-- Settings
UI_TEXT_CONTENT["AISTUDIO::PAGES::SETTINGS::T1258653480"] = "Einstellungen"
@ -9372,6 +9477,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source Code
-- Document
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T4165204724"] = "Dokument"
-- Plugin archive
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T927001356"] = "Plugin-Archiv"
-- The Assistant Builder context could not be loaded.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T108292972"] = "Der Kontext des Assistenten-Builders konnte nicht geladen werden."
@ -9477,6 +9585,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T4
-- Internal assistant plugins cannot be deleted.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1084244321"] = "Interne Assistenten-Plugins können nicht gelöscht werden."
-- This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1138181282"] = "Dieses Plugin-Archiv gibt an, von einem Konfigurationsserver verwaltet zu werden. Nur die IT-Abteilung Ihrer Organisation kann solche Plugins bereitstellen."
-- The assistant plugin directory is outside the local assistant plugin directory.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1211881977"] = "Das Assistenten-Plugin-Verzeichnis befindet sich außerhalb des lokalen Assistenten-Plugin-Verzeichnisses."
@ -9486,12 +9597,18 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1288
-- The assistant cannot be deleted while background work is still running.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1318944584"] = "Der Assistent kann nicht gelöscht werden, solange noch Hintergrundaktivitäten ausgeführt werden."
-- Currently, only assistant plugins can be imported.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T139615196"] = "Derzeit können nur Assistenten-Plugins importiert werden."
-- Please select a plugin archive with the extension .mwplugin or .zip.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1809137998"] = "Bitte wählen Sie ein Plugin-Archiv mit der Dateiendung .mwplugin oder .zip aus."
-- The selected plugin archive does not exist.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1821013825"] = "Das ausgewählte Plugin-Archiv existiert nicht."
-- No Lua plugin code was generated.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1839013358"] = "Es wurde kein Lua-Plugin-Code generiert."
-- The edited assistant plugin uses the ID of an internal AI Studio plugin.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2061233834"] = "Das bearbeitete Assistenten-Plugin verwendet die ID eines internen AI-Studio-Plugins."
-- The assistant plugin directory does not exist.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2148384567"] = "Das Verzeichnis für das Assistenten-Plugin existiert nicht."
@ -9501,20 +9618,35 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2223
-- Unexpected error: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2350673880"] = "Unerwarteter Fehler: {0}"
-- The generated assistant plugin uses the ID of another installed plugin.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2441747251"] = "Das generierte Assistenten-Plugin verwendet die ID eines anderen installierten Plugins."
-- Config server managed assistant plugins cannot be replaced.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2594571117"] = "Vom Konfigurationsserver verwaltete Assistenten-Plugins können nicht ersetzt werden."
-- The assistant plugin has no local directory.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2682912892"] = "Das Assistenten-Plugin hat kein lokales Verzeichnis."
-- The AI Studio data directory is not initialized yet.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2712481762"] = "Das Datenverzeichnis von AI Studio ist noch nicht initialisiert."
-- The imported assistant plugin is invalid. Issue: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2777304537"] = "Das importierte Assistenten-Plugin ist ungültig. Problem: {0}"
-- Only assistant plugins can be deleted.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2864597027"] = "Nur Assistant-Plugins können gelöscht werden."
-- The generated plugin is not an assistant plugin. Issue: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2955055168"] = "Das generierte Plugin ist kein Assistenten-Plugin. Problem: {0}"
-- The generated assistant plugin uses the ID of an internal AI Studio plugin.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3162363526"] = "Das generierte Assistent-Plugin verwendet die ID eines internen AI-Studio-Plugins."
-- The imported assistant plugin uses the ID of another installed plugin.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2971411166"] = "Das importierte Assistenten-Plugin verwendet die ID eines anderen installierten Plugins."
-- Your organization has disabled importing plugins.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3212529834"] = "Ihre Organisation hat das Importieren von Plugins deaktiviert."
-- The plugin archive must contain exactly one plugin.lua file.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3355918609"] = "Das Plugin-Archiv muss genau eine plugin.lua-Datei enthalten."
-- Config Server managed assistant plugins cannot be deleted.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3751820312"] = "Von einem Config-Server verwaltete Assistenten-Plugins können nicht gelöscht werden."
@ -9534,6 +9666,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T4062
-- The edited assistant plugin is invalid. Issue: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T554567780"] = "Das bearbeitete Assistenten-Plugin ist ungültig. Problem: {0}"
-- The edited assistant plugin uses the ID of another installed plugin.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T584770023"] = "Das bearbeitete Assistenten-Plugin verwendet die ID eines anderen installierten Plugins."
-- The edited assistant plugin must keep the same plugin ID.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T693124809"] = "Das bearbeitete Assistant-Plugin muss dieselbe Plugin-ID beibehalten."
@ -9594,6 +9729,36 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T18544701
-- Pandoc may be required for importing files.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T2596465560"] = "Zum Importieren von Dateien kann Pandoc erforderlich sein."
-- Internal plugins cannot be shared.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T1668534561"] = "Interne Plugins können nicht geteilt werden."
-- Config Server managed plugins cannot be shared.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2077776546"] = "Vom Konfigurationsserver verwaltete Plugins können nicht geteilt werden."
-- The native share dialog could not be opened.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2101116016"] = "Der systemeigene Dialog zum Teilen konnte nicht geöffnet werden."
-- The plugin directory does not exist.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2221093487"] = "Das Plugin-Verzeichnis existiert nicht."
-- Unexpected error: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2350673880"] = "Unerwarteter Fehler: {0}"
-- The plugin has no local directory.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T3284289028"] = "Das Plugin hat kein lokales Verzeichnis."
-- Your organization has disabled sharing plugins.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T3379469503"] = "Ihre Organisation hat das Teilen von Plugins deaktiviert."
-- The plugin directory is invalid: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T3774594541"] = "Das Plugin-Verzeichnis ist ungültig: {0}"
-- Export plugin archive
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T3872669664"] = "Plugin-Archiv exportieren"
-- The plugin directory does not contain a plugin.lua file.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T409411078"] = "Das Plugin-Verzeichnis enthält keine Datei `plugin.lua`."
-- Failed to store the secret data due to an API issue.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1110203516"] = "Fehler beim Speichern der geheimen Daten aufgrund eines API-Problems."

View File

@ -5361,6 +5361,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGRESULTDIALOG::T1173984541"] = "Embe
-- Close
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGRESULTDIALOG::T3448155331"] = "Close"
-- Close
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::INFORMATIONDIALOG::T3448155331"] = "Close"
-- Unfortunately, Pandoc's GPL license isn't compatible with the AI Studios licenses. However, software under the GPL is free to use and free of charge. You'll need to accept the GPL license before we can download and install Pandoc for you automatically (recommended). Alternatively, you might download it yourself using the instructions below or install it otherwise, e.g., by using a package manager of your operating system.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PANDOCDIALOG::T1001483402"] = "Unfortunately, Pandoc's GPL license isn't compatible with the AI Studios licenses. However, software under the GPL is free to use and free of charge. You'll need to accept the GPL license before we can download and install Pandoc for you automatically (recommended). Alternatively, you might download it yourself using the instructions below or install it otherwise, e.g., by using a package manager of your operating system."
@ -5451,6 +5454,57 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PANDOCDIALOG::T504404155"] = "Accept the ter
-- Pandoc is distributed under the GNU General Public License v2 (GPL). By clicking "Accept the GPL and download the archive," you agree to the terms of the GPL license. Software under GPL is free of charge and free to use.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PANDOCDIALOG::T523908375"] = "Pandoc is distributed under the GNU General Public License v2 (GPL). By clicking \"Accept the GPL and download the archive,\" you agree to the terms of the GPL license. Software under GPL is free of charge and free to use."
-- Install plugin
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1525735539"] = "Install plugin"
-- Version
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1573770551"] = "Version"
-- Source
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1642243064"] = "Source"
-- You are about to install a language plugin from a file.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1974491324"] = "You are about to install a language plugin from a file."
-- Authors
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T1985367263"] = "Authors"
-- Plugins contain code that runs inside AI Studio. Install plugins only when you trust their source.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2053517490"] = "Plugins contain code that runs inside AI Studio. Install plugins only when you trust their source."
-- You are about to install an assistant plugin from a file.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2063808316"] = "You are about to install an assistant plugin from a file."
-- You are about to install a configuration plugin from a file.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T21052500"] = "You are about to install a configuration plugin from a file."
-- You are about to install a theme plugin from a file.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2163853103"] = "You are about to install a theme plugin from a file."
-- Support contact
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T2434966596"] = "Support contact"
-- This replaces the already installed plugin '{0}'. Version {1} gets replaced by version {2}.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3415610475"] = "This replaces the already installed plugin '{0}'. Version {1} gets replaced by version {2}."
-- Unknown
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3424652889"] = "Unknown"
-- Type
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T3512062061"] = "Type"
-- Replace plugin
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T4068580334"] = "Replace plugin"
-- The authors marked this plugin as deprecated: {0}
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T497068698"] = "The authors marked this plugin as deprecated: {0}"
-- You are about to install a plugin from a file.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T841685558"] = "You are about to install a plugin from a file."
-- Cancel
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T900713019"] = "Cancel"
-- Tell the AI what you want it to do for you. What are your goals or are you trying to achieve? Like having the AI address you informally.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T1458195391"] = "Tell the AI what you want it to do for you. What are your goals or are you trying to achieve? Like having the AI address you informally."
@ -7554,6 +7608,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1924365263"] = "This library is
-- Encryption secret: is configured
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1931141322"] = "Encryption secret: is configured"
-- The objc2 project provides access to Apple's Objective-C frameworks from Rust. On macOS, we use the libraries objc2, objc2-app-kit, and objc2-foundation to open the native macOS share sheet, e.g., when you share a plugin with others.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1985806792"] = "The objc2 project provides access to Apple's Objective-C frameworks from Rust. On macOS, we use the libraries objc2, objc2-app-kit, and objc2-foundation to open the native macOS share sheet, e.g., when you share a plugin with others."
-- Copies the number of loaded root certificates to the clipboard
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2015329654"] = "Copies the number of loaded root certificates to the clipboard"
@ -7563,6 +7620,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2029659664"] = "Copies the follo
-- Copies the server URL to the clipboard
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2037899437"] = "Copies the server URL to the clipboard"
-- The windows-rs project provides access to Windows APIs from Rust. We use several libraries from this project: windows-registry is used to read the desired configuration in Windows enterprise environments. The windows and windows-collections libraries are used to open the native Windows share dialog, e.g., when you share a plugin with others.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2146481269"] = "The windows-rs project provides access to Windows APIs from Rust. We use several libraries from this project: windows-registry is used to read the desired configuration in Windows enterprise environments. The windows and windows-collections libraries are used to open the native Windows share dialog, e.g., when you share a plugin with others."
-- This library is used to create temporary folders in runtime tests and supporting filesystem operations.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2160280545"] = "This library is used to create temporary folders in runtime tests and supporting filesystem operations."
@ -7779,9 +7839,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3813932670"] = "this version doe
-- On Linux, ashpd provides access to desktop portals, allowing AI Studio to open folders and files for the user.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3871176264"] = "On Linux, ashpd provides access to desktop portals, allowing AI Studio to open folders and files for the user."
-- This library is used to access the Windows registry. We use this for Windows enterprise environments to read the desired configuration.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3874337003"] = "This library is used to access the Windows registry. We use this for Windows enterprise environments to read the desired configuration."
-- Now we have multiple systems, some developed in .NET and others in Rust. The data format JSON is responsible for translating data between both worlds (called data serialization and deserialization). Serde takes on this task in the Rust world. The counterpart in the .NET world is an integral part of .NET and is located in System.Text.Json.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3908558992"] = "Now we have multiple systems, some developed in .NET and others in Rust. The data format JSON is responsible for translating data between both worlds (called data serialization and deserialization). Serde takes on this task in the Rust world. The counterpart in the .NET world is an integral part of .NET and is located in System.Text.Json."
@ -7902,6 +7959,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1229643769"] = "Potentially Dangerou
-- Disable plugin
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1430375822"] = "Disable plugin"
-- Import
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1463683828"] = "Import"
-- Assistant Audit
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T1506922856"] = "Assistant Audit"
@ -7923,6 +7983,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2057806005"] = "Enable plugin"
-- No source url available
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2058912565"] = "No source url available"
-- Assistant installed.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2069785341"] = "Assistant installed."
-- Plugins
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2222816203"] = "Plugins"
@ -7935,18 +7998,51 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2738444034"] = "Enabled Plugins"
-- Revise Assistant Plugin
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T284393424"] = "Revise Assistant Plugin"
-- Import not possible
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3051566124"] = "Import not possible"
-- The assistant plugin '{0}' has been successfully saved.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3143506997"] = "The assistant plugin '{0}' has been successfully saved."
-- An error occurred while sharing the plugin.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3184210266"] = "An error occurred while sharing the plugin."
-- Import assistant plugin
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3246593895"] = "Import assistant plugin"
-- Your organization has disabled exporting plugins.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3342440765"] = "Your organization has disabled exporting plugins."
-- Share plugin archive
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3355474457"] = "Share plugin archive"
-- Your organization has disabled sharing plugins
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3379469503"] = "Your organization has disabled sharing plugins"
-- Close
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3448155331"] = "Close"
-- Please drop a plugin archive with the extension {0} or .zip.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3785427568"] = "Please drop a plugin archive with the extension {0} or .zip."
-- Revise assistant plugin with AI
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3801095542"] = "Revise assistant plugin with AI"
-- Actions
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3865031940"] = "Actions"
-- Export plugin archive
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3872669664"] = "Export plugin archive"
-- Install Plugin
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3902690643"] = "Install Plugin"
-- Please drop only one plugin archive at a time.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T3974628410"] = "Please drop only one plugin archive at a time."
-- Assistant updated.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T40397082"] = "Assistant updated."
-- The automatic security audit for the assistant plugin '{0}' failed. Please run it manually.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4066679817"] = "The automatic security audit for the assistant plugin '{0}' failed. Please run it manually."
@ -7959,6 +8055,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T4239378936"] = "Open website"
-- The assistant plugin '{0}' was audited with the level '{1}', which is below the required minimum level '{2}'. Your current settings allow activation anyway, but this may be potentially dangerous. Do you really want to enable this plugin?
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T448946658"] = "The assistant plugin '{0}' was audited with the level '{1}', which is below the required minimum level '{2}'. Your current settings allow activation anyway, but this may be potentially dangerous. Do you really want to enable this plugin?"
-- The plugin archive was exported to '{0}'.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T659549952"] = "The plugin archive was exported to '{0}'."
-- An error occurred while exporting the plugin.
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T759681732"] = "An error occurred while exporting the plugin."
-- The plugin could not be imported: {0}
UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T837269472"] = "The plugin could not be imported: {0}"
-- Settings
UI_TEXT_CONTENT["AISTUDIO::PAGES::SETTINGS::T1258653480"] = "Settings"
@ -9372,6 +9477,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source like p
-- Document
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T4165204724"] = "Document"
-- Plugin archive
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T927001356"] = "Plugin archive"
-- The Assistant Builder context could not be loaded.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T108292972"] = "The Assistant Builder context could not be loaded."
@ -9477,6 +9585,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGINGENERATIONSERVICE::T4
-- Internal assistant plugins cannot be deleted.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1084244321"] = "Internal assistant plugins cannot be deleted."
-- This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1138181282"] = "This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins."
-- The assistant plugin directory is outside the local assistant plugin directory.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1211881977"] = "The assistant plugin directory is outside the local assistant plugin directory."
@ -9486,12 +9597,18 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1288
-- The assistant cannot be deleted while background work is still running.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1318944584"] = "The assistant cannot be deleted while background work is still running."
-- Currently, only assistant plugins can be imported.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T139615196"] = "Currently, only assistant plugins can be imported."
-- Please select a plugin archive with the extension .mwplugin or .zip.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1809137998"] = "Please select a plugin archive with the extension .mwplugin or .zip."
-- The selected plugin archive does not exist.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1821013825"] = "The selected plugin archive does not exist."
-- No Lua plugin code was generated.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T1839013358"] = "No Lua plugin code was generated."
-- The edited assistant plugin uses the ID of an internal AI Studio plugin.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2061233834"] = "The edited assistant plugin uses the ID of an internal AI Studio plugin."
-- The assistant plugin directory does not exist.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2148384567"] = "The assistant plugin directory does not exist."
@ -9501,20 +9618,35 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2223
-- Unexpected error: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2350673880"] = "Unexpected error: {0}"
-- The generated assistant plugin uses the ID of another installed plugin.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2441747251"] = "The generated assistant plugin uses the ID of another installed plugin."
-- Config server managed assistant plugins cannot be replaced.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2594571117"] = "Config server managed assistant plugins cannot be replaced."
-- The assistant plugin has no local directory.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2682912892"] = "The assistant plugin has no local directory."
-- The AI Studio data directory is not initialized yet.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2712481762"] = "The AI Studio data directory is not initialized yet."
-- The imported assistant plugin is invalid. Issue: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2777304537"] = "The imported assistant plugin is invalid. Issue: {0}"
-- Only assistant plugins can be deleted.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2864597027"] = "Only assistant plugins can be deleted."
-- The generated plugin is not an assistant plugin. Issue: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2955055168"] = "The generated plugin is not an assistant plugin. Issue: {0}"
-- The generated assistant plugin uses the ID of an internal AI Studio plugin.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3162363526"] = "The generated assistant plugin uses the ID of an internal AI Studio plugin."
-- The imported assistant plugin uses the ID of another installed plugin.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T2971411166"] = "The imported assistant plugin uses the ID of another installed plugin."
-- Your organization has disabled importing plugins.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3212529834"] = "Your organization has disabled importing plugins."
-- The plugin archive must contain exactly one plugin.lua file.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3355918609"] = "The plugin archive must contain exactly one plugin.lua file."
-- Config Server managed assistant plugins cannot be deleted.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T3751820312"] = "Config Server managed assistant plugins cannot be deleted."
@ -9534,6 +9666,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T4062
-- The edited assistant plugin is invalid. Issue: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T554567780"] = "The edited assistant plugin is invalid. Issue: {0}"
-- The edited assistant plugin uses the ID of another installed plugin.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T584770023"] = "The edited assistant plugin uses the ID of another installed plugin."
-- The edited assistant plugin must keep the same plugin ID.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T693124809"] = "The edited assistant plugin must keep the same plugin ID."
@ -9594,6 +9729,36 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T18544701
-- Pandoc may be required for importing files.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T2596465560"] = "Pandoc may be required for importing files."
-- Internal plugins cannot be shared.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T1668534561"] = "Internal plugins cannot be shared."
-- Config Server managed plugins cannot be shared.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2077776546"] = "Config Server managed plugins cannot be shared."
-- The native share dialog could not be opened.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2101116016"] = "The native share dialog could not be opened."
-- The plugin directory does not exist.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2221093487"] = "The plugin directory does not exist."
-- Unexpected error: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2350673880"] = "Unexpected error: {0}"
-- The plugin has no local directory.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T3284289028"] = "The plugin has no local directory."
-- Your organization has disabled sharing plugins.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T3379469503"] = "Your organization has disabled sharing plugins."
-- The plugin directory is invalid: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T3774594541"] = "The plugin directory is invalid: {0}"
-- Export plugin archive
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T3872669664"] = "Export plugin archive"
-- The plugin directory does not contain a plugin.lua file.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T409411078"] = "The plugin directory does not contain a plugin.lua file."
-- Failed to store the secret data due to an API issue.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1110203516"] = "Failed to store the secret data due to an API issue."

View File

@ -191,6 +191,8 @@ internal sealed class Program
builder.Services.AddSingleton<DatabaseClientProvider>();
builder.Services.AddHostedService<GlobalShortcutService>(serviceProvider => serviceProvider.GetRequiredService<GlobalShortcutService>());
builder.Services.AddHostedService<RustAvailabilityMonitorService>();
builder.Services.AddScoped<NativeShareService>();
builder.Services.AddScoped<PluginShareService>();
// ReSharper disable AccessToDisposedClosure
builder.Services.AddHostedService<RustService>(_ => rust);

View File

@ -149,6 +149,16 @@ public sealed class DataApp(Expression<Func<Data, DataApp>>? configSelection = n
/// </summary>
public bool AllowUserToAddProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToAddProvider, true);
/// <summary>
/// Should the user be allowed to import plugin archives from disk?
/// </summary>
public bool AllowUserToImportPlugins { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToImportPlugins, true);
/// <summary>
/// Should the user be allowed to share or export plugins as archives?
/// </summary>
public bool AllowUserToSharePlugins { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToSharePlugins, true);
/// <summary>
/// Should administration settings be visible in the UI?
/// </summary>

View File

@ -0,0 +1,80 @@
using System.IO.Compression;
namespace AIStudio.Tools.PluginSystem;
public static class PluginArchive
{
/// <summary>
/// The file extension of plugin archives.
/// </summary>
/// <remarks>
/// Keep in sync with SHARE_FILE_EXTENSION in runtime/src/share_sheet.rs: the runtime only hands
/// archives with this extension to the native share sheet.
/// </remarks>
public const string PLUGIN_FILE_EXTENSION = ".mwplugin";
// Compatibility shim for Windows-created ZIPs with backslashes in entry names (dotnet/runtime#27620);
// remove after dotnet/runtime#27620 and #41914 are fixed.
// See documentation/compatibility-shims/2026-07-plugin-archive-zip-backslashes.md.
public static void Extract(string sourceArchiveFileName, string destinationDirectory)
{
using var archive = ZipFile.OpenRead(sourceArchiveFileName);
Directory.CreateDirectory(destinationDirectory);
var destinationDirectoryFullPath = Path.GetFullPath(destinationDirectory);
if (!destinationDirectoryFullPath.EndsWith(Path.DirectorySeparatorChar))
destinationDirectoryFullPath += Path.DirectorySeparatorChar;
foreach (var entry in archive.Entries)
{
var normalizedEntryName = NormalizeEntryName(entry.FullName);
var destinationPath = GetEntryDestinationPath(destinationDirectoryFullPath, normalizedEntryName);
if (normalizedEntryName.EndsWith('/'))
{
if (entry.Length != 0)
throw new InvalidDataException($"The plugin archive contains a directory entry with data: '{entry.FullName}'.");
Directory.CreateDirectory(destinationPath);
continue;
}
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
entry.ExtractToFile(destinationPath);
}
}
private static string NormalizeEntryName(string entryName)
{
var normalizedEntryName = entryName.Replace('\\', '/');
if (string.IsNullOrWhiteSpace(normalizedEntryName))
throw new InvalidDataException("The plugin archive contains an empty entry name.");
if (normalizedEntryName.Contains('\0'))
throw new InvalidDataException($"The plugin archive contains an invalid entry name: '{entryName}'.");
if (normalizedEntryName.StartsWith('/'))
throw new InvalidDataException($"The plugin archive contains a rooted entry name: '{entryName}'.");
if (normalizedEntryName is [_, ':', ..])
throw new InvalidDataException($"The plugin archive contains a drive-qualified entry name: '{entryName}'.");
var pathSegments = normalizedEntryName.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (pathSegments.Length == 0 || pathSegments.Any(segment => segment is "." or ".."))
throw new InvalidDataException($"The plugin archive contains an unsafe entry name: '{entryName}'.");
return normalizedEntryName;
}
private static string GetEntryDestinationPath(string destinationDirectoryFullPath, string normalizedEntryName)
{
var pathSegments = normalizedEntryName.Split('/', StringSplitOptions.RemoveEmptyEntries);
var relativePath = Path.Combine(pathSegments);
var destinationPath = Path.GetFullPath(Path.Combine(destinationDirectoryFullPath, relativePath));
if (!destinationPath.StartsWith(destinationDirectoryFullPath, StringComparison.Ordinal))
throw new InvalidDataException($"The plugin archive contains an entry outside the destination directory: '{normalizedEntryName}'.");
return destinationPath;
}
}

View File

@ -179,6 +179,12 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Config: allow the user to add providers?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToAddProvider, this.Id, settingsTable, dryRun);
// Config: allow the user to import plugin archives?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportPlugins, this.Id, settingsTable, dryRun);
// Config: allow the user to share or export plugins?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToSharePlugins, this.Id, settingsTable, dryRun);
// Config: show administration settings?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShowAdminSettings, this.Id, settingsTable, dryRun);

View File

@ -1,4 +1,3 @@
using System.IO.Compression;
using System.Net.Http.Headers;
namespace AIStudio.Tools.PluginSystem;
@ -130,69 +129,11 @@ public static partial class PluginFactory
return wasSuccessful;
}
// Compatibility shim for Windows-created ZIPs with backslashes in entry names (dotnet/runtime#27620).
// See documentation/compatibility-shims/2026-07-enterprise-config-zip-backslashes.md.
private static void ExtractConfigPluginArchive(string sourceArchiveFileName, string destinationDirectory)
{
using var archive = ZipFile.OpenRead(sourceArchiveFileName);
Directory.CreateDirectory(destinationDirectory);
var destinationDirectoryFullPath = Path.GetFullPath(destinationDirectory);
if (!destinationDirectoryFullPath.EndsWith(Path.DirectorySeparatorChar))
destinationDirectoryFullPath += Path.DirectorySeparatorChar;
foreach (var entry in archive.Entries)
{
var normalizedEntryName = NormalizeConfigPluginZipEntryName(entry.FullName);
var destinationPath = GetConfigPluginZipEntryDestinationPath(destinationDirectoryFullPath, normalizedEntryName);
if (normalizedEntryName.EndsWith('/'))
{
if (entry.Length != 0)
throw new InvalidDataException($"The enterprise configuration plugin archive contains a directory entry with data: '{entry.FullName}'.");
Directory.CreateDirectory(destinationPath);
continue;
}
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
entry.ExtractToFile(destinationPath);
}
PluginArchive.Extract(sourceArchiveFileName, destinationDirectory);
if (!Directory.EnumerateFiles(destinationDirectory, "plugin.lua", SearchOption.AllDirectories).Any())
throw new InvalidDataException("The enterprise configuration plugin archive does not contain a plugin.lua file.");
}
private static string NormalizeConfigPluginZipEntryName(string entryName)
{
var normalizedEntryName = entryName.Replace('\\', '/');
if (string.IsNullOrWhiteSpace(normalizedEntryName))
throw new InvalidDataException("The enterprise configuration plugin archive contains an empty entry name.");
if (normalizedEntryName.Contains('\0'))
throw new InvalidDataException($"The enterprise configuration plugin archive contains an invalid entry name: '{entryName}'.");
if (normalizedEntryName.StartsWith('/'))
throw new InvalidDataException($"The enterprise configuration plugin archive contains a rooted entry name: '{entryName}'.");
if (normalizedEntryName is [_, ':', ..])
throw new InvalidDataException($"The enterprise configuration plugin archive contains a drive-qualified entry name: '{entryName}'.");
var pathSegments = normalizedEntryName.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (pathSegments.Length == 0 || pathSegments.Any(segment => segment is "." or ".."))
throw new InvalidDataException($"The enterprise configuration plugin archive contains an unsafe entry name: '{entryName}'.");
return normalizedEntryName;
}
private static string GetConfigPluginZipEntryDestinationPath(string destinationDirectoryFullPath, string normalizedEntryName)
{
var pathSegments = normalizedEntryName.Split('/', StringSplitOptions.RemoveEmptyEntries);
var relativePath = Path.Combine(pathSegments);
var destinationPath = Path.GetFullPath(Path.Combine(destinationDirectoryFullPath, relativePath));
if (!destinationPath.StartsWith(destinationDirectoryFullPath, StringComparison.Ordinal))
throw new InvalidDataException($"The enterprise configuration plugin archive contains an entry outside the destination directory: '{normalizedEntryName}'.");
return destinationPath;
}
}

View File

@ -265,6 +265,14 @@ public static partial class PluginFactory
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.AllowUserToAddProvider, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the plugin import permission:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.AllowUserToImportPlugins, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the plugin sharing permission:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.AllowUserToSharePlugins, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for admin settings visibility:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShowAdminSettings, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
@ -377,7 +385,15 @@ public static partial class PluginFactory
}
}
public static async Task<PluginBase> Load(string? pluginPath, string code, CancellationToken cancellationToken = default)
/// <param name="pluginPath">The directory the plugin is located in, or null when the code has no directory yet.</param>
/// <param name="code">The Lua code of the plugin's main file.</param>
/// <param name="cancellationToken">Cancellation token for running the Lua code.</param>
/// <param name="allowedBaseDirectory">
/// The directory the plugin path must be nested in. Without it, the installed plugins directory
/// is used. Validating a plugin before its installation needs this, because the plugin lives in
/// a staging directory at that point and could not load any of its own Lua modules otherwise.
/// </param>
public static async Task<PluginBase> Load(string? pluginPath, string code, CancellationToken cancellationToken = default, string? allowedBaseDirectory = null)
{
if(ForbiddenPlugins.Check(code) is { IsForbidden: true } forbiddenState)
return new NoPlugin($"This plugin is forbidden: {forbiddenState.Message}");
@ -386,7 +402,7 @@ public static partial class PluginFactory
if (!string.IsNullOrWhiteSpace(pluginPath))
{
// Add the module loader so that the plugin can load other Lua modules:
state.ModuleLoader = new PluginLoader(pluginPath);
state.ModuleLoader = new PluginLoader(pluginPath, allowedBaseDirectory);
}
// Add some useful libraries:

View File

@ -14,10 +14,17 @@ namespace AIStudio.Tools.PluginSystem;
/// Loading other modules outside the plugin directory is not allowed.
/// </remarks>
/// <param name="pluginDirectory">The directory where the plugin is located.</param>
public sealed class PluginLoader(string pluginDirectory) : ILuaModuleLoader
/// <param name="allowedBaseDirectory">
/// The directory the plugin directory must be nested in. Without it, the installed plugins directory
/// is used. Validating a plugin before its installation needs this, because the plugin is not
/// installed yet and lives in a staging directory outside the installed plugins directory.
/// </param>
public sealed class PluginLoader(string pluginDirectory, string? allowedBaseDirectory = null) : ILuaModuleLoader
{
private static readonly string PLUGIN_BASE_PATH = Path.Join(SettingsManager.DataDirectory, "plugins");
private readonly string baseDirectory = string.IsNullOrWhiteSpace(allowedBaseDirectory) ? PLUGIN_BASE_PATH : allowedBaseDirectory;
#region Implementation of ILuaModuleLoader
/// <inheritdoc />
@ -27,8 +34,8 @@ public sealed class PluginLoader(string pluginDirectory) : ILuaModuleLoader
if (moduleName.Contains("..") || pluginDirectory.Contains(".."))
return false;
// Ensure that the plugin directory is nested in the plugin base path:
if (!pluginDirectory.StartsWith(PLUGIN_BASE_PATH, StringComparison.OrdinalIgnoreCase))
// Ensure that the plugin directory is nested in the allowed base directory:
if (!pluginDirectory.StartsWith(this.baseDirectory, StringComparison.OrdinalIgnoreCase))
return false;
var path = Path.Join(pluginDirectory, $"{moduleName}.lua");

View File

@ -1,4 +1,5 @@
using AIStudio.Tools.PluginSystem;
// ReSharper disable MemberCanBePrivate.Global
namespace AIStudio.Tools.Rust;
@ -81,6 +82,7 @@ public static class FileTypes
// Other standalone types
public static readonly FileTypeFilter CERTIFICATE_BUNDLE = FileTypeFilter.Leaf(TB("Certificate bundle"), "pem", "crt", "cer");
public static readonly FileTypeFilter EXECUTABLES = FileTypeFilter.Leaf(TB("Executable"), "exe", "app", "bin", "appimage");
public static readonly FileTypeFilter PLUGIN_ARCHIVE = FileTypeFilter.Leaf(TB("Plugin archive"), PluginArchive.PLUGIN_FILE_EXTENSION.TrimStart('.'), "zip");
public static FileTypeFilter? AsOneFileType(params FileTypeFilter[]? types)
{

View File

@ -0,0 +1,3 @@
namespace AIStudio.Tools.Services;
public sealed record AssistantPluginCheckResult(bool Success, Guid PluginId, string PluginName, string Issue);

View File

@ -0,0 +1,3 @@
namespace AIStudio.Tools.Services;
public sealed record AssistantPluginDeleteResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, string Issue);

View File

@ -0,0 +1,3 @@
namespace AIStudio.Tools.Services;
public sealed record AssistantPluginInstallResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, bool ReplacedExisting, string Issue, bool Cancelled = false);

View File

@ -4,17 +4,10 @@ using AIStudio.Tools.AssistantSessions;
using AIStudio.Tools.Media;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.PluginSystem.Assistants;
using AIStudio.Tools.Rust;
namespace AIStudio.Tools.Services;
public sealed record AssistantPluginInstallResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, bool ReplacedExisting, string Issue);
public sealed record AssistantPluginCheckResult(bool Success, Guid PluginId, string PluginName, string Issue);
public sealed record AssistantPluginDeleteResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, string Issue);
public sealed record AssistantPluginUpdateResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, string Issue);
public sealed class AssistantPluginInstallService
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(AssistantPluginInstallService).Namespace, nameof(AssistantPluginInstallService));
@ -22,6 +15,7 @@ public sealed class AssistantPluginInstallService
private const string PLUGIN_FILE_NAME = "plugin.lua";
private const string ASSISTANT_BUILDER_DIRECTORY_PREFIX = "assistant-builder";
private const string DELETE_BACKUP_DIRECTORY = ".plugin-delete-backups";
private const string INSTALL_BACKUP_DIRECTORY = ".plugin-install-backups";
private const int DIRECTORY_PREFIX_MAX_LEN = 80;
private readonly ILogger<AssistantPluginInstallService> logger;
@ -32,17 +26,15 @@ public sealed class AssistantPluginInstallService
private static AssistantPluginInstallResult Error(string issue) => new(false, Guid.Empty, string.Empty, string.Empty, false, issue);
private static AssistantPluginInstallResult CancelledByUser() => new(false, Guid.Empty, string.Empty, string.Empty, false, string.Empty, true);
private static AssistantPluginCheckResult CheckError(string issue) => new(false, Guid.Empty, string.Empty, issue);
private static AssistantPluginDeleteResult DeleteError(IPluginMetadata plugin, string pluginDirectory, string issue) => new(false, plugin.Id, plugin.Name, pluginDirectory, issue);
private static AssistantPluginUpdateResult UpdateError(IPluginMetadata plugin, string pluginDirectory, string issue) => new(false, plugin.Id, plugin.Name, pluginDirectory, issue);
public AssistantPluginInstallService(
ILogger<AssistantPluginInstallService> logger,
SettingsManager settingsManager,
AssistantSessionService assistantSessionService,
MediaTranscriptionService mediaTranscriptionService)
public AssistantPluginInstallService(ILogger<AssistantPluginInstallService> logger, SettingsManager settingsManager, AssistantSessionService assistantSessionService, MediaTranscriptionService mediaTranscriptionService)
{
this.logger = logger;
this.settingsManager = settingsManager;
@ -127,76 +119,104 @@ public sealed class AssistantPluginInstallService
return Error(rootIssue);
await this.installSemaphore.WaitAsync(token);
AssistantPluginValidationResult validation;
try
{
validation = await this.ValidateIntoStagingAsync(lua, token);
var validation = await this.ValidateIntoStagingAsync(lua, token);
if (!validation.Success || validation.AssistantPlugin is null)
return Error(validation.Issue);
Directory.CreateDirectory(assistantPluginsRoot);
var stagingDirectory = validation.StagingDirectory;
var assistantPlugin = validation.AssistantPlugin;
string? backupDirectory = null;
string? finalDirectory = null;
var replacedExisting = false;
try
return await this.InstallStagedAssistantAsync(assistantPluginsRoot, validation, token);
}
finally
{
finalDirectory = DetermineFinalDirectory(assistantPluginsRoot, assistantPlugin);
if (!IsPathInsideDirectory(assistantPluginsRoot, finalDirectory))
return Error(TB("The resolved plugin directory is outside the assistant plugin directory."));
if (Directory.Exists(finalDirectory))
{
replacedExisting = true;
backupDirectory = Path.Join(assistantPluginsRoot, $".{Path.GetFileName(finalDirectory)}.backup-{Guid.NewGuid():N}");
Directory.Move(finalDirectory, backupDirectory);
this.installSemaphore.Release();
}
}
Directory.Move(stagingDirectory, finalDirectory);
if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory))
/// <summary>
/// Installs an assistant plugin archive that contains exactly one <c>plugin.lua</c> file.
/// Companion files are validated from and moved with the same staging directory.
/// </summary>
/// <param name="archivePath">The local <c>.mwplugin</c> or <c>.zip</c> archive path.</param>
/// <param name="confirmAsync">
/// Asks the user whether the validated archive may be installed. It is called after all checks
/// passed and before anything gets written. Returning false aborts the installation.
/// </param>
/// <param name="token">Cancellation token for extraction, validation, file IO, and plugin reload.</param>
/// <returns>Installation result that contains success state, installed plugin metadata, and a user-facing issue when installation failed.</returns>
public async Task<AssistantPluginInstallResult> InstallArchiveAsync(string archivePath, Func<PluginImportPreview, Task<bool>> confirmAsync, CancellationToken token)
{
if (!this.settingsManager.ConfigurationData.App.AllowUserToImportPlugins)
return Error(TB("Your organization has disabled importing plugins."));
if (!FileTypes.IsAllowedPath(archivePath, FileTypes.PLUGIN_ARCHIVE))
return Error(TB("Please select a plugin archive with the extension .mwplugin or .zip."));
if (!File.Exists(archivePath))
return Error(TB("The selected plugin archive does not exist."));
if (!TryGetAssistantPluginsRoot(out var assistantPluginsRoot, out var rootIssue))
return Error(rootIssue);
if (!PluginFactory.IsInitialized)
return Error(TB("The plugin system is not initialized yet."));
await this.installSemaphore.WaitAsync(token);
var stagingDirectory = Path.Join(Path.GetTempPath(), $"assistant-plugin-import.staging-{Guid.NewGuid():N}");
try
{
try
{
Directory.Delete(backupDirectory, true);
}
catch (Exception e)
{
this.logger.LogError(e, $"Failed to delete assistant plugin backup directory '{backupDirectory}'.");
}
}
token.ThrowIfCancellationRequested();
PluginArchive.Extract(archivePath, stagingDirectory);
await PluginFactory.LoadAll(token);
this.logger.LogInformation($"Installed assistant plugin '{assistantPlugin.Name}' ({assistantPlugin.Id}) to '{finalDirectory}'.");
return new(true, assistantPlugin.Id, assistantPlugin.Name, finalDirectory, replacedExisting, string.Empty);
}
catch (Exception e)
{
this.logger.LogError(e, "Failed to install assistant plugin.");
var pluginFiles = Directory.EnumerateFiles(stagingDirectory, PLUGIN_FILE_NAME, SearchOption.AllDirectories).ToArray();
if (pluginFiles.Length != 1)
return Error(TB("The plugin archive must contain exactly one plugin.lua file."));
if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory) && !string.IsNullOrWhiteSpace(finalDirectory) && !Directory.Exists(finalDirectory))
{
try
{
Directory.Move(backupDirectory, finalDirectory);
}
catch (Exception restoreException)
{
this.logger.LogError(restoreException, "Failed to restore the previous assistant plugin after a failed installation.");
}
}
var pluginFile = pluginFiles[0];
var pluginDirectory = Path.GetDirectoryName(pluginFile)!;
var pluginCode = await File.ReadAllTextAsync(pluginFile, Encoding.UTF8, token);
var validation = await ValidateAssistantPluginCodeAsync(
pluginDirectory,
pluginCode.Trim(),
TB("Currently, only assistant plugins can be imported."),
TB("The imported assistant plugin is invalid. Issue: {0}"),
TB("The imported assistant plugin uses the ID of another installed plugin."),
token);
if (!validation.Success || validation.AssistantPlugin is null)
return Error(validation.Issue);
// A plugin the user imports by hand never comes from a config server. We reject such
// archives because AI Studio trusts this self-declared flag: an imported plugin
// claiming it would be neither replaceable nor deletable through the user interface:
if (validation.AssistantPlugin.IsManagedByConfigServer)
return Error(TB("This plugin archive declares itself as managed by a config server. Only the IT department of your organization might deploy such plugins."));
// The archive would replace an existing plugin: reject it when that plugin belongs
// to the IT department. We check this before asking the user, so that the
// confirmation never offers something we would refuse afterwards anyway:
var replacementIssue = GetAssistantReplacementIssue(validation.AssistantPlugin.Id);
if (!string.IsNullOrEmpty(replacementIssue))
return Error(replacementIssue);
// Everything is validated, but nothing was written yet. This is the point where the
// user decides, because the plugin code comes from an untrusted source:
if (!await confirmAsync(CreateImportPreview(validation.AssistantPlugin)))
return CancelledByUser();
return await this.InstallStagedAssistantAsync(assistantPluginsRoot, validation with { StagingDirectory = pluginDirectory }, token);
}
catch (Exception e) when (e is not OperationCanceledException)
{
this.logger.LogError(e, "Failed to extract or validate assistant plugin archive '{ArchivePath}'.", archivePath);
return Error(string.Format(TB("Unexpected error: {0}"), e.Message));
}
}
finally
{
this.TryDeleteStagingDirectory(stagingDirectory);
}
}
finally
{
this.installSemaphore.Release();
}
}
@ -289,9 +309,10 @@ public sealed class AssistantPluginInstallService
Directory.Move(pluginDirectory, backupDirectory);
wasEnabled = this.settingsManager.ConfigurationData.EnabledPlugins.Remove(plugin.Id);
removedAudits = this.settingsManager.ConfigurationData.AssistantPluginAudits
.Where(audit => audit.PluginId == plugin.Id)
.ToList();
removedAudits =
[
.. this.settingsManager.ConfigurationData.AssistantPluginAudits.Where(audit => audit.PluginId == plugin.Id)
];
if (removedAudits.Count > 0)
this.settingsManager.ConfigurationData.AssistantPluginAudits.RemoveAll(audit => audit.PluginId == plugin.Id);
@ -412,6 +433,79 @@ public sealed class AssistantPluginInstallService
}
}
private async Task<AssistantPluginInstallResult> InstallStagedAssistantAsync(string assistantPluginsRoot, AssistantPluginValidationResult validation, CancellationToken token)
{
var stagingDirectory = validation.StagingDirectory;
var assistantPlugin = validation.AssistantPlugin!;
string? backupDirectory = null;
string? finalDirectory = null;
var replacedExisting = false;
var movedIntoPlace = false;
try
{
Directory.CreateDirectory(assistantPluginsRoot);
finalDirectory = DetermineFinalDirectory(assistantPluginsRoot, assistantPlugin);
if (!IsPathInsideDirectory(assistantPluginsRoot, finalDirectory))
return Error(TB("The resolved plugin directory is outside the assistant plugin directory."));
var replacementIssue = GetAssistantReplacementIssue(assistantPlugin.Id);
if (!string.IsNullOrWhiteSpace(replacementIssue))
return Error(replacementIssue);
if (Directory.Exists(finalDirectory))
{
replacedExisting = true;
// The backup goes to a directory outside the plugin root, so the plugin loader
// cannot discover it during the reload below. Otherwise, the previous version
// would be loaded a second time, next to the version we are installing:
backupDirectory = CreateInstallBackupDirectory(assistantPlugin);
Directory.CreateDirectory(Path.GetDirectoryName(backupDirectory)!);
Directory.Move(finalDirectory, backupDirectory);
}
Directory.Move(stagingDirectory, finalDirectory);
movedIntoPlace = true;
await PluginFactory.LoadAll(token);
if (!string.IsNullOrWhiteSpace(backupDirectory))
TryDeleteDirectory(backupDirectory, "assistant plugin backup", this.logger);
this.logger.LogInformation("Installed assistant plugin '{PluginName}' ({PluginId}) to '{PluginDirectory}'.", assistantPlugin.Name, assistantPlugin.Id, finalDirectory);
return new(true, assistantPlugin.Id, assistantPlugin.Name, finalDirectory, replacedExisting, string.Empty);
}
catch (Exception e)
{
this.logger.LogError(e, "Failed to install assistant plugin.");
// Only remove the target directory when this installation actually moved the plugin
// there. Otherwise, when moving the previous plugin into the backup directory failed,
// we would delete the still intact previous plugin:
if (movedIntoPlace && !string.IsNullOrWhiteSpace(finalDirectory) && Directory.Exists(finalDirectory))
TryDeleteDirectory(finalDirectory, "failed assistant plugin installation", this.logger);
if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory) && !string.IsNullOrWhiteSpace(finalDirectory) && !Directory.Exists(finalDirectory))
{
try
{
Directory.Move(backupDirectory, finalDirectory);
await PluginFactory.LoadAll(CancellationToken.None);
}
catch (Exception restoreException)
{
this.logger.LogError(restoreException, "Failed to restore the previous assistant plugin after a failed installation.");
}
}
return Error(string.Format(TB("Unexpected error: {0}"), e.Message));
}
finally
{
this.TryDeleteStagingDirectory(stagingDirectory);
}
}
private async Task<AssistantPluginValidationResult> ValidateIntoStagingAsync(string lua, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(lua))
@ -429,12 +523,12 @@ public sealed class AssistantPluginInstallService
var stagedPluginFile = Path.Join(stagingDirectory, PLUGIN_FILE_NAME);
await File.WriteAllTextAsync(stagedPluginFile, pluginCode, Encoding.UTF8, token);
var validation = await this.ValidateAssistantPluginCodeAsync(
var validation = await ValidateAssistantPluginCodeAsync(
stagingDirectory,
pluginCode,
TB("The generated plugin is not an assistant plugin. Issue: {0}"),
TB("The generated assistant plugin is invalid. Issue: {0}"),
TB("The generated assistant plugin uses the ID of an internal AI Studio plugin."),
TB("The generated assistant plugin uses the ID of another installed plugin."),
token);
if (!validation.Success || validation.AssistantPlugin is null)
@ -460,12 +554,12 @@ public sealed class AssistantPluginInstallService
try
{
return await this.ValidateAssistantPluginCodeAsync(
return await ValidateAssistantPluginCodeAsync(
pluginDirectory,
lua.Trim(),
TB("The edited plugin is not an assistant plugin. Issue: {0}"),
TB("The edited assistant plugin is invalid. Issue: {0}"),
TB("The edited assistant plugin uses the ID of an internal AI Studio plugin."),
TB("The edited assistant plugin uses the ID of another installed plugin."),
token);
}
catch (Exception e)
@ -475,23 +569,25 @@ public sealed class AssistantPluginInstallService
}
}
private async Task<AssistantPluginValidationResult> ValidateAssistantPluginCodeAsync(
string pluginDirectory,
string pluginCode,
string notAssistantIssue,
string invalidAssistantIssue,
string internalPluginIdIssue,
CancellationToken token)
private static async Task<AssistantPluginValidationResult> ValidateAssistantPluginCodeAsync(string pluginDirectory, string pluginCode,
string notAssistantIssue, string invalidAssistantIssue, string conflictingPluginIdIssue, CancellationToken token)
{
var plugin = await PluginFactory.Load(pluginDirectory, pluginCode, token);
// The plugin is not installed yet: it sits in a staging directory outside the installed
// plugins directory. We allow that directory as the module base, so the plugin can load its
// own Lua modules, e.g., an icon.lua, while we validate it:
var plugin = await PluginFactory.Load(pluginDirectory, pluginCode, token, pluginDirectory);
if (plugin is not PluginAssistants assistantPlugin)
return AssistantPluginValidationResult.Failure(string.Format(notAssistantIssue, string.Join("; ", plugin.Issues)));
if (!assistantPlugin.IsValid)
return AssistantPluginValidationResult.Failure(string.Format(invalidAssistantIssue, string.Join("; ", assistantPlugin.Issues)));
if (PluginFactory.AvailablePlugins.Any(availablePlugin => availablePlugin.Type is PluginType.ASSISTANT && availablePlugin.Id == assistantPlugin.Id && availablePlugin.IsInternal))
return AssistantPluginValidationResult.Failure(internalPluginIdIssue);
// Plugin IDs must be unique across all plugin types: several lookups resolve a plugin by its
// ID alone, e.g., the base language plugin in PluginFactory.Starting. An assistant plugin
// carrying the ID of a language or configuration plugin would break those lookups. Reusing
// the ID of another local assistant plugin stays allowed: that is how updating one works.
if (PluginFactory.AvailablePlugins.Any(availablePlugin => availablePlugin.Id == assistantPlugin.Id && (availablePlugin.IsInternal || availablePlugin.Type is not PluginType.ASSISTANT)))
return AssistantPluginValidationResult.Failure(conflictingPluginIdIssue);
return new(true, string.Empty, assistantPlugin, string.Empty);
}
@ -547,22 +643,63 @@ public sealed class AssistantPluginInstallService
: TB("The assistant plugin directory does not exist.");
}
private void TryDeleteStagingDirectory(string stagingDirectory)
{
TryDeleteDirectory(stagingDirectory, "assistant plugin staging", this.logger);
}
private void TryDeleteStagingDirectory(string stagingDirectory) => TryDeleteDirectory(stagingDirectory, "assistant plugin staging", this.logger);
private static string DetermineFinalDirectory(string assistantPluginsRoot, PluginAssistants assistantPlugin)
{
var existingPlugin = PluginFactory.AvailablePlugins
.OfType<IAvailablePlugin>()
.FirstOrDefault(plugin => plugin.Type is PluginType.ASSISTANT && plugin.Id == assistantPlugin.Id && !plugin.IsInternal);
var existingPlugin = FindReplaceableAssistantPlugin(assistantPlugin.Id);
return existingPlugin is not null
? existingPlugin.LocalPath
: Path.Join(assistantPluginsRoot, CreatePluginDirectoryName(assistantPlugin));
}
/// <summary>
/// Finds the local assistant plugin that an installation with the given ID would replace.
/// </summary>
/// <param name="pluginId">The ID of the assistant plugin about to be installed.</param>
/// <returns>The plugin that would be replaced, or null when the installation adds a new plugin.</returns>
private static IAvailablePlugin? FindReplaceableAssistantPlugin(Guid pluginId) => PluginFactory.AvailablePlugins
.OfType<IAvailablePlugin>()
.FirstOrDefault(plugin => plugin.Type is PluginType.ASSISTANT && plugin.Id == pluginId && !plugin.IsInternal);
/// <summary>
/// Collects the metadata an archive declares about itself, together with the information about
/// the installed plugin it would replace.
/// </summary>
/// <param name="assistantPlugin">The validated assistant plugin from the archive.</param>
/// <returns>The preview shown to the user before the installation starts.</returns>
private static PluginImportPreview CreateImportPreview(PluginAssistants assistantPlugin) => new(assistantPlugin, FindReplaceableAssistantPlugin(assistantPlugin.Id));
/// <summary>
/// Checks whether an installation may replace the assistant plugin that currently uses the given ID.
/// Plugins deployed by a Config Server belong to the organization's IT, so neither an import nor
/// the Assistant Builder may overwrite them.
/// </summary>
/// <param name="pluginId">The ID of the assistant plugin about to be installed.</param>
/// <returns>A user-facing issue when the existing plugin must not be replaced, an empty string otherwise.</returns>
private static string GetAssistantReplacementIssue(Guid pluginId)
{
var existingPlugin = FindReplaceableAssistantPlugin(pluginId);
if (existingPlugin is null)
return string.Empty;
if (existingPlugin.IsManagedByConfigServer)
return TB("Config server managed assistant plugins cannot be replaced.");
if (string.IsNullOrWhiteSpace(existingPlugin.LocalPath))
return string.Empty;
// The metadata above and the running plugin read the same Lua field. We check both, though,
// just like the deletion path does:
var runningPlugin = PluginFactory.RunningPlugins
.OfType<PluginAssistants>()
.FirstOrDefault(candidate => candidate.Id == pluginId && IsSameDirectory(candidate.PluginPath, existingPlugin.LocalPath));
return runningPlugin?.IsManagedByConfigServer is true
? TB("Config server managed assistant plugins cannot be replaced.")
: string.Empty;
}
private static string CreatePluginDirectoryName(PluginAssistants assistantPlugin)
{
var safeName = CreateSafeDirectoryNamePart(assistantPlugin.Name);
@ -628,6 +765,12 @@ public sealed class AssistantPluginInstallService
return Path.Join(backupRoot, $"assistant-{plugin.Id:N}-{Guid.NewGuid():N}");
}
private static string CreateInstallBackupDirectory(IPluginMetadata plugin)
{
var backupRoot = Path.Join(SettingsManager.DataDirectory, INSTALL_BACKUP_DIRECTORY);
return Path.Join(backupRoot, $"assistant-{plugin.Id:N}-{Guid.NewGuid():N}");
}
private async Task TryRestoreDeletedAssistantPluginAsync(IAvailablePlugin plugin, string pluginDirectory, string backupDirectory, bool wasEnabled, List<PluginAssistantAudit> removedAudits, CancellationToken token)
{
try

View File

@ -0,0 +1,3 @@
namespace AIStudio.Tools.Services;
public sealed record AssistantPluginUpdateResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, string Issue);

View File

@ -0,0 +1,6 @@
namespace AIStudio.Tools.Services;
public sealed class NativeShareService(RustService rustService)
{
public Task<bool> Share(string filePath) => rustService.ShareFile(filePath);
}

View File

@ -0,0 +1,17 @@
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tools.Services;
/// <summary>
/// What the user gets to see before a plugin archive is installed. It is not bound to a specific
/// plugin type, so it also serves upcoming import paths for other plugin types.
/// </summary>
/// <param name="Plugin">The plugin from the archive, with the metadata it declares about itself.</param>
/// <param name="ExistingPlugin">The installed plugin that gets replaced or null when the archive adds a new plugin.</param>
public sealed record PluginImportPreview(IPluginMetadata Plugin, IAvailablePlugin? ExistingPlugin)
{
/// <summary>
/// True when an installed plugin with the same ID gets replaced.
/// </summary>
public bool ReplacesExisting => this.ExistingPlugin is not null;
}

View File

@ -0,0 +1,3 @@
namespace AIStudio.Tools.Services;
public sealed record PluginShareResult(bool Success, string PluginName, string ArchivePath, string Issue, bool Cancelled = false);

View File

@ -0,0 +1,242 @@
using System.IO.Compression;
using AIStudio.Settings;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust;
namespace AIStudio.Tools.Services;
public sealed class PluginShareService(NativeShareService nativeShareService, RustService rustService, SettingsManager settingsManager, ILogger<PluginShareService> logger)
{
private static PluginShareResult ShareError(IAvailablePlugin plugin, string issue) => new(false, plugin.Name, string.Empty, issue);
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginShareService).Namespace, nameof(PluginShareService));
private const string PLUGIN_FILE_NAME = "plugin.lua";
/// <remarks>
/// Keep in sync with SHARE_DIRECTORY_NAME in runtime/src/share_sheet.rs: the runtime only hands
/// archives from a directory with this name to the native share sheet.
/// </remarks>
private const string TEMPORARY_ARCHIVE_DIRECTORY = "mindwork-ai-studio-plugin-shares";
private const int TEMPORARY_ARCHIVE_RETENTION_HOURS = 24;
private const int FILE_NAME_PREFIX_MAX_LEN = 80;
/// <summary>
/// Creates a shareable plugin archive from a local plugin and hands it over to the user.
/// The archive contains the plugin root contents, so <c>plugin.lua</c> is located at the archive root.
/// </summary>
/// <remarks>
/// On Windows and macOS, the archive is created in a temporary directory and handed over to the
/// native share sheet. Linux has no such share sheet, since the XDG desktop portals do not provide
/// a share interface. Thus, the archive is exported to a location of the user's choice there.
/// </remarks>
/// <param name="plugin">The local plugin to archive and share.</param>
/// <param name="token">Cancellation token for archive creation.</param>
/// <returns>The share result, including the archive path when successful.</returns>
public async Task<PluginShareResult> ShareAsync(IAvailablePlugin plugin, CancellationToken token)
{
if (plugin.IsInternal)
return ShareError(plugin, TB("Internal plugins cannot be shared."));
if (plugin.IsManagedByConfigServer)
return ShareError(plugin, TB("Config Server managed plugins cannot be shared."));
if (!settingsManager.ConfigurationData.App.AllowUserToSharePlugins)
return ShareError(plugin, TB("Your organization has disabled sharing plugins."));
if (!TryGetPluginRoot(plugin, out var pluginRoot, out var issue))
return ShareError(plugin, issue);
if (OperatingSystem.IsLinux())
return await this.ExportAsync(plugin, pluginRoot, token);
return await this.ShareViaNativeSheetAsync(plugin, pluginRoot, token);
}
/// <summary>
/// Asks the user for a target location and writes the plugin archive to it.
/// </summary>
/// <param name="plugin">The local plugin to archive.</param>
/// <param name="pluginRoot">The validated plugin root directory.</param>
/// <param name="token">Cancellation token for archive creation.</param>
/// <returns>The share result, including the chosen archive path when successful.</returns>
private async Task<PluginShareResult> ExportAsync(IAvailablePlugin plugin, string pluginRoot, CancellationToken token)
{
var suggestedFileName = $"{CreateSafeFileNamePrefix(plugin.Name)}{PluginArchive.PLUGIN_FILE_EXTENSION}";
var saveResponse = await rustService.SaveFile(TB("Export plugin archive"), [FileTypes.PLUGIN_ARCHIVE], suggestedFileName);
if (saveResponse.UserCancelled)
return new(false, plugin.Name, string.Empty, string.Empty, true);
var archivePath = saveResponse.SaveFilePath;
try
{
token.ThrowIfCancellationRequested();
await Task.Run(() =>
{
token.ThrowIfCancellationRequested();
// The save dialog already asked the user about overwriting an existing file.
// ZipFile.CreateFromDirectory would fail on an existing file, though:
if (File.Exists(archivePath))
File.Delete(archivePath);
ZipFile.CreateFromDirectory(pluginRoot, archivePath, CompressionLevel.Optimal, false);
}, token);
logger.LogInformation("Exported plugin '{PluginName}' ({PluginId}) to the archive '{ArchivePath}'.", plugin.Name, plugin.Id, archivePath);
return new(true, plugin.Name, archivePath, string.Empty);
}
catch (OperationCanceledException)
{
this.TryDeleteArchive(archivePath);
throw;
}
catch (Exception exception)
{
this.TryDeleteArchive(archivePath);
logger.LogError(exception, "Failed to export plugin '{PluginName}' ({PluginId}).", plugin.Name, plugin.Id);
return ShareError(plugin, string.Format(TB("Unexpected error: {0}"), exception.Message));
}
}
/// <summary>
/// Creates the plugin archive in a temporary directory and opens the native share sheet for it.
/// </summary>
/// <param name="plugin">The local plugin to archive and share.</param>
/// <param name="pluginRoot">The validated plugin root directory.</param>
/// <param name="token">Cancellation token for archive creation.</param>
/// <returns>The share result, including the retained temporary archive path when successful.</returns>
private async Task<PluginShareResult> ShareViaNativeSheetAsync(IAvailablePlugin plugin, string pluginRoot, CancellationToken token)
{
var archiveDirectory = Path.Join(Path.GetTempPath(), TEMPORARY_ARCHIVE_DIRECTORY);
var archivePath = Path.Join(archiveDirectory, $"{CreateSafeFileNamePrefix(plugin.Name)}-{plugin.Id:N}-{Guid.NewGuid():N}{PluginArchive.PLUGIN_FILE_EXTENSION}");
try
{
token.ThrowIfCancellationRequested();
Directory.CreateDirectory(archiveDirectory);
this.CleanUpExpiredArchives(archiveDirectory);
await Task.Run(() =>
{
token.ThrowIfCancellationRequested();
ZipFile.CreateFromDirectory(pluginRoot, archivePath, CompressionLevel.Optimal, false);
}, token);
token.ThrowIfCancellationRequested();
if (!await nativeShareService.Share(archivePath))
{
this.TryDeleteArchive(archivePath);
return ShareError(plugin, TB("The native share dialog could not be opened."));
}
logger.LogInformation("Created plugin archive '{ArchivePath}' for plugin '{PluginName}' ({PluginId}).", archivePath, plugin.Name, plugin.Id);
return new(true, plugin.Name, archivePath, string.Empty);
}
catch (OperationCanceledException)
{
this.TryDeleteArchive(archivePath);
throw;
}
catch (Exception exception)
{
this.TryDeleteArchive(archivePath);
logger.LogError(exception, "Failed to create a share archive for plugin '{PluginName}' ({PluginId}).", plugin.Name, plugin.Id);
return ShareError(plugin, string.Format(TB("Unexpected error: {0}"), exception.Message));
}
}
private static bool TryGetPluginRoot(IAvailablePlugin plugin, out string pluginRoot, out string issue)
{
pluginRoot = string.Empty;
issue = string.Empty;
if (string.IsNullOrWhiteSpace(plugin.LocalPath))
{
issue = TB("The plugin has no local directory.");
return false;
}
try
{
pluginRoot = Path.GetFullPath(plugin.LocalPath);
}
catch (Exception exception)
{
issue = string.Format(TB("The plugin directory is invalid: {0}"), exception.Message);
return false;
}
if (!Directory.Exists(pluginRoot))
{
issue = TB("The plugin directory does not exist.");
return false;
}
var pluginFile = Path.Join(pluginRoot, PLUGIN_FILE_NAME);
if (!IsPathInsideDirectory(pluginRoot, pluginFile) || !File.Exists(pluginFile))
{
issue = TB("The plugin directory does not contain a plugin.lua file.");
return false;
}
return true;
}
private void CleanUpExpiredArchives(string archiveDirectory)
{
var expiry = DateTime.UtcNow.AddHours(-TEMPORARY_ARCHIVE_RETENTION_HOURS);
foreach (var archivePath in Directory.EnumerateFiles(archiveDirectory, $"*{PluginArchive.PLUGIN_FILE_EXTENSION}", SearchOption.TopDirectoryOnly))
{
try
{
if (File.GetLastWriteTimeUtc(archivePath) < expiry)
File.Delete(archivePath);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to delete expired plugin archive '{ArchivePath}'.", archivePath);
}
}
}
private static string CreateSafeFileNamePrefix(string pluginName)
{
var invalidCharacters = Path.GetInvalidFileNameChars().ToHashSet();
var fileName = new string(pluginName
.Trim()
.Select(character => char.IsLetterOrDigit(character) || ((character is '-' or '_' or '.') && !invalidCharacters.Contains(character))
? character
: '-')
.ToArray())
.Trim('-', '.');
if (fileName.Length > FILE_NAME_PREFIX_MAX_LEN)
fileName = fileName[..FILE_NAME_PREFIX_MAX_LEN].Trim('-', '.');
return string.IsNullOrWhiteSpace(fileName) ? "plugin" : fileName;
}
private static bool IsPathInsideDirectory(string parentDirectory, string path)
{
var parentPath = Path.GetFullPath(parentDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
var childPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
return childPath.StartsWith(parentPath, StringComparison.OrdinalIgnoreCase);
}
private void TryDeleteArchive(string archivePath)
{
if (!File.Exists(archivePath))
return;
try
{
File.Delete(archivePath);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to delete temporary plugin archive '{ArchivePath}'.", archivePath);
}
}
}

View File

@ -0,0 +1,44 @@
// ReSharper disable NotAccessedPositionalProperty.Local
namespace AIStudio.Tools.Services;
public sealed partial class RustService
{
public async Task<bool> ShareFile(string filePath)
{
try
{
using var response = await this.http.PostAsJsonAsync("/share/file", new ShareFileRequest(filePath), this.jsonRustSerializerOptions);
if (!response.IsSuccessStatusCode)
{
this.logger?.LogError($"The Rust runtime rejected the share request: {response.StatusCode}.");
return false;
}
var result = await response.Content.ReadFromJsonAsync<ShareFileResponse>(this.jsonRustSerializerOptions);
if (result?.Success == true)
return true;
this.logger?.LogError($"The native share sheet could not be opened: {result?.Issue ?? "Unknown error"}");
return false;
}
catch (HttpRequestException exception)
{
this.logger?.LogWarning(exception, "Failed to reach the Rust runtime share endpoint.");
return false;
}
catch (TaskCanceledException exception)
{
this.logger?.LogWarning(exception, "Timed out while reaching the Rust runtime share endpoint.");
return false;
}
catch (Exception exception)
{
this.logger?.LogError(exception, "Failed to process the Rust runtime share response.");
return false;
}
}
private sealed record ShareFileRequest(string FilePath);
private sealed record ShareFileResponse(bool Success, string Issue);
}

View File

@ -1,4 +1,10 @@
# v26.8.1, build 251 (2026-08-xx xx:xx UTC)
- Added a prototype Visual Briefing Assistant that turns documents, data, images, audio, and video into self-contained interactive HTML briefings.
- Added organization-configurable defaults and visibility controls for the Visual Briefing Assistant.
- Added a share button for plugins, which uses the native share dialog on Windows and macOS. For Linux, we added an export option for plugins, which stores the plugin archive at a location of your choice.
- Added an import button on the plugin page to install plugin archives directly from your files.
- Added the option to import plugins by dropping a plugin archive onto the plugin page.
- Added the dedicated file extension `.mwplugin` for plugin archives.
- Added an option for organizations to disable importing, sharing, and exporting plugins.
- Fixed dropping files after you closed a dialog that accepts files itself. Such a dialog takes over dropped files while it is open, but never handed that role back when you closed it. Afterwards, the chat and the assistants silently ignored dropped files until you switched to another page. Each time you opened such a dialog again, the problem got worse.
- Upgraded dependencies to their latest versions to improve security and stability.

View File

@ -1,10 +1,12 @@
# Enterprise Configuration ZIP Backslashes
# Plugin Archive ZIP Backslashes
- Status: Active
- Introduced: 2026-07-09
- Remove after: when Microsoft fixes dotnet/runtime#27620 and dotnet/runtime#41914
- Code references:
- `app/MindWork AI Studio/Tools/PluginSystem/PluginArchive.cs`
- `app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Download.cs`
- `app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs`
## User Impact
@ -12,9 +14,11 @@ Some enterprise administrators create configuration plugin ZIP files on Windows.
Without this shim, Unix systems extract those entries as files whose names contain literal backslash characters. The plugin loader then cannot find `plugin.lua`, so the enterprise configuration plugin is not activated.
The same applies to plugin archives that users import from their disk. Archives created by AI Studio itself always use forward slashes. However, users may import archives that were packaged by hand or with another tool on Windows, so the import path needs the same tolerance. Otherwise, importing such an archive fails because `plugin.lua` cannot be found.
## Compatibility Behavior
AI Studio manually extracts downloaded enterprise configuration plugin ZIP files. During extraction, entry names are normalized so both `/` and `\` are treated as archive path separators.
AI Studio manually extracts enterprise configuration plugin archives and plugin archives imported by users. During extraction, entry names are normalized so both `/` and `\` are treated as archive path separators.
The extraction still preserves the archive structure and validates each entry before writing it to disk. Rooted paths, drive-qualified paths, and parent-directory traversal paths are rejected.
@ -23,6 +27,5 @@ This works around the behavior described in dotnet/runtime#27620. A related upst
## Removal Checklist
- Confirm supported .NET runtimes and administrator packaging guidance no longer require accepting backslashes in enterprise ZIP entry names.
- Replace the manual enterprise configuration plugin ZIP extraction with `ZipFile.ExtractToDirectory(...)`.
- Remove `ExtractConfigPluginArchive(...)`, `NormalizeConfigPluginZipEntryName(...)`, and `GetConfigPluginZipEntryDestinationPath(...)`.
- Replace `PluginArchive.Extract(...)` with `ZipFile.ExtractToDirectory(...)`.
- Update this document's status to `Removed`.

View File

@ -23,7 +23,7 @@ Every compatibility shim must have:
- Status: Active
- Introduced: YYYY-MM-DD
- Remove after: YYYY-MM-DD
- Remove after: YYYY-MM-DD or a condition e.g., someone is solving an issue on a dependency
- Code references:
- path/to/file.cs

69
runtime/Cargo.lock generated
View File

@ -4259,6 +4259,9 @@ dependencies = [
"image",
"keyring-core",
"log",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-foundation 0.3.2",
"once_cell",
"pbkdf2",
"pdfium-render",
@ -4292,6 +4295,8 @@ dependencies = [
"webkit2gtk",
"webm-iterable",
"whoami",
"windows 0.61.3",
"windows-collections 0.2.0",
"windows-native-keyring-store",
"windows-registry",
]
@ -4646,23 +4651,30 @@ dependencies = [
[[package]]
name = "objc2-app-kit"
version = "0.3.0"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5906f93257178e2f7ae069efb89fbd6ee94f0592740b5f8a1512ca498814d0fb"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.11.1",
"block2 0.6.2",
"libc",
"objc2 0.6.4",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "objc2-cloud-kit"
version = "0.3.0"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c1948a9be5f469deadbd6bcb86ad7ff9e47b4f632380139722f7d9840c0d42c"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.11.1",
"objc2 0.6.4",
@ -4671,10 +4683,11 @@ dependencies = [
[[package]]
name = "objc2-core-data"
version = "0.3.0"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f860f8e841f6d32f754836f51e6bc7777cd7e7053cf18528233f6811d3eceb4"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.11.1",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
]
@ -4692,11 +4705,12 @@ dependencies = [
[[package]]
name = "objc2-core-graphics"
version = "0.3.0"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dca602628b65356b6513290a21a6405b4d4027b8b250f0b98dddbb28b7de02"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.11.1",
"dispatch2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-io-surface",
@ -4704,9 +4718,9 @@ dependencies = [
[[package]]
name = "objc2-core-image"
version = "0.3.0"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ffa6bea72bf42c78b0b34e89c0bafac877d5f80bf91e159a5d96ea7f693ca56"
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
dependencies = [
"objc2 0.6.4",
"objc2-foundation 0.3.2",
@ -4722,6 +4736,31 @@ dependencies = [
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.11.1",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.11.1",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@ -4774,9 +4813,9 @@ dependencies = [
[[package]]
name = "objc2-io-surface"
version = "0.3.0"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "161a8b87e32610086e1a7a9e9ec39f84459db7b3a0881c1f16ca5a2605581c19"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.11.1",
"objc2 0.6.4",
@ -4833,9 +4872,9 @@ dependencies = [
[[package]]
name = "objc2-quartz-core"
version = "0.3.0"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fb3794501bb1bee12f08dcad8c61f2a5875791ad1c6f47faa71a0f033f20071"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.11.1",
"objc2 0.6.4",
@ -4868,7 +4907,7 @@ dependencies = [
"objc2-core-image",
"objc2-core-location",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.0",
"objc2-quartz-core 0.3.2",
"objc2-user-notifications",
]

View File

@ -69,9 +69,14 @@ permutation_iterator = { git = "https://github.com/SommerEngineering/permutation
[target.'cfg(target_os = "windows")'.dependencies]
windows-registry = "0.6.1"
windows-native-keyring-store = "1.1.0"
windows = { version = "=0.61.3", features = ["ApplicationModel_DataTransfer", "Foundation", "Foundation_Collections", "Storage", "Storage_Streams", "Win32_Foundation", "Win32_System_WinRT", "Win32_UI_Shell"] }
windows-collections = "=0.2.0"
[target.'cfg(target_os = "macos")'.dependencies]
apple-native-keyring-store = { version = "1.0.0", features = ["keychain"] }
objc2 = "0.6.3"
objc2-app-kit = { version = "0.3.2", features = ["NSResponder", "NSSharingService", "NSView"] }
objc2-foundation = { version = "0.3.2", features = ["NSArray", "NSGeometry", "NSString", "NSURL"] }
[target.'cfg(target_os = "linux")'.dependencies]
ashpd = { version = "0.13.12", default-features = false, features = ["tokio", "open_uri", "global_shortcuts"] }

View File

@ -328,28 +328,11 @@ pub async fn open_path_in_file_manager(
});
}
let Some(target) = resolve_file_manager_target(&requested_path) else {
let issue = format!(
"The path does not exist and its parent folder could not be found: {}",
requested_path.to_string_lossy(),
);
error!(Source = "Tauri"; "{issue}");
return Json(OpenPathResponse {
success: false,
issue,
});
};
#[cfg(target_os = "linux")]
{
return match open_path_in_linux_file_manager(&target).await {
Ok(()) => {
info!("Opened file manager for path: {:?}", target.path);
Json(OpenPathResponse {
match open_file_manager_target(&requested_path).await {
Ok(()) => Json(OpenPathResponse {
success: true,
issue: String::new(),
})
}
}),
Err(issue) => {
error!(Source = "Tauri"; "{issue}");
@ -358,6 +341,27 @@ pub async fn open_path_in_file_manager(
issue,
})
}
}
}
async fn open_file_manager_target(requested_path: &Path) -> Result<(), String> {
let Some(target) = resolve_file_manager_target(requested_path) else {
let issue = format!(
"The path does not exist and its parent folder could not be found: {}",
requested_path.to_string_lossy(),
);
return Err(issue);
};
#[cfg(target_os = "linux")]
{
return match open_path_in_linux_file_manager(&target).await {
Ok(()) => {
info!("Opened file manager for path: {:?}", target.path);
Ok(())
}
Err(issue) => Err(issue),
};
}
@ -371,19 +375,12 @@ pub async fn open_path_in_file_manager(
match command.spawn() {
Ok(_) => {
info!("Opened file manager for path: {:?}", target.path);
Json(OpenPathResponse {
success: true,
issue: String::new(),
})
Ok(())
}
Err(error) => {
let issue = format!("Failed to open the file manager: {error}");
error!(Source = "Tauri"; "{issue}");
Json(OpenPathResponse {
success: false,
issue,
})
Err(issue)
}
}
}

View File

@ -19,6 +19,7 @@ pub mod qdrant_edge_database;
pub mod certificate_factory;
pub mod runtime_api_token;
pub mod stale_process_cleanup;
pub mod share_sheet;
mod sidecar_types;
mod file_actions;
pub mod global_shortcuts;

View File

@ -38,6 +38,7 @@ pub fn start_runtime_api() {
.route("/system/qdrant-edge/delete-file", post(crate::qdrant_edge_database::delete_qdrant_edge_embedding_by_file))
.route("/system/qdrant-edge/delete-store", post(crate::qdrant_edge_database::delete_qdrant_edge_store))
.route("/clipboard/set", post(crate::clipboard::set_clipboard))
.route("/share/file", post(crate::share_sheet::share_file))
.route("/events", get(crate::app_window::get_event_stream))
.route("/updates/check", get(crate::app_window::check_for_update))
.route("/updates/install", get(crate::app_window::install_update))

201
runtime/src/share_sheet.rs Normal file
View File

@ -0,0 +1,201 @@
use axum::Json;
use log::{error, info};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::api_token::APIToken;
/// The directory the app creates its shareable plugin archives in. Keep in sync with
/// PluginShareService.TEMPORARY_ARCHIVE_DIRECTORY on the .NET side.
const SHARE_DIRECTORY_NAME: &str = "mindwork-ai-studio-plugin-shares";
/// The file extension of plugin archives, without the leading dot. Keep in sync with
/// PluginShareService.PLUGIN_FILE_EXTENSION on the .NET side.
const SHARE_FILE_EXTENSION: &str = "mwplugin";
#[derive(Deserialize)]
pub struct ShareFileRequest {
file_path: String,
}
#[derive(Serialize)]
pub struct ShareFileResponse {
success: bool,
issue: String,
}
pub async fn share_file(_token: APIToken, Json(request): Json<ShareFileRequest>) -> Json<ShareFileResponse> {
let path = PathBuf::from(request.file_path.trim());
if path.as_os_str().is_empty() {
return failure("The file path is empty.");
}
if !path.is_file() {
return failure(format!("The requested path is not an existing file: {}", path.to_string_lossy()));
}
// Resolve the path before validating it, so a symlink with a matching name cannot point at an
// arbitrary file. We share the original path afterwards, though: on Windows, canonicalize
// returns a \\?\ path, which the WinRT storage APIs do not accept.
let resolved_path = match std::fs::canonicalize(&path) {
Ok(resolved_path) => resolved_path,
Err(error) => return failure(format!("The requested path could not be resolved: {error}")),
};
if !is_shareable_archive(&resolved_path) {
return failure(format!("The requested path is not a plugin archive created by AI Studio: {}", path.to_string_lossy()));
}
let result = share_file_on_platform(path).await;
match result {
Ok(()) => {
info!(Source = "Share sheet"; "Opened the native share UI.");
Json(ShareFileResponse {
success: true,
issue: String::new(),
})
}
Err(issue) => {
error!(Source = "Share sheet"; "{issue}");
failure(issue)
}
}
}
/// Checks that a path points to a plugin archive the app itself created for sharing. This keeps the
/// endpoint from handing arbitrary readable files to the operating system's share UI.
///
/// We match the directory by name instead of comparing it against the temporary directory: Rust and
/// .NET do not have to agree on where that is, and a mismatch would break sharing entirely.
fn is_shareable_archive(path: &Path) -> bool {
let has_archive_extension = path.extension().is_some_and(|extension| extension.eq_ignore_ascii_case(SHARE_FILE_EXTENSION));
if !has_archive_extension {
return false;
}
path.parent()
.and_then(|parent| parent.file_name())
.is_some_and(|directory_name| directory_name == SHARE_DIRECTORY_NAME)
}
fn failure(issue: impl Into<String>) -> Json<ShareFileResponse> {
Json(ShareFileResponse {
success: false,
issue: issue.into(),
})
}
// Linux has no native share sheet: the XDG desktop portals do not provide a share interface. The
// app exports the file through the save dialog instead, hence this endpoint is not used on Linux:
#[cfg(target_os = "linux")]
async fn share_file_on_platform(_path: PathBuf) -> Result<(), String> {
Err(String::from("The native share sheet is not available on Linux."))
}
#[cfg(windows)]
async fn share_file_on_platform(path: PathBuf) -> Result<(), String> {
use std::cell::RefCell;
use windows::ApplicationModel::DataTransfer::{DataRequestedEventArgs, DataTransferManager};
use windows::Foundation::{EventRegistrationToken, TypedEventHandler};
use windows::Storage::{IStorageItem, StorageFile};
use windows::Win32::UI::Shell::IDataTransferManagerInterop;
use windows::core::{factory, HSTRING, Interface};
use windows_collections::IIterable;
// The DataTransferManager belongs to the window, not to a single share. Registering a handler
// for every share would stack them up, and each stale handler keeps pointing at the archive of
// its own share, which gets cleaned up after a while. We therefore remember the registration
// and remove the previous handler before adding a new one. We only ever register on the main
// thread, hence a thread-local reference is sufficient:
thread_local! {
static DATA_REQUESTED_TOKEN: RefCell<Option<EventRegistrationToken>> = const { RefCell::new(None) };
}
let window = crate::app_window::MAIN_WINDOW.lock().unwrap().clone()
.ok_or_else(|| String::from("The main window is not available."))?;
let ui_window = window.clone();
let path = path.to_string_lossy().to_string();
let (sender, receiver) = tokio::sync::oneshot::channel();
window.run_on_main_thread(move || {
let result = (|| -> Result<(), String> {
let hwnd = ui_window.hwnd().map_err(|error| format!("Failed to get the native window handle: {error}"))?;
let interop: IDataTransferManagerInterop = factory::<DataTransferManager, IDataTransferManagerInterop>()
.map_err(|error| format!("Failed to access the Windows share service: {error}"))?;
let manager: DataTransferManager = unsafe { interop.GetForWindow(hwnd) }
.map_err(|error| format!("Failed to create the Windows share request: {error}"))?;
let handler = TypedEventHandler::<DataTransferManager, DataRequestedEventArgs>::new(move |_, arguments| {
let Some(arguments) = arguments.as_ref() else {
return Ok(());
};
let request = arguments.Request()?;
let file = StorageFile::GetFileFromPathAsync(&HSTRING::from(&path))?.get()?;
let file: IStorageItem = file.cast()?;
let items = IIterable::<IStorageItem>::from(vec![Some(file)]);
request.Data()?.Properties()?.SetTitle(&HSTRING::from("MindWork AI Studio"))?;
request.Data()?.SetStorageItemsReadOnly(&items)?;
Ok(())
});
if let Some(previous_token) = DATA_REQUESTED_TOKEN.with(|token| token.borrow_mut().take()) {
let _ = manager.RemoveDataRequested(previous_token);
}
let token = manager.DataRequested(&handler)
.map_err(|error| format!("Failed to provide the shared file: {error}"))?;
DATA_REQUESTED_TOKEN.with(|current| current.replace(Some(token)));
unsafe { interop.ShowShareUIForWindow(hwnd) }
.map_err(|error| format!("Failed to open the Windows share sheet: {error}"))?;
Ok(())
})();
let _ = sender.send(result);
}).map_err(|error| format!("Failed to schedule the Windows share sheet: {error}"))?;
receiver.await.map_err(|_| String::from("The Windows share sheet did not return a result."))?
}
#[cfg(target_os = "macos")]
async fn share_file_on_platform(path: PathBuf) -> Result<(), String> {
use std::cell::RefCell;
use objc2::rc::Retained;
use objc2::runtime::AnyObject;
use objc2::{AnyThread, MainThreadMarker};
use objc2_app_kit::{NSSharingServicePicker, NSView};
use objc2_foundation::{NSArray, NSRect, NSRectEdge, NSString, NSURL};
// AppKit does not retain the picker while its UI is shown. Without a strong reference of our
// own, the picker would be deallocated right after showRelativeToRect and the share sheet
// would close immediately. We create and replace the picker on the main thread only, hence a
// thread-local reference is sufficient:
thread_local! {
static CURRENT_PICKER: RefCell<Option<Retained<NSSharingServicePicker>>> = const { RefCell::new(None) };
}
let window = crate::app_window::MAIN_WINDOW.lock().unwrap().clone()
.ok_or_else(|| String::from("The main window is not available."))?;
let ui_window = window.clone();
let path = path.to_string_lossy().to_string();
let (sender, receiver) = tokio::sync::oneshot::channel();
window.run_on_main_thread(move || {
let result = (|| -> Result<(), String> {
// We create the NSView reference from a raw pointer below, which bypasses the
// main-thread guarantee of objc2. Thus, we assert the main thread ourselves:
let _mtm = MainThreadMarker::new().ok_or_else(|| String::from("The macOS share sheet must run on the main thread."))?;
let path = NSString::from_str(&path);
let url = NSURL::fileURLWithPath(&path);
let item: Retained<AnyObject> = Retained::into_super(Retained::into_super(url));
let items = NSArray::from_retained_slice(&[item]);
// Safety: the items are NSURL instances, which conform to NSPasteboardWriting.
let picker = unsafe { NSSharingServicePicker::initWithItems(NSSharingServicePicker::alloc(), &items) };
let view = unsafe { &*ui_window.ns_view().map_err(|error| format!("Failed to get the native view: {error}"))?.cast::<NSView>() };
picker.showRelativeToRect_ofView_preferredEdge(NSRect::ZERO, view, NSRectEdge::MinY);
CURRENT_PICKER.with(|current| current.replace(Some(picker)));
Ok(())
})();
let _ = sender.send(result);
}).map_err(|error| format!("Failed to schedule the macOS share sheet: {error}"))?;
receiver.await.map_err(|_| String::from("The macOS share sheet did not return a result."))?
}