Fixed plugin installation and endless plugin reloading on Linux (#924)
Some checks are pending
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
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-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (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

This commit is contained in:
Thorsten Sommer 2026-08-15 19:55:42 +02:00 committed by GitHub
parent 01f25b2bc0
commit 8148b66876
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 402 additions and 95 deletions

View File

@ -111,7 +111,7 @@ else
@T("The generated assistant could not be checked.")
@if (!string.IsNullOrWhiteSpace(this.installFlowIssue))
{
<span> @string.Format(T("Issue: {0}"), this.installFlowIssue)</span>
<span>&#32;@string.Format(T("Issue: {0}"), this.installFlowIssue)</span>
}
</MudAlert>
}
@ -143,7 +143,7 @@ else
@T("The assistant could not be installed.")
@if (!string.IsNullOrWhiteSpace(this.installFlowIssue))
{
<span> @string.Format(T("Issue: {0}"), this.installFlowIssue)</span>
<span>&#32;@string.Format(T("Issue: {0}"), this.installFlowIssue)</span>
}
</MudAlert>
}
@ -177,7 +177,7 @@ else
@T("The security audit could not be completed.")
@if (!string.IsNullOrWhiteSpace(this.installFlowIssue))
{
<span> @string.Format(T("Issue: {0}"), this.installFlowIssue)</span>
<span>&#32;@string.Format(T("Issue: {0}"), this.installFlowIssue)</span>
}
</MudAlert>
}
@ -209,7 +209,7 @@ else
@T("The assistant cannot be enabled.")
@if (!string.IsNullOrWhiteSpace(this.installFlowIssue))
{
<span> @string.Format(T("Issue: {0}"), this.installFlowIssue)</span>
<span>&#32;@string.Format(T("Issue: {0}"), this.installFlowIssue)</span>
}
</MudAlert>
}

View File

@ -334,10 +334,35 @@ public sealed class SettingsManager
}
var settingsJson = JsonSerializer.Serialize(settingsData, JSON_OPTIONS);
var tempFile = Path.GetTempFileName();
await File.WriteAllTextAsync(tempFile, settingsJson);
File.Move(tempFile, settingsPath, true);
//
// We write the new settings next to the previous ones and replace them afterwards, so that
// no crash can leave a half-written settings file behind. The temporary file has to live in
// the configuration directory for that: replacing a file is a rename, and a rename across a
// file system boundary falls back to copying, which is exactly what we want to avoid. The
// temporary directory of the operating system is such another file system under Flatpak.
//
var tempFile = $"{settingsPath}.tmp-{Guid.NewGuid():N}";
try
{
await File.WriteAllTextAsync(tempFile, settingsJson);
File.Move(tempFile, settingsPath, true);
}
catch
{
try
{
if (File.Exists(tempFile))
File.Delete(tempFile);
}
catch (Exception cleanupException)
{
this.logger.LogWarning(cleanupException, $"Failed to delete the temporary settings file '{tempFile}'.");
}
throw;
}
this.logger.LogInformation($"Stored the settings to '{settingsPath}'.");
}

View File

@ -301,12 +301,40 @@ public sealed class PluginAssistants(bool isInternal, LuaState state, PluginType
return fileMap.ToImmutable();
}
/// <summary>
/// The audit hash of this plugin, together with the directory it was computed for.
/// </summary>
/// <remarks>
/// One record instead of two fields, so that a reader always sees a directory and a hash which
/// belong together. Recomputing the same hash twice costs nothing but time, mixing up a hash
/// with the wrong directory would show a wrong security state.
/// </remarks>
private sealed record AuditHashCache(string PluginPath, string Hash);
private AuditHashCache? auditHashCache;
/// <summary>
/// Computes a stable audit hash across all Lua files by hashing a canonical
/// sequence of relative path length, relative path, content length, and content
/// for each file in ordinal path order.
/// </summary>
public string ComputeAuditHash() => AssistantPluginHash.Compute(this.PluginPath);
/// <remarks>
/// The result is kept, because computing it reads every Lua file of the plugin, and the plugins
/// page as well as the assistants page ask for it on every render. That is safe: the files of
/// one plugin instance never change. Whenever something in the plugins directory changes, the
/// plugin factory reloads and creates new instances, cf. PluginFactory.Starting.RestartAllPlugins.
/// The plugin directory is assigned after the instance was created, so the cache remembers which
/// directory it belongs to.
/// </remarks>
public string ComputeAuditHash()
{
if (this.auditHashCache is { } cache && string.Equals(cache.PluginPath, this.PluginPath, StringComparison.Ordinal))
return cache.Hash;
var hash = AssistantPluginHash.Compute(this.PluginPath);
this.auditHashCache = new(this.PluginPath, hash);
return hash;
}
private static string BuildSecureSystemPrompt(string pluginSystemPrompt)
{

View File

@ -1,9 +1,26 @@
using Timer = System.Timers.Timer;
namespace AIStudio.Tools.PluginSystem;
public static partial class PluginFactory
{
private static readonly SemaphoreSlim HOT_RELOAD_SEMAPHORE = new(1, 1);
/// <summary>
/// How long the plugins directory has to stay quiet before we reload.
/// </summary>
/// <remarks>
/// One change never arrives as one event: writing a single file produces several, and moving an
/// entire plugin directory into place produces dozens. Reloading on each of them would restart
/// every plugin over and over.
/// </remarks>
private static readonly TimeSpan HOT_RELOAD_DEBOUNCE_INTERVAL = TimeSpan.FromSeconds(1);
private static readonly Timer HOT_RELOAD_DEBOUNCE_TIMER = new(HOT_RELOAD_DEBOUNCE_INTERVAL)
{
AutoReset = false,
};
public static void SetUpHotReloading()
{
if (!IsInitialized)
@ -11,18 +28,26 @@ public static partial class PluginFactory
LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it.");
return;
}
LOG.LogInformation($"Start hot reloading plugins for path '{HOT_RELOAD_WATCHER.Path}'.");
try
{
HOT_RELOAD_DEBOUNCE_TIMER.Elapsed += (_, _) => _ = ReloadPluginsAsync();
HOT_RELOAD_WATCHER.IncludeSubdirectories = true;
HOT_RELOAD_WATCHER.NotifyFilter = NotifyFilters.CreationTime
| NotifyFilters.DirectoryName
//
// We watch for plugins appearing, disappearing, and changing. We do not watch access
// times: reading a plugin is not a change, and on Linux our own reads would be
// reported back to us. Loading the plugins and computing the audit hash of an
// assistant plugin both read every Lua file in this directory, so such a filter
// makes each reload cause the next one:
//
HOT_RELOAD_WATCHER.NotifyFilter = NotifyFilters.DirectoryName
| NotifyFilters.FileName
| NotifyFilters.LastAccess
| NotifyFilters.LastWrite
| NotifyFilters.Size;
HOT_RELOAD_WATCHER.Changed += HotReloadEventHandler;
HOT_RELOAD_WATCHER.Deleted += HotReloadEventHandler;
HOT_RELOAD_WATCHER.Created += HotReloadEventHandler;
@ -42,64 +67,96 @@ public static partial class PluginFactory
LOG.LogInformation("Hot reloading plugins set up.");
}
}
private static async void HotReloadEventHandler(object _, FileSystemEventArgs args)
private static void HotReloadEventHandler(object _, FileSystemEventArgs args)
{
try
{
var changeType = args.ChangeType.ToString().ToLowerInvariant();
if (!await HOT_RELOAD_SEMAPHORE.WaitAsync(0))
{
LOG.LogInformation($"File changed '{args.FullPath}' (event={changeType}). Already processing another change.");
//
// Our own lock file lives in the watched directory. Writing and removing it are not
// plugin changes, and reacting to them would turn every locked operation into a
// reload of its own:
//
if (IsHotReloadLockFile(args.FullPath))
return;
}
try
{
LOG.LogInformation($"File changed '{args.FullPath}' (event={changeType}). Reloading plugins...");
if (File.Exists(HOT_RELOAD_LOCK_FILE))
{
LOG.LogInformation("Hot reload lock file exists. Waiting for it to be released before proceeding with the reload.");
var changeType = args.ChangeType.ToString().ToLowerInvariant();
LOG.LogInformation($"File changed '{args.FullPath}' (event={changeType}). Scheduling a plugin reload.");
var lockFileCancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var token = lockFileCancellationTokenSource.Token;
var waitTime = TimeSpan.FromSeconds(1);
while (File.Exists(HOT_RELOAD_LOCK_FILE) && !token.IsCancellationRequested)
{
try
{
LOG.LogDebug("Waiting for hot reload lock to be released...");
await Task.Delay(waitTime, token);
waitTime = TimeSpan.FromSeconds(Math.Min(waitTime.TotalSeconds * 2, 120)); // Exponential backoff with a cap
}
catch (TaskCanceledException)
{
// Case: The cancellation token was triggered, meaning the lock file is still present.
// We expect that something goes wrong. So, we try to delete the lock file:
LOG.LogWarning("Hot reload lock file still exists after 30 seconds. Attempting to delete it...");
UnlockHotReload();
break;
}
}
LOG.LogInformation("Hot reload lock file released. Proceeding with plugin reload.");
}
await LoadAll();
await MessageBus.INSTANCE.SendMessage<bool>(null, Event.PLUGINS_RELOADED);
}
catch(Exception e)
{
LOG.LogError(e, $"Error while reloading plugins after change in file '{args.FullPath}' with change type '{changeType}'.");
}
finally
{
HOT_RELOAD_SEMAPHORE.Release();
}
// Restart the debounce window, so that a burst of events results in one reload:
HOT_RELOAD_DEBOUNCE_TIMER.Stop();
HOT_RELOAD_DEBOUNCE_TIMER.Start();
}
catch (Exception e)
{
LOG.LogError(e, $"Error while handling hot reload event for file '{args.FullPath}' with change type '{args.ChangeType}'.");
}
}
private static bool IsHotReloadLockFile(string path)
{
if (string.IsNullOrWhiteSpace(path) || string.IsNullOrWhiteSpace(HOT_RELOAD_LOCK_FILE))
return false;
return string.Equals(path, HOT_RELOAD_LOCK_FILE, StringComparison.OrdinalIgnoreCase);
}
private static async Task ReloadPluginsAsync()
{
//
// Reloads must never overlap. When one is still running, we do not drop this one: the
// changes which triggered it might have arrived after the running reload had already read
// them. We try again after another quiet window instead:
//
if (!await HOT_RELOAD_SEMAPHORE.WaitAsync(0))
{
LOG.LogInformation("A plugin reload is already running. Waiting for it to finish before reloading again.");
HOT_RELOAD_DEBOUNCE_TIMER.Stop();
HOT_RELOAD_DEBOUNCE_TIMER.Start();
return;
}
try
{
LOG.LogInformation("Reloading plugins...");
if (File.Exists(HOT_RELOAD_LOCK_FILE))
{
LOG.LogInformation("Hot reload lock file exists. Waiting for it to be released before proceeding with the reload.");
var lockFileCancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var token = lockFileCancellationTokenSource.Token;
var waitTime = TimeSpan.FromSeconds(1);
while (File.Exists(HOT_RELOAD_LOCK_FILE) && !token.IsCancellationRequested)
{
try
{
LOG.LogDebug("Waiting for hot reload lock to be released...");
await Task.Delay(waitTime, token);
waitTime = TimeSpan.FromSeconds(Math.Min(waitTime.TotalSeconds * 2, 120)); // Exponential backoff with a cap
}
catch (TaskCanceledException)
{
// Case: The cancellation token was triggered, meaning the lock file is still present.
// We expect that something goes wrong. So, we try to delete the lock file:
LOG.LogWarning("Hot reload lock file still exists after 30 seconds. Attempting to delete it...");
UnlockHotReload();
break;
}
}
LOG.LogInformation("Hot reload lock file released. Proceeding with plugin reload.");
}
// LoadAll announces the reload itself, cf. PluginFactory.Starting.RestartAllPlugins:
await LoadAll();
}
catch(Exception e)
{
LOG.LogError(e, "Error while reloading plugins after a change in the plugins directory.");
}
finally
{
HOT_RELOAD_SEMAPHORE.Release();
}
}
}

View File

@ -290,7 +290,25 @@ public static partial class PluginFactory
return AVAILABLE_PLUGINS.Any(plugin => plugin.Id == configPluginId && plugin.Type is PluginType.CONFIGURATION && IsEnterpriseTestConfigurationPath(plugin.LocalPath));
}
private static async Task LockHotReloadAsync()
/// <summary>
/// Counts how many operations currently write to the plugins directory.
/// </summary>
/// <remarks>
/// Downloading an organization's configuration and installing a plugin can run at the same
/// time. Without counting, whichever finishes first would unlock hot reloading while the other
/// is still writing.
/// </remarks>
private static int HOT_RELOAD_LOCK_COUNT;
private static readonly SemaphoreSlim HOT_RELOAD_LOCK_SEMAPHORE = new(1, 1);
/// <summary>
/// Holds back hot reloading while the caller writes to the plugins directory.
/// </summary>
/// <remarks>
/// Every caller has to release the lock again, so wrap the write in a try-finally block. Hot
/// reloading resumes once the last caller has released it.
/// </remarks>
public static async Task LockHotReloadAsync()
{
if (!IsInitialized)
{
@ -298,23 +316,28 @@ public static partial class PluginFactory
return;
}
await HOT_RELOAD_LOCK_SEMAPHORE.WaitAsync();
try
{
if (File.Exists(HOT_RELOAD_LOCK_FILE))
{
LOG.LogWarning("Hot reload lock file already exists.");
if (HOT_RELOAD_LOCK_COUNT++ > 0)
return;
}
await File.WriteAllTextAsync(HOT_RELOAD_LOCK_FILE, DateTime.UtcNow.ToString("o"));
}
catch (Exception e)
{
LOG.LogError(e, "An error occurred while trying to lock hot reloading.");
}
finally
{
HOT_RELOAD_LOCK_SEMAPHORE.Release();
}
}
private static void UnlockHotReload()
/// <summary>
/// Releases the hot reload lock of one caller, see LockHotReloadAsync.
/// </summary>
public static void UnlockHotReload()
{
if (!IsInitialized)
{
@ -322,8 +345,20 @@ public static partial class PluginFactory
return;
}
HOT_RELOAD_LOCK_SEMAPHORE.Wait();
try
{
//
// The count can be zero when the reload gave up waiting and removed the lock file
// itself. We must not go negative, because that would keep the next lock from ever
// writing the file again:
//
if (HOT_RELOAD_LOCK_COUNT > 0)
HOT_RELOAD_LOCK_COUNT--;
if (HOT_RELOAD_LOCK_COUNT > 0)
return;
if(File.Exists(HOT_RELOAD_LOCK_FILE))
File.Delete(HOT_RELOAD_LOCK_FILE);
else
@ -333,14 +368,19 @@ public static partial class PluginFactory
{
LOG.LogError(e, "An error occurred while trying to unlock hot reloading.");
}
finally
{
HOT_RELOAD_LOCK_SEMAPHORE.Release();
}
}
public static void Dispose()
{
if(!IsInitialized)
return;
HOT_RELOAD_WATCHER.Dispose();
HOT_RELOAD_DEBOUNCE_TIMER.Dispose();
}
public static IReadOnlyList<DataMandatoryInfo> GetMandatoryInfos()

View File

@ -7,7 +7,7 @@ public sealed partial class PluginInstallService
{
/// <summary>
/// Checks whether generated Lua assistant plugin code can be loaded and installed.
/// The plugin is written to a temporary staging directory and validated through the
/// The plugin is written to a staging directory and validated through the
/// normal plugin loader, but it is not moved into the user plugin directory.
/// </summary>
/// <param name="lua">The full generated <c>plugin.lua</c> content.</param>
@ -44,7 +44,7 @@ public sealed partial class PluginInstallService
/// <summary>
/// Installs generated Lua assistant plugin code into the user plugin directory.
/// Writes the plugin into a temporary staging directory first, validates it through the
/// Writes the plugin into a staging directory first, validates it through the
/// normal plugin loader, then moves into <c>data/plugins/assistants</c>.
/// If plugin with same ID already exists, the existing directory is moved
/// aside as backup and restored when replacement fails.
@ -84,11 +84,11 @@ public sealed partial class PluginInstallService
return PluginValidationResult.Failure(TB("The plugin system is not initialized yet."));
var pluginCode = lua.Trim();
var stagingDirectory = Path.Join(Path.GetTempPath(), $"{ASSISTANT_BUILDER_DIRECTORY_PREFIX}.staging-{Guid.NewGuid():N}");
if (!this.TryCreateStagingDirectory(ASSISTANT_BUILDER_DIRECTORY_PREFIX, out var stagingDirectory, out var stagingIssue))
return PluginValidationResult.Failure(stagingIssue);
try
{
Directory.CreateDirectory(stagingDirectory);
var stagedPluginFile = Path.Join(stagingDirectory, PLUGIN_FILE_NAME);
await File.WriteAllTextAsync(stagedPluginFile, pluginCode, Encoding.UTF8, token);

View File

@ -106,6 +106,9 @@ public sealed partial class PluginInstallService
var backupDirectory = string.Empty;
var sideEffects = PluginDeleteSideEffects.NONE;
// We reload the plugins ourselves below. Holding back hot reloading keeps the file system
// watcher from starting a second reload while the plugin is being moved away:
await PluginFactory.LockHotReloadAsync();
try
{
// Check again under the semaphore: another operation might have changed the plugin state
@ -116,7 +119,7 @@ public sealed partial class PluginInstallService
backupDirectory = CreateDeleteBackupDirectory(plugin);
Directory.CreateDirectory(Path.GetDirectoryName(backupDirectory)!);
Directory.Move(pluginDirectory, backupDirectory);
this.MoveDirectory(pluginDirectory, backupDirectory);
sideEffects = this.ApplyDeleteSideEffects(plugin);
if (sideEffects.HasChanges)
@ -137,6 +140,7 @@ public sealed partial class PluginInstallService
}
finally
{
PluginFactory.UnlockHotReload();
this.installSemaphore.Release();
}
}
@ -241,7 +245,7 @@ public sealed partial class PluginInstallService
try
{
if (!Directory.Exists(pluginDirectory) && Directory.Exists(backupDirectory))
Directory.Move(backupDirectory, pluginDirectory);
this.MoveDirectory(backupDirectory, pluginDirectory);
var configurationData = this.settingsManager.ConfigurationData;
if (sideEffects.WasEnabled && !configurationData.EnabledPlugins.Contains(plugin.Id))

View File

@ -97,6 +97,9 @@ public sealed partial class PluginInstallService
var tempFile = string.Empty;
var backupFile = string.Empty;
// We reload the plugins ourselves below. Holding back hot reloading keeps the file system
// watcher from starting a second reload while the plugin file is being replaced:
await PluginFactory.LockHotReloadAsync();
try
{
var validation = await this.ValidateInPluginDirectoryAsync(lua, pluginDirectory, token);
@ -144,6 +147,7 @@ public sealed partial class PluginInstallService
{
this.TryDeleteFile(tempFile, "assistant plugin edit temp file");
PluginFactory.UnlockHotReload();
this.installSemaphore.Release();
}
}

View File

@ -1,7 +1,131 @@
using AIStudio.Settings;
namespace AIStudio.Tools.Services;
public sealed partial class PluginInstallService
{
/// <summary>
/// Creates a staging directory for a plugin that is about to be installed.
/// </summary>
/// <remarks>
/// The staging directory lives below the data directory, never below the temporary directory of
/// the operating system. Installing means moving the staged plugin into the plugins directory,
/// and a directory move cannot cross a file system boundary. Flatpak is the case where this
/// always applies: its temporary directory is a tmpfs inside the sandbox, while the data
/// directory lives in the home directory of the user.<br/><br/>
/// It lives next to the plugins directory, not inside it: the plugin loader searches the plugins
/// directory recursively, so a half-written plugin there would be loaded while it is still being
/// staged.
/// </remarks>
/// <param name="prefix">The prefix of the staging directory name, naming the caller.</param>
/// <param name="stagingDirectory">The created staging directory.</param>
/// <param name="issue">A user-facing issue when the staging directory could not be created.</param>
/// <returns>True when the staging directory exists, false otherwise.</returns>
private bool TryCreateStagingDirectory(string prefix, out string stagingDirectory, out string issue)
{
stagingDirectory = string.Empty;
issue = string.Empty;
var dataDirectory = SettingsManager.DataDirectory;
if (string.IsNullOrWhiteSpace(dataDirectory))
{
issue = TB("The AI Studio data directory is not initialized yet.");
return false;
}
var stagingRoot = Path.Join(dataDirectory, STAGING_DIRECTORY);
try
{
Directory.CreateDirectory(stagingRoot);
this.CleanUpExpiredStagingDirectories(stagingRoot);
stagingDirectory = Path.Join(stagingRoot, $"{prefix}.staging-{Guid.NewGuid():N}");
Directory.CreateDirectory(stagingDirectory);
return true;
}
catch (Exception e)
{
this.logger.LogError(e, "Failed to create the plugin staging directory below '{StagingRoot}'.", stagingRoot);
stagingDirectory = string.Empty;
issue = string.Format(TB("Unexpected error: {0}"), e.Message);
return false;
}
}
/// <summary>
/// Removes staging directories which an earlier installation left behind, e.g. after a crash.
/// </summary>
private void CleanUpExpiredStagingDirectories(string stagingRoot)
{
var expiry = DateTime.UtcNow.AddHours(-STAGING_RETENTION_HOURS);
foreach (var leftOverDirectory in Directory.EnumerateDirectories(stagingRoot, "*", SearchOption.TopDirectoryOnly))
{
try
{
if (Directory.GetLastWriteTimeUtc(leftOverDirectory) < expiry)
Directory.Delete(leftOverDirectory, true);
}
catch (Exception e)
{
this.logger.LogWarning(e, "Failed to delete the left-over plugin staging directory '{StagingDirectory}'.", leftOverDirectory);
}
}
}
/// <summary>
/// Moves a directory and falls back to copying it when the move crosses a file system boundary.
/// </summary>
/// <remarks>
/// On Unix-like systems, a directory move is a plain rename, which fails as soon as source and
/// destination live on different file systems. A file move falls back to copy and delete in that
/// case, a directory move does not. Everything this service moves stays below the data directory,
/// so the fallback is not expected to run. It keeps installing and deleting plugins working when
/// a setup spreads the data directory across mounts.<br/><br/>
/// The fallback only applies when the move failed for that reason: when the destination is
/// already taken, the caller has to learn about it instead of getting the two directories merged.
/// <br/><br/>
/// A failing copy leaves nothing behind: the half-written destination is removed before the
/// error reaches the caller. Every caller rolls back by asking whether the destination exists,
/// so a partial copy would look like a completed move and keep the backup from being restored.
/// </remarks>
/// <param name="sourceDirectory">The directory to move.</param>
/// <param name="destinationDirectory">The directory to move it to. It must not exist yet.</param>
private void MoveDirectory(string sourceDirectory, string destinationDirectory)
{
try
{
Directory.Move(sourceDirectory, destinationDirectory);
return;
}
catch (IOException e) when (Directory.Exists(sourceDirectory) && !Directory.Exists(destinationDirectory))
{
this.logger.LogWarning(e, "Was not able to move the directory '{SourceDirectory}' to '{DestinationDirectory}'. Falling back to copying it.", sourceDirectory, destinationDirectory);
}
try
{
CopyDirectory(sourceDirectory, destinationDirectory);
}
catch
{
TryDeleteDirectory(destinationDirectory, "partially copied plugin", this.logger);
throw;
}
Directory.Delete(sourceDirectory, true);
}
private static void CopyDirectory(string sourceDirectory, string destinationDirectory)
{
Directory.CreateDirectory(destinationDirectory);
foreach (var filePath in Directory.EnumerateFiles(sourceDirectory))
File.Copy(filePath, Path.Join(destinationDirectory, Path.GetFileName(filePath)), true);
foreach (var subDirectory in Directory.EnumerateDirectories(sourceDirectory))
CopyDirectory(subDirectory, Path.Join(destinationDirectory, Path.GetFileName(subDirectory)));
}
private static bool IsPathInsideDirectory(string parentDirectory, string path)
{
var parentPath = Path.GetFullPath(parentDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;

View File

@ -38,10 +38,13 @@ public sealed partial class PluginInstallService
return Error(TB("The plugin system is not initialized yet."));
await this.installSemaphore.WaitAsync(token);
var stagingDirectory = Path.Join(Path.GetTempPath(), $"plugin-import.staging-{Guid.NewGuid():N}");
var stagingDirectory = string.Empty;
try
{
token.ThrowIfCancellationRequested();
if (!this.TryCreateStagingDirectory(PLUGIN_IMPORT_DIRECTORY_PREFIX, out stagingDirectory, out var stagingIssue))
return Error(stagingIssue);
PluginArchive.Extract(archivePath, stagingDirectory);
var pluginFiles = Directory.EnumerateFiles(stagingDirectory, PLUGIN_FILE_NAME, SearchOption.AllDirectories).ToArray();

View File

@ -16,16 +16,19 @@ public sealed partial class PluginInstallService
var replacedExisting = false;
var movedIntoPlace = false;
// We reload the plugins ourselves below. Holding back hot reloading keeps the file system
// watcher from starting a second reload while the plugin is being moved into place:
await PluginFactory.LockHotReloadAsync();
try
{
Directory.CreateDirectory(pluginRoot);
finalDirectory = DetermineFinalDirectory(pluginRoot, plugin, pluginType);
if (!IsPathInsideDirectory(pluginRoot, finalDirectory))
return Error(TB("The resolved plugin directory is outside the plugin directory."));
return Error(plugin, finalDirectory, TB("The resolved plugin directory is outside the plugin directory."));
var replacementIssue = GetReplacementIssue(plugin.Id, pluginType);
if (!string.IsNullOrWhiteSpace(replacementIssue))
return Error(replacementIssue);
return Error(plugin, finalDirectory, replacementIssue);
if (Directory.Exists(finalDirectory))
{
@ -36,10 +39,10 @@ public sealed partial class PluginInstallService
// would be loaded a second time, next to the version we are installing:
backupDirectory = CreateInstallBackupDirectory(plugin);
Directory.CreateDirectory(Path.GetDirectoryName(backupDirectory)!);
Directory.Move(finalDirectory, backupDirectory);
this.MoveDirectory(finalDirectory, backupDirectory);
}
Directory.Move(stagingDirectory, finalDirectory);
this.MoveDirectory(stagingDirectory, finalDirectory);
movedIntoPlace = true;
await PluginFactory.LoadAll(token);
@ -51,7 +54,7 @@ public sealed partial class PluginInstallService
}
catch (Exception e)
{
this.logger.LogError(e, "Failed to install plugin.");
this.logger.LogError(e, "Failed to install the {PluginType} plugin '{PluginName}' ({PluginId}) into '{PluginDirectory}'.", pluginType, plugin.Name, plugin.Id, finalDirectory);
// Only remove the target directory when this installation actually moved the plugin
// there. Otherwise, when moving the previous plugin into the backup directory failed,
@ -63,7 +66,7 @@ public sealed partial class PluginInstallService
{
try
{
Directory.Move(backupDirectory, finalDirectory);
this.MoveDirectory(backupDirectory, finalDirectory);
await PluginFactory.LoadAll(CancellationToken.None);
}
catch (Exception restoreException)
@ -72,11 +75,12 @@ public sealed partial class PluginInstallService
}
}
return Error(string.Format(TB("Unexpected error: {0}"), e.Message));
return Error(plugin, finalDirectory ?? string.Empty, string.Format(TB("Unexpected error: {0}"), e.Message));
}
finally
{
this.TryDeleteStagingDirectory(stagingDirectory);
PluginFactory.UnlockHotReload();
}
}

View File

@ -23,8 +23,11 @@ public sealed partial class PluginInstallService
private const string PLUGIN_FILE_NAME = "plugin.lua";
private const string ASSISTANT_BUILDER_DIRECTORY_PREFIX = "assistant-builder";
private const string PLUGIN_IMPORT_DIRECTORY_PREFIX = "plugin-import";
private const string DELETE_BACKUP_DIRECTORY = ".plugin-delete-backups";
private const string INSTALL_BACKUP_DIRECTORY = ".plugin-install-backups";
private const string STAGING_DIRECTORY = ".plugin-staging";
private const int STAGING_RETENTION_HOURS = 24;
private const int DIRECTORY_PREFIX_MAX_LEN = 80;
private readonly ILogger<PluginInstallService> logger;
@ -35,6 +38,16 @@ public sealed partial class PluginInstallService
private static AssistantPluginInstallResult Error(string issue) => new(false, Guid.Empty, string.Empty, string.Empty, false, issue);
/// <summary>
/// Reports a failed installation of a plugin we already know.
/// </summary>
/// <remarks>
/// Prefer this over the variant which only takes an issue: the caller logs the plugin and the
/// directory it tried to install into, and both are empty otherwise. Everything that fails
/// before we could read the plugin has to use the other variant.
/// </remarks>
private static AssistantPluginInstallResult Error(IPluginMetadata plugin, string pluginDirectory, string issue) => new(false, plugin.Id, plugin.Name, pluginDirectory, 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);

View File

@ -72,16 +72,19 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru
try
{
token.ThrowIfCancellationRequested();
await Task.Run(() =>
await Task.Run(async () =>
{
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);
//
// The save dialog already asked the user about overwriting an existing file, so we
// write into the file the user picked instead of removing and recreating it. That
// matters on Linux: inside a Flatpak, the file dialog hands out one single file
// through the desktop portal. We may write that file, but we may not create a new
// one next to it, which is what deleting and recreating would come down to.
//
await using var archiveStream = File.Create(archivePath);
ZipFile.CreateFromDirectory(pluginRoot, archiveStream, CompressionLevel.Optimal, false);
}, token);
logger.LogInformation("Exported plugin '{PluginName}' ({PluginId}) to the archive '{ArchivePath}'.", plugin.Name, plugin.Id, archivePath);

View File

@ -41,9 +41,9 @@
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[9.0.19, )",
"resolved": "9.0.19",
"contentHash": "I9GkKrCVjzxGU1hsKSurOW6P/ABPPHARfc/MTnzIgDb8YjJ/votxKN2z7K+J3DvlQXGH0O7KqdtBseRj8j7eNQ=="
"requested": "[9.0.18, )",
"resolved": "9.0.18",
"contentHash": "ztGVXB28bi8SeplFmAx+4MkqP1ieA4UNzj/M3qyyz5tLa37Ln8x8LuaXdxzzoOdaucjQBKXSdCMFSbpQaNGIEg=="
},
"MudBlazor": {
"type": "Direct",

View File

@ -42,5 +42,7 @@
- Fixed the assistant categories when your organization hides individual assistants. A category heading could stay visible above an empty area, and the Log Viewer could disappear together with the Localization assistant. Each heading now follows the assistants actually shown below it.
- Fixed a removed API key staying in the operating system's credential store. When you cleared the API key of a provider, the previous key remained stored and was still used. It is now removed together with your change.
- Fixed AI Studio installing a second copy of itself next to an existing installation. Its updater always installs into your personal user folder, so an installation elsewhere, such as one your IT department rolled out, was never replaced. AI Studio now recognizes those installations and leaves them alone. The information page tells you which case applies to yours. For IT departments: automatic updates can now stay enabled for everybody, and the Enterprise IT documentation explains the rest.
- Fixed installing an assistant on Linux when AI Studio runs as a Flatpak. The Assistant Builder was able to create an assistant, but installing it always ended with an unexpected error.
- Fixed the plugins page and the assistants page reloading again and again on Linux, which started as soon as an assistant was installed. Both pages kept flickering, and nothing on them could be used anymore until you left for another page.
- Removed the legacy PowerPoint format (`.ppt`) from the selectable file types. AI Studio has no reader for it, so such a file could be attached but never read. The modern `.pptx` format is not affected.
- Upgraded dependencies to their latest versions to improve security and stability.