diff --git a/Flow.Launcher.Core/Plugin/IResultUpdateRegister.cs b/Flow.Launcher.Core/Plugin/IResultUpdateRegister.cs index 1da04bf01a6..902bfcd870c 100644 --- a/Flow.Launcher.Core/Plugin/IResultUpdateRegister.cs +++ b/Flow.Launcher.Core/Plugin/IResultUpdateRegister.cs @@ -9,4 +9,10 @@ public interface IResultUpdateRegister /// /// void RegisterResultsUpdatedEvent(PluginPair pair); + + /// + /// Unregister a plugin from the results updated event, e.g. before the plugin is unloaded or reloaded. + /// + /// + void UnregisterResultsUpdatedEvent(PluginPair pair); } diff --git a/Flow.Launcher.Core/Plugin/PluginAssemblyLoader.cs b/Flow.Launcher.Core/Plugin/PluginAssemblyLoader.cs index 87bf8e6e88b..fdc9e9b4845 100644 --- a/Flow.Launcher.Core/Plugin/PluginAssemblyLoader.cs +++ b/Flow.Launcher.Core/Plugin/PluginAssemblyLoader.cs @@ -2,7 +2,9 @@ using System.IO; using System.Linq; using System.Reflection; +using System.Runtime.CompilerServices; using System.Runtime.Loader; +using System.Threading.Tasks; namespace Flow.Launcher.Core.Plugin { @@ -13,11 +15,43 @@ internal class PluginAssemblyLoader : AssemblyLoadContext private readonly AssemblyName assemblyName; internal PluginAssemblyLoader(string assemblyFilePath) + : base(name: Path.GetFileNameWithoutExtension(assemblyFilePath), isCollectible: true) { dependencyResolver = new AssemblyDependencyResolver(assemblyFilePath); assemblyName = new AssemblyName(Path.GetFileNameWithoutExtension(assemblyFilePath)); } + /// + /// Initiates unload of the given load context and returns a weak reference that can be used to + /// verify the context has actually been collected. Kept in a separate non-inlined method so the + /// caller's stack frame holds no strong reference to the context that would prevent collection. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + internal static WeakReference UnloadAndGetWeakReference(PluginAssemblyLoader loader) + { + var weakReference = new WeakReference(loader); + loader.Unload(); + return weakReference; + } + + /// + /// Waits for an unloaded context to be collected. Returns false if the context is still alive + /// after all attempts, which means something (e.g. a cached delegate) is pinning the old assembly. + /// + internal static async Task WaitForUnloadAsync(WeakReference weakReference, int maxAttempts = 10) + { + for (var i = 0; i < maxAttempts && weakReference.IsAlive; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + if (!weakReference.IsAlive) return true; + // ConfigureAwait(false) keeps the blocking GC loop off the UI thread's context + await Task.Delay(100).ConfigureAwait(false); + } + + return !weakReference.IsAlive; + } + internal Assembly LoadAssemblyAndDependencies() { return LoadFromAssemblyName(assemblyName); diff --git a/Flow.Launcher.Core/Plugin/PluginConfig.cs b/Flow.Launcher.Core/Plugin/PluginConfig.cs index db6813deb6b..1a3c7081478 100644 --- a/Flow.Launcher.Core/Plugin/PluginConfig.cs +++ b/Flow.Launcher.Core/Plugin/PluginConfig.cs @@ -110,7 +110,7 @@ internal static (List, List) GetUniqueLatestPlug return (unique_list, duplicate_list); } - private static PluginMetadata GetPluginMetadata(string pluginDirectory) + internal static PluginMetadata GetPluginMetadata(string pluginDirectory) { string configPath = Path.Combine(pluginDirectory, Constant.PluginMetadataFileName); if (!File.Exists(configPath)) diff --git a/Flow.Launcher.Core/Plugin/PluginInstaller.cs b/Flow.Launcher.Core/Plugin/PluginInstaller.cs index 6027b712e73..cba4c607eb9 100644 --- a/Flow.Launcher.Core/Plugin/PluginInstaller.cs +++ b/Flow.Launcher.Core/Plugin/PluginInstaller.cs @@ -91,7 +91,13 @@ await DownloadFileAsync( return; // do not restart on failure } - if (Settings.AutoRestartAfterChanging) + if (Settings.HotReloadAfterChanging && await PluginManager.ReloadPluginAsync(newPlugin.ID)) + { + PublicApi.Instance.ShowMsg( + Localize.installbtn(), + Localize.InstallSuccessHotReload(newPlugin.Name)); + } + else if (Settings.AutoRestartAfterChanging) { PublicApi.Instance.RestartApp(); } @@ -186,7 +192,14 @@ public static async Task UninstallPluginAndCheckRestartAsync(PluginMetadata oldP return; // don not restart on failure } - if (Settings.AutoRestartAfterChanging) + if (Settings.HotReloadAfterChanging && !PublicApi.Instance.PluginModified(oldPlugin.ID)) + { + // The plugin was fully unloaded and its directory deleted, so no restart is needed + PublicApi.Instance.ShowMsg( + Localize.uninstallbtn(), + Localize.UninstallSuccessHotReload(oldPlugin.Name)); + } + else if (Settings.AutoRestartAfterChanging) { PublicApi.Instance.RestartApp(); } @@ -246,7 +259,13 @@ await DownloadFileAsync( return; // do not restart on failure } - if (Settings.AutoRestartAfterChanging) + if (Settings.HotReloadAfterChanging && await PluginManager.ReloadPluginAsync(oldPlugin.ID)) + { + PublicApi.Instance.ShowMsg( + Localize.updatebtn(), + Localize.UpdateSuccessHotReload(newPlugin.Name)); + } + else if (Settings.AutoRestartAfterChanging) { PublicApi.Instance.RestartApp(); } @@ -328,6 +347,7 @@ where string.Compare(existingPlugin.Metadata.Version, pluginUpdateSource.Version public static async Task UpdateAllPluginsAsync(IEnumerable resultsForUpdate, bool restart) { var anyPluginSuccess = false; + var allPluginsHotReloaded = true; await Task.WhenAll(resultsForUpdate.Select(async plugin => { var downloadToFilePath = Path.Combine(Path.GetTempPath(), $"{plugin.Name}-{plugin.NewVersion}.zip"); @@ -343,18 +363,26 @@ await DownloadFileAsync( // check if user cancelled download before installing plugin if (cts.IsCancellationRequested) { + allPluginsHotReloaded = false; return; } if (!await PublicApi.Instance.UpdatePluginAsync(plugin.PluginExistingMetadata, plugin.PluginNewUserPlugin, downloadToFilePath)) { + allPluginsHotReloaded = false; return; } anyPluginSuccess = true; + + if (!Settings.HotReloadAfterChanging || !await PluginManager.ReloadPluginAsync(plugin.ID)) + { + allPluginsHotReloaded = false; + } } catch (Exception e) { + allPluginsHotReloaded = false; PublicApi.Instance.LogException(ClassName, "Failed to update plugin", e); PublicApi.Instance.ShowMsgError(Localize.ErrorUpdatingPlugin()); } @@ -362,7 +390,13 @@ await DownloadFileAsync( if (!anyPluginSuccess) return; - if (restart) + if (allPluginsHotReloaded) + { + PublicApi.Instance.ShowMsg( + Localize.updatebtn(), + Localize.PluginsUpdateSuccessHotReload()); + } + else if (restart) { PublicApi.Instance.RestartApp(); } diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index 56abfb251e1..4835d1483b7 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using System.Windows; +using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Core.ExternalPlugins; using Flow.Launcher.Core.Resource; using Flow.Launcher.Infrastructure; @@ -33,12 +34,16 @@ public static class PluginManager private static readonly ConcurrentDictionary> _nonGlobalPlugins = []; private static PluginsSettings Settings; - private static readonly ConcurrentBag ModifiedPlugins = []; + private static IResultUpdateRegister _register; + private static readonly ConcurrentDictionary ModifiedPlugins = []; - private static readonly ConcurrentBag _contextMenuPlugins = []; - private static readonly ConcurrentBag _homePlugins = []; - private static readonly ConcurrentBag _translationPlugins = []; - private static readonly ConcurrentBag _externalPreviewPlugins = []; + // Load contexts of dotnet plugins, kept so their assemblies can be unloaded on reload/uninstall + private static readonly ConcurrentDictionary _assemblyLoaders = []; + + private static readonly ConcurrentDictionary _contextMenuPlugins = []; + private static readonly ConcurrentDictionary _homePlugins = []; + private static readonly ConcurrentDictionary _translationPlugins = []; + private static readonly ConcurrentDictionary _externalPreviewPlugins = []; /// /// Directories that will hold Flow Launcher plugin directory @@ -48,6 +53,14 @@ public static class PluginManager Constant.PreinstalledDirectory, DataLocation.PluginsDirectory ]; + internal static void TrackAssemblyLoader(string id, PluginAssemblyLoader loader) + { + _assemblyLoaders[id] = loader; + } + + private static bool HotReloadEnabled => + Ioc.Default.GetRequiredService().HotReloadAfterChanging; + #region Save & Dispose & Reload Plugin /// @@ -113,6 +126,189 @@ public static async Task ReloadDataAsync() #endregion + #region Hot Reload Plugin + + private static readonly ConcurrentDictionary _reloadLocks = new(); + + // Directories of freshly installed or updated plugins that have not been loaded yet, + // so that ReloadPluginAsync knows to load the new version instead of the running one + private static readonly ConcurrentDictionary _pendingInstallPaths = new(); + + /// + /// Fully reloads one plugin in place: disposes the instance, unloads its assembly (for dotnet + /// plugins), and loads and initializes the plugin again from disk. If the plugin was just + /// installed or updated, the newly installed version is loaded. On failure the plugin is + /// flagged as modified so the existing restart-required flow takes over. + /// + public static async Task ReloadPluginAsync(string id) + { + var semaphore = _reloadLocks.GetOrAdd(id, _ => new SemaphoreSlim(1, 1)); + await semaphore.WaitAsync(); + try + { + string pluginDirectory; + if (_pendingInstallPaths.TryRemove(id, out var newDirectory) && Directory.Exists(newDirectory)) + { + // Freshly installed or updated: unload the running version if any, load the new directory + pluginDirectory = newDirectory; + if (_allLoadedPlugins.TryGetValue(id, out var oldPair)) + { + await UnloadPluginAsync(oldPair); + } + } + else if (_allLoadedPlugins.TryGetValue(id, out var pair)) + { + pluginDirectory = pair.Metadata.PluginDirectory; + await UnloadPluginAsync(pair); + } + else + { + return false; + } + + var success = await LoadAndInitializePluginAsync(pluginDirectory); + if (success) + { + // A concurrent install may have recorded a newer version while this reload was + // loading the old directory; keep the modified flag in that case so the + // restart-required flow (or a later reload of the pending version) still applies + if (!_pendingInstallPaths.ContainsKey(id)) + { + ClearPluginModified(id); + } + } + else + { + // The plugin is unloaded at this point, so keep its directory discoverable + // in either case: a later reload attempt can then still pick it up + _pendingInstallPaths.TryAdd(id, pluginDirectory); + ModifiedPlugins.TryAdd(id, 0); + } + return success; + } + finally + { + semaphore.Release(); + } + } + + /// + /// Fully reloads all loaded plugins. Plugins already flagged as modified are skipped because + /// their on-disk directory is pending deletion or replacement and requires a restart. + /// Returns false if any plugin failed to reload. + /// + public static async Task ReloadAllPluginsAsync() + { + var allSucceeded = true; + foreach (var pair in GetAllLoadedPlugins()) + { + var id = pair.Metadata.ID; + if (PluginModified(id)) continue; + + if (!await ReloadPluginAsync(id)) + { + allSucceeded = false; + } + } + return allSucceeded; + } + + /// + /// Removes a plugin from every runtime registry, disposes it and, for dotnet plugins, unloads + /// its assembly load context. Returns false if the assembly could not be verified as unloaded, + /// in which case it stays in memory (and its files locked) until the app restarts. + /// + internal static async Task UnloadPluginAsync(PluginPair pair) + { + var metadata = pair.Metadata; + var id = metadata.ID; + + // Save plugin settings before the instance is disposed + try + { + (pair.Plugin as ISavable)?.Save(); + } + catch (Exception e) + { + PublicApi.Instance.LogException(ClassName, $"Failed to save plugin {metadata.Name} before unload", e); + } + + // Removing the plugin from the initialized list first makes in-flight queries treat it as + // still initializing, so they show a placeholder result instead of touching a dead instance + RemovePluginFromLists(id); + UnregisterPluginActionKeywords(id); + _register?.UnregisterResultsUpdatedEvent(pair); + DialogJump.RemoveDialogJumpPlugin(pair); + _allLoadedPlugins.TryRemove(id, out _); + + // For JsonRPC V2 plugins this kills the child process, releasing its file locks + await DisposePluginAsync(pair); + + if (!_assemblyLoaders.TryRemove(id, out var loader)) return true; + + // Persist and evict the Type-keyed storages that would otherwise pin the collectible load context + PublicApi.Instance.SavePluginSettings(); + PublicApi.Instance.SavePluginCaches(); + if (PublicApi.Instance is IRemovable removable) + { + removable.RemovePluginSettings(metadata.AssemblyName); + removable.RemovePluginCaches(metadata.PluginCacheDirectoryPath); + } + + pair.Plugin = null; + + var weakReference = PluginAssemblyLoader.UnloadAndGetWeakReference(loader); + loader = null; + var unloaded = await PluginAssemblyLoader.WaitForUnloadAsync(weakReference); + if (!unloaded) + { + PublicApi.Instance.LogWarn(ClassName, + $"Assembly of plugin <{metadata.Name}> could not be fully unloaded, e.g. because results " + + $"or event handlers still reference it. Its memory will be reclaimed on restart."); + } + + return unloaded; + } + + /// + /// Loads and initializes a single plugin from its directory, registering it in every runtime + /// registry, the same way startup does for all plugins. + /// + internal static async Task LoadAndInitializePluginAsync(string pluginDirectory) + { + var metadata = PluginConfig.GetPluginMetadata(pluginDirectory); + if (metadata == null) return false; + + // Bail out before creating an assembly load context for a plugin that is already running + if (_allLoadedPlugins.ContainsKey(metadata.ID)) + { + PublicApi.Instance.LogError(ClassName, $"Plugin with ID {metadata.ID} already loaded"); + return false; + } + + var metadatas = new List { metadata }; + Settings.UpdatePluginSettings(metadatas); + + var pair = PluginsLoader.LoadPlugin(metadata, Settings); + if (pair?.Plugin == null) return false; + + // Since dotnet plugins need to get assembly name first, we should update plugin directory after loading plugins + UpdatePluginDirectory(metadatas); + + // Cannot race the earlier ContainsKey check: all callers hold the per-plugin + // lifecycle lock, which serializes every path that adds to _allLoadedPlugins + if (!_allLoadedPlugins.TryAdd(metadata.ID, pair)) + { + PublicApi.Instance.LogError(ClassName, $"Plugin with ID {metadata.ID} already loaded"); + await DisposePluginAsync(pair); + return false; + } + + return await InitializePluginAsync(pair); + } + + #endregion + #region External Preview public static async Task OpenExternalPreviewAsync(string path, bool sendFailToast = true) @@ -159,7 +355,7 @@ public static bool AllowAlwaysPreview() private static IList GetExternalPreviewPlugins() { - return [.. _externalPreviewPlugins.Where(p => !PluginModified(p.Metadata.ID))]; + return [.. _externalPreviewPlugins.Values.Where(p => !PluginModified(p.Metadata.ID))]; } #endregion @@ -255,47 +451,68 @@ private static void UpdatePluginDirectory(List metadatas) /// return the list of failed to init plugins or null for none public static async Task InitializePluginsAsync(IResultUpdateRegister register) { - var initTasks = _allLoadedPlugins.Select(x => Task.Run(async () => + _register = register; + + var initTasks = _allLoadedPlugins.Select(x => Task.Run(() => InitializePluginAsync(x.Value))); + + await Task.WhenAll(initTasks); + + if (!_initFailedPlugins.IsEmpty) { - var pair = x.Value; + var failed = string.Join(",", _initFailedPlugins.Values.Select(x => x.Metadata.Name)); + PublicApi.Instance.ShowMsg( + Localize.failedToInitializePluginsTitle(), + Localize.failedToInitializePluginsMessage(failed), + "", + false + ); + } + } - // Register plugin action keywords so that plugins can be queried in results - RegisterPluginActionKeywords(pair); + internal static async Task InitializePluginAsync(PluginPair pair) + { + // Register plugin action keywords so that plugins can be queried in results + RegisterPluginActionKeywords(pair); - try - { - var milliseconds = await PublicApi.Instance.StopwatchLogDebugAsync(ClassName, $"Init method time cost for <{pair.Metadata.Name}>", - () => pair.Plugin.InitAsync(new PluginInitContext(pair.Metadata, PublicApi.Instance))); + try + { + var milliseconds = await PublicApi.Instance.StopwatchLogDebugAsync(ClassName, $"Init method time cost for <{pair.Metadata.Name}>", + () => pair.Plugin.InitAsync(new PluginInitContext(pair.Metadata, PublicApi.Instance))); - pair.Metadata.InitTime += milliseconds; - PublicApi.Instance.LogInfo(ClassName, - $"Total init cost for <{pair.Metadata.Name}> is <{pair.Metadata.InitTime}ms>"); + pair.Metadata.InitTime += milliseconds; + PublicApi.Instance.LogInfo(ClassName, + $"Total init cost for <{pair.Metadata.Name}> is <{pair.Metadata.InitTime}ms>"); + } + catch (Exception e) + { + PublicApi.Instance.LogException(ClassName, $"Fail to Init plugin: {pair.Metadata.Name}", e); + if (pair.Metadata.Disabled && pair.Metadata.HomeDisabled) + { + // If this plugin is already disabled, do not show error message again + // Or else it will be shown every time + PublicApi.Instance.LogDebug(ClassName, $"Skipped init for <{pair.Metadata.Name}> due to error"); } - catch (Exception e) + else { - PublicApi.Instance.LogException(ClassName, $"Fail to Init plugin: {pair.Metadata.Name}", e); - if (pair.Metadata.Disabled && pair.Metadata.HomeDisabled) - { - // If this plugin is already disabled, do not show error message again - // Or else it will be shown every time - PublicApi.Instance.LogDebug(ClassName, $"Skipped init for <{pair.Metadata.Name}> due to error"); - } - else - { - pair.Metadata.Disabled = true; - pair.Metadata.HomeDisabled = true; - PublicApi.Instance.LogDebug(ClassName, $"Disable plugin <{pair.Metadata.Name}> because init failed"); - } - - // Even if the plugin cannot be initialized, we still need to add it in all plugin list so that - // we can remove the plugin from Plugin or Store page or Plugin Manager plugin. - _allInitializedPlugins.TryAdd(pair.Metadata.ID, pair); - _initFailedPlugins.TryAdd(pair.Metadata.ID, pair); - return; + pair.Metadata.Disabled = true; + pair.Metadata.HomeDisabled = true; + PublicApi.Instance.LogDebug(ClassName, $"Disable plugin <{pair.Metadata.Name}> because init failed"); } + // Do not leave a plugin that failed to initialize queryable via its action keywords + UnregisterPluginActionKeywords(pair.Metadata.ID); + + // Even if the plugin cannot be initialized, we still need to add it in all plugin list so that + // we can remove the plugin from Plugin or Store page or Plugin Manager plugin. + _allInitializedPlugins.TryAdd(pair.Metadata.ID, pair); + _initFailedPlugins.TryAdd(pair.Metadata.ID, pair); + return false; + } + + try + { // Register ResultsUpdated event so that plugin query can use results updated interface - register.RegisterResultsUpdatedEvent(pair); + _register?.RegisterResultsUpdatedEvent(pair); // Update plugin metadata translation after the plugin is initialized with IPublicAPI instance Internationalization.UpdatePluginMetadataTranslation(pair); @@ -305,19 +522,24 @@ public static async Task InitializePluginsAsync(IResultUpdateRegister register) // Add plugin to lists after the plugin is initialized AddPluginToLists(pair); - })); - - await Task.WhenAll(initTasks); - if (!_initFailedPlugins.IsEmpty) + return true; + } + catch (Exception e) { - var failed = string.Join(",", _initFailedPlugins.Values.Select(x => x.Metadata.Name)); - PublicApi.Instance.ShowMsg( - Localize.failedToInitializePluginsTitle(), - Localize.failedToInitializePluginsMessage(failed), - "", - false - ); + // Roll back partial registrations so the failure surfaces as a clean init failure + // instead of a plugin that is half-registered + PublicApi.Instance.LogException(ClassName, $"Fail to register plugin: {pair.Metadata.Name}", e); + RemovePluginFromLists(pair.Metadata.ID); + UnregisterPluginActionKeywords(pair.Metadata.ID); + _register?.UnregisterResultsUpdatedEvent(pair); + DialogJump.RemoveDialogJumpPlugin(pair); + + pair.Metadata.Disabled = true; + pair.Metadata.HomeDisabled = true; + _allInitializedPlugins.TryAdd(pair.Metadata.ID, pair); + _initFailedPlugins.TryAdd(pair.Metadata.ID, pair); + return false; } } @@ -355,23 +577,51 @@ private static void AddPluginToLists(PluginPair pair) { if (pair.Plugin is IContextMenu) { - _contextMenuPlugins.Add(pair); + _contextMenuPlugins.TryAdd(pair.Metadata.ID, pair); } if (pair.Plugin is IAsyncHomeQuery) { - _homePlugins.Add(pair); + _homePlugins.TryAdd(pair.Metadata.ID, pair); } if (pair.Plugin is IPluginI18n) { - _translationPlugins.Add(pair); + _translationPlugins.TryAdd(pair.Metadata.ID, pair); } if (pair.Plugin is IAsyncExternalPreview) { - _externalPreviewPlugins.Add(pair); + _externalPreviewPlugins.TryAdd(pair.Metadata.ID, pair); } _allInitializedPlugins.TryAdd(pair.Metadata.ID, pair); } + private static void RemovePluginFromLists(string id) + { + _contextMenuPlugins.TryRemove(id, out _); + _homePlugins.TryRemove(id, out _); + _translationPlugins.TryRemove(id, out _); + _externalPreviewPlugins.TryRemove(id, out _); + _allInitializedPlugins.TryRemove(id, out _); + _initFailedPlugins.TryRemove(id, out _); + } + + private static void UnregisterPluginActionKeywords(string id) + { + _globalPlugins.TryRemove(id, out _); + + foreach (var entry in _nonGlobalPlugins.ToList()) + { + lock (entry.Value) + { + entry.Value.RemoveAll(p => p.Metadata.ID == id); + + if (entry.Value.Count == 0) + { + _nonGlobalPlugins.TryRemove(new KeyValuePair>(entry.Key, entry.Value)); + } + } + } + } + #endregion #region Validate & Query Plugins @@ -412,7 +662,7 @@ private static bool TryGetNonGlobalPlugins(string actionKeyword, out List ValidPluginsForHomeQuery() { - return [.. _homePlugins.Where(p => !PluginModified(p.Metadata.ID))]; + return [.. _homePlugins.Values.Where(p => !PluginModified(p.Metadata.ID))]; } public static async Task> QueryForPluginAsync(PluginPair pair, Query query, CancellationToken token) @@ -616,7 +866,7 @@ public static Dictionary> GetNonGlobalPlugins() public static List GetTranslationPlugins() { - return [.. _translationPlugins.Where(p => !PluginModified(p.Metadata.ID))]; + return [.. _translationPlugins.Values.Where(p => !PluginModified(p.Metadata.ID))]; } #endregion @@ -658,7 +908,7 @@ public static PluginPair GetPluginForId(string id) public static List GetContextMenusForPlugin(Result result) { var results = new List(); - var pluginPair = _contextMenuPlugins.Where(p => !PluginModified(p.Metadata.ID)).FirstOrDefault(o => o.Metadata.ID == result.PluginID); + var pluginPair = _contextMenuPlugins.Values.Where(p => !PluginModified(p.Metadata.ID)).FirstOrDefault(o => o.Metadata.ID == result.PluginID); if (pluginPair != null) { var plugin = (IContextMenu)pluginPair.Plugin; @@ -690,7 +940,7 @@ public static List GetContextMenusForPlugin(Result result) public static bool IsHomePlugin(string id) { - return _homePlugins.Where(p => !PluginModified(p.Metadata.ID)).Any(p => p.Metadata.ID == id); + return _homePlugins.Values.Where(p => !PluginModified(p.Metadata.ID)).Any(p => p.Metadata.ID == id); } #endregion @@ -893,7 +1143,12 @@ private static bool SameOrLesserPluginVersionExists(PluginMetadata metadata) public static bool PluginModified(string id) { - return ModifiedPlugins.Contains(id); + return ModifiedPlugins.ContainsKey(id); + } + + internal static void ClearPluginModified(string id) + { + ModifiedPlugins.TryRemove(id, out _); } public static async Task UpdatePluginAsync(PluginMetadata existingVersion, UserPlugin newVersion, string zipFilePath) @@ -911,7 +1166,7 @@ public static async Task UpdatePluginAsync(PluginMetadata existingVersion, var uninstallSuccess = await UninstallPluginAsync(existingVersion, removePluginFromSettings: false, removePluginSettings: false, checkModified: false); if (!uninstallSuccess) return false; - ModifiedPlugins.Add(existingVersion.ID); + ModifiedPlugins.TryAdd(existingVersion.ID, 0); return true; } @@ -975,6 +1230,23 @@ internal static bool InstallPlugin(UserPlugin plugin, string zipFilePath, bool c return false; } + if (string.IsNullOrEmpty(plugin.ID)) + { + // Install-from-URL requests do not know the plugin ID until the package is + // downloaded, so adopt the ID from the package's plugin.json + plugin.ID = newMetadata.ID; + } + else if (!string.Equals(newMetadata.ID, plugin.ID, StringComparison.Ordinal)) + { + // A mismatched package would install and later load under a different identity + // than the plugin the user asked for + PublicApi.Instance.ShowMsgError(Localize.failedToInstallPluginTitle(plugin.Name), + Localize.pluginIDMismatchMessage()); + PublicApi.Instance.LogError(ClassName, + $"Plugin package ID <{newMetadata.ID}> does not match the requested plugin ID <{plugin.ID}> for {plugin.Name}"); + return false; + } + if (SameOrLesserPluginVersionExists(newMetadata)) { PublicApi.Instance.ShowMsgError(Localize.failedToInstallPluginTitle(plugin.Name), @@ -1033,9 +1305,14 @@ internal static bool InstallPlugin(UserPlugin plugin, string zipFilePath, bool c PublicApi.Instance.LogException(ClassName, $"Failed to delete plugin marker file in {newPluginPath}", e); } + // Remember where this version was installed so a hot reload can load it without a + // restart. Recorded before the modified flag so that a concurrent reload observing + // the flag always sees the pending path too and never clears the flag prematurely. + _pendingInstallPaths[plugin.ID] = newPluginPath; + if (checkModified) { - ModifiedPlugins.Add(plugin.ID); + ModifiedPlugins.TryAdd(plugin.ID, 0); } return true; @@ -1055,6 +1332,22 @@ internal static bool InstallPlugin(UserPlugin plugin, string zipFilePath, bool c } internal static async Task UninstallPluginAsync(PluginMetadata plugin, bool removePluginFromSettings, bool removePluginSettings, bool checkModified) + { + // Take the same per-plugin lifecycle lock as ReloadPluginAsync so a concurrent reload + // cannot resurrect a plugin that is being uninstalled or race its file removal + var semaphore = _reloadLocks.GetOrAdd(plugin.ID, _ => new SemaphoreSlim(1, 1)); + await semaphore.WaitAsync(); + try + { + return await UninstallPluginUnlockedAsync(plugin, removePluginFromSettings, removePluginSettings, checkModified); + } + finally + { + semaphore.Release(); + } + } + + private static async Task UninstallPluginUnlockedAsync(PluginMetadata plugin, bool removePluginFromSettings, bool removePluginSettings, bool checkModified) { if (checkModified && PluginModified(plugin.ID)) { @@ -1063,6 +1356,10 @@ internal static async Task UninstallPluginAsync(PluginMetadata plugin, boo return false; } + // Fully unloaded plugins release all file handles, so their directory can be deleted + // immediately instead of being marked for deletion on the next startup + var fullyUnloaded = false; + if (removePluginSettings || removePluginFromSettings) { // If we want to remove plugin from AllPlugins, @@ -1071,7 +1368,14 @@ internal static async Task UninstallPluginAsync(PluginMetadata plugin, boo var pluginPairs = GetAllInitializedPlugins(includeFailed: true).Where(p => p.Metadata.ID == plugin.ID).ToList(); foreach (var pluginPair in pluginPairs) { - await DisposePluginAsync(pluginPair); + if (removePluginFromSettings && HotReloadEnabled) + { + fullyUnloaded = await UnloadPluginAsync(pluginPair); + } + else + { + await DisposePluginAsync(pluginPair); + } } } @@ -1113,39 +1417,36 @@ internal static async Task UninstallPluginAsync(PluginMetadata plugin, boo Localize.failedToRemovePluginCacheMessage(plugin.Name)); } Settings.RemovePluginSettings(plugin.ID); + _allLoadedPlugins.TryRemove(plugin.ID, out var _); + RemovePluginFromLists(plugin.ID); + UnregisterPluginActionKeywords(plugin.ID); + } + + // When the plugin was fully unloaded, its directory can be removed right away and + // no restart is needed; otherwise mark it for deletion on next startup + var deleted = false; + if (fullyUnloaded) + { + try { - _allLoadedPlugins.TryRemove(plugin.ID, out var _); - } - { - _allInitializedPlugins.TryRemove(plugin.ID, out var _); - } - { - _initFailedPlugins.TryRemove(plugin.ID, out var _); - } - { - _globalPlugins.TryRemove(plugin.ID, out var _); + deleted = FilesFolders.TryDeleteDirectoryRobust(plugin.PluginDirectory); } - var entriesToUpdate = _nonGlobalPlugins.ToList(); - foreach (var entry in entriesToUpdate) + catch (Exception e) { - lock (entry.Value) - { - entry.Value.RemoveAll(p => p.Metadata.ID == plugin.ID); - - if (entry.Value.Count == 0) - { - _nonGlobalPlugins.TryRemove(new KeyValuePair>(entry.Key, entry.Value)); - } - } + PublicApi.Instance.LogException(ClassName, + $"Failed to delete plugin folder for {plugin.Name}, marking it for deletion on next startup", e); } } - // Marked for deletion. Will be deleted on next start up - using var _ = File.CreateText(Path.Combine(plugin.PluginDirectory, DataLocation.PluginDeleteFile)); - - if (checkModified) + if (!deleted && Directory.Exists(plugin.PluginDirectory)) { - ModifiedPlugins.Add(plugin.ID); + // Marked for deletion. Will be deleted on next start up + using var _ = File.CreateText(Path.Combine(plugin.PluginDirectory, DataLocation.PluginDeleteFile)); + + if (checkModified) + { + ModifiedPlugins.TryAdd(plugin.ID, 0); + } } return true; diff --git a/Flow.Launcher.Core/Plugin/PluginsLoader.cs b/Flow.Launcher.Core/Plugin/PluginsLoader.cs index 119dd83baa3..6dc84f59943 100644 --- a/Flow.Launcher.Core/Plugin/PluginsLoader.cs +++ b/Flow.Launcher.Core/Plugin/PluginsLoader.cs @@ -57,58 +57,15 @@ private static List DotNetPlugins(List source) foreach (var metadata in metadatas) { - var milliseconds = PublicApi.Instance.StopwatchLogDebug(ClassName, $"Constructor init cost for {metadata.Name}", () => + var pair = DotNetPlugin(metadata); + if (pair == null) { - Assembly assembly = null; - IAsyncPlugin plugin = null; - - try - { - var assemblyLoader = new PluginAssemblyLoader(metadata.ExecuteFilePath); - assembly = assemblyLoader.LoadAssemblyAndDependencies(); - - var type = assemblyLoader.FromAssemblyGetTypeOfInterface(assembly, - typeof(IAsyncPlugin)); - - plugin = Activator.CreateInstance(type) as IAsyncPlugin; - - metadata.AssemblyName = assembly.GetName().Name; - } -#if DEBUG - catch (Exception) - { - throw; - } -#else - catch (Exception e) when (assembly == null) - { - PublicApi.Instance.LogException(ClassName, $"Couldn't load assembly for the plugin: {metadata.Name}", e); - } - catch (InvalidOperationException e) - { - PublicApi.Instance.LogException(ClassName, $"Can't find the required IPlugin interface for the plugin: <{metadata.Name}>", e); - } - catch (ReflectionTypeLoadException e) - { - PublicApi.Instance.LogException(ClassName, $"The GetTypes method was unable to load assembly types for the plugin: <{metadata.Name}>", e); - } - catch (Exception e) - { - PublicApi.Instance.LogException(ClassName, $"The following plugin has errored and can not be loaded: <{metadata.Name}>", e); - } -#endif - - if (plugin == null) - { - erroredPlugins.Add(metadata.Name); - return; - } - - plugins.Add(new PluginPair { Plugin = plugin, Metadata = metadata }); - }); - - metadata.InitTime += milliseconds; - PublicApi.Instance.LogDebug(ClassName, $"Constructor cost for <{metadata.Name}> is <{metadata.InitTime}ms>"); + erroredPlugins.Add(metadata.Name); + } + else + { + plugins.Add(pair); + } } if (erroredPlugins.Count > 0) @@ -127,6 +84,104 @@ private static List DotNetPlugins(List source) return plugins; } + internal static PluginPair DotNetPlugin(PluginMetadata metadata) + { + PluginPair pair = null; + + var milliseconds = PublicApi.Instance.StopwatchLogDebug(ClassName, $"Constructor init cost for {metadata.Name}", () => + { + Assembly assembly = null; + IAsyncPlugin plugin = null; + PluginAssemblyLoader assemblyLoader = null; + + try + { + assemblyLoader = new PluginAssemblyLoader(metadata.ExecuteFilePath); + assembly = assemblyLoader.LoadAssemblyAndDependencies(); + + var type = assemblyLoader.FromAssemblyGetTypeOfInterface(assembly, + typeof(IAsyncPlugin)); + + plugin = Activator.CreateInstance(type) as IAsyncPlugin; + + metadata.AssemblyName = assembly.GetName().Name; + } +#if DEBUG + catch (Exception) + { + throw; + } +#else + catch (Exception e) when (assembly == null) + { + PublicApi.Instance.LogException(ClassName, $"Couldn't load assembly for the plugin: {metadata.Name}", e); + } + catch (InvalidOperationException e) + { + PublicApi.Instance.LogException(ClassName, $"Can't find the required IPlugin interface for the plugin: <{metadata.Name}>", e); + } + catch (ReflectionTypeLoadException e) + { + PublicApi.Instance.LogException(ClassName, $"The GetTypes method was unable to load assembly types for the plugin: <{metadata.Name}>", e); + } + catch (Exception e) + { + PublicApi.Instance.LogException(ClassName, $"The following plugin has errored and can not be loaded: <{metadata.Name}>", e); + } +#endif + + if (plugin == null) return; + + // Track the load context only for an accepted plugin, so a rejected one cannot + // displace the running instance's context; it is used to unload the assembly when + // the plugin is reloaded or uninstalled + PluginManager.TrackAssemblyLoader(metadata.ID, assemblyLoader); + + pair = new PluginPair { Plugin = plugin, Metadata = metadata }; + }); + + metadata.InitTime += milliseconds; + PublicApi.Instance.LogDebug(ClassName, $"Constructor cost for <{metadata.Name}> is <{metadata.InitTime}ms>"); + + return pair; + } + + /// + /// Loads a single plugin from its already-parsed metadata, dispatching on the plugin language. + /// Used by hot reload; the batch equivalent for startup is . + /// + internal static PluginPair LoadPlugin(PluginMetadata metadata, PluginsSettings settings) + { + if (AllowedLanguage.IsDotNet(metadata.Language)) + { + return DotNetPlugin(metadata); + } + + var metadatas = new List { metadata }; + + if (AllowedLanguage.IsPython(metadata.Language) || AllowedLanguage.IsNodeJs(metadata.Language)) + { + AbstractPluginEnvironment environment = metadata.Language.ToUpperInvariant() switch + { + "PYTHON" => new PythonEnvironment(metadatas, settings), + "PYTHON_V2" => new PythonV2Environment(metadatas, settings), + "TYPESCRIPT" => new TypeScriptEnvironment(metadatas, settings), + "TYPESCRIPT_V2" => new TypeScriptV2Environment(metadatas, settings), + "JAVASCRIPT" => new JavaScriptEnvironment(metadatas, settings), + _ => new JavaScriptV2Environment(metadatas, settings), + }; + return environment.Setup().FirstOrDefault(); + } + + if (AllowedLanguage.IsExecutable(metadata.Language)) + { + return ExecutablePlugins(metadatas).Concat(ExecutableV2Plugins(metadatas)).FirstOrDefault(); + } + + PublicApi.Instance.LogError(ClassName, $"Unsupported language <{metadata.Language}> for plugin <{metadata.Name}>"); + return null; + } + private static IEnumerable ExecutablePlugins(IEnumerable source) { return source diff --git a/Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs b/Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs index 53df05bf259..46e3fb47ebf 100644 --- a/Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs +++ b/Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using System.Windows.Threading; @@ -146,6 +147,43 @@ public static void InitializeDialogJumpPlugin(PluginPair pair) } } + public static void RemoveDialogJumpPlugin(PluginPair pair) + { + foreach (var explorer in _dialogJumpExplorers.Keys.Where(e => e.Metadata.ID == pair.Metadata.ID).ToList()) + { + if (_dialogJumpExplorers.TryRemove(explorer, out var explorerWindow)) + { + explorerWindow?.Dispose(); + } + + lock (_lastExplorerLock) + { + if (_lastExplorer == explorer) + { + _lastExplorer = null; + } + } + } + + foreach (var dialog in _dialogJumpDialogs.Keys.Where(d => d.Metadata.ID == pair.Metadata.ID).ToList()) + { + if (_dialogJumpDialogs.TryRemove(dialog, out var dialogWindow)) + { + // Drop the cached active dialog window so later navigation cannot call into the + // disposed instance. The cached window may have been created by foreground + // detection without being stored in the dictionary, so it cannot be attributed + // to a plugin by comparing values; clearing it is safe because the next + // foreground event repopulates it from the remaining dialogs. + lock (_dialogWindowLock) + { + _dialogWindow = null; + } + + dialogWindow?.Dispose(); + } + } + } + public static void SetupDialogJump(bool enabled) { if (enabled == _enabled) return; @@ -353,7 +391,15 @@ private static bool RefreshLastExplorer() public static string GetActiveExplorerPath() { - return RefreshLastExplorer() ? _dialogJumpExplorers[_lastExplorer].GetExplorerPath() : string.Empty; + if (!RefreshLastExplorer()) return string.Empty; + + lock (_lastExplorerLock) + { + // The explorer can be removed concurrently by a plugin hot reload + return _lastExplorer != null && _dialogJumpExplorers.TryGetValue(_lastExplorer, out var explorerWindow) + ? explorerWindow?.GetExplorerPath() ?? string.Empty + : string.Empty; + } } #endregion @@ -510,7 +556,8 @@ uint dwmsEventTime dialog.Metadata.Disabled) continue; // Plugin is disabled IDialogJumpDialogWindow dialogWindow; - var existingDialogWindow = _dialogJumpDialogs[dialog]; + // Skip dialogs removed concurrently by a plugin hot reload + if (!_dialogJumpDialogs.TryGetValue(dialog, out var existingDialogWindow)) continue; if (existingDialogWindow != null && existingDialogWindow.Handle == hwnd) { // If the dialog window is already in the list, no need to check again @@ -827,7 +874,10 @@ private static async Task NavigateDialogPathAsync(HWND hwnd, bool auto = f string path; lock (_lastExplorerLock) { - path = _dialogJumpExplorers[_lastExplorer]?.GetExplorerPath(); + // The explorer can be removed concurrently by a plugin hot reload + path = _lastExplorer != null && _dialogJumpExplorers.TryGetValue(_lastExplorer, out var explorerWindow) + ? explorerWindow?.GetExplorerPath() + : null; } // Check path null or empty @@ -913,7 +963,8 @@ private static IDialogJumpDialogWindow GetDialogWindow(HWND hwnd) if (PublicApi.Instance.PluginModified(dialog.Metadata.ID) || // Plugin is modified dialog.Metadata.Disabled) continue; // Plugin is disabled - var dialogWindow = _dialogJumpDialogs[dialog]; + // The dialog can be removed concurrently by a plugin hot reload + _dialogJumpDialogs.TryGetValue(dialog, out var dialogWindow); if (dialogWindow != null && dialogWindow.Handle == hwnd) { return dialogWindow; @@ -927,7 +978,8 @@ private static IDialogJumpDialogWindow GetDialogWindow(HWND hwnd) dialog.Metadata.Disabled) continue; // Plugin is disabled IDialogJumpDialogWindow dialogWindow; - var existingDialogWindow = _dialogJumpDialogs[dialog]; + // Skip dialogs removed concurrently by a plugin hot reload + if (!_dialogJumpDialogs.TryGetValue(dialog, out var existingDialogWindow)) continue; if (existingDialogWindow != null && existingDialogWindow.Handle == hwnd) { // If the dialog window is already in the list, no need to check again diff --git a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs index fd535f21a6e..37199207a47 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs @@ -265,6 +265,7 @@ public bool ShowHistoryResultsForHomePage public int MaxHistoryResultsToShowForHomePage { get; set; } = 5; public bool AutoRestartAfterChanging { get; set; } = false; + public bool HotReloadAfterChanging { get; set; } = true; public bool ShowUnknownSourceWarning { get; set; } = true; public bool AutoUpdatePlugins { get; set; } = true; diff --git a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs index 304f4d5c00e..466e23769ab 100644 --- a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs +++ b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs @@ -603,6 +603,27 @@ public interface IPublicAPI /// public Task UninstallPluginAsync(PluginMetadata pluginMetadata, bool removePluginSettings = false); + /// + /// Fully reload one plugin in place, without restarting the app: dispose the running instance, + /// unload its assembly (for .NET plugins), and load and initialize the plugin again from disk. + /// If the plugin was just installed or updated, the newly installed version is loaded. + /// + /// Plugin id + /// + /// True if the plugin is reloaded successfully. False if the reload failed, in which case the + /// plugin is marked as modified and an app restart is required. + /// + public Task ReloadPluginAsync(string id); + + /// + /// Fully reload all loaded plugins, without restarting the app. Plugins already marked as + /// modified are skipped. See . + /// + /// + /// True if every reloaded plugin succeeded; false if any plugin failed to reload. + /// + public Task ReloadAllPluginsAsync(); + /// /// Log debug message of the time taken to execute a method /// Message will only be logged in Debug mode diff --git a/Flow.Launcher.Test/PluginHotReloadTest.cs b/Flow.Launcher.Test/PluginHotReloadTest.cs new file mode 100644 index 00000000000..f9eff05dfb2 --- /dev/null +++ b/Flow.Launcher.Test/PluginHotReloadTest.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Core.Plugin; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using NUnit.Framework; + +namespace Flow.Launcher.Test +{ + [TestFixture] + public class PluginHotReloadTest + { + [OneTimeSetUp] + public void SetUp() + { + var api = new Mock(); + api.Setup(m => m.StopwatchLogDebugAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns, string>(async (_, _, action, _) => + { + await action(); + return 0L; + }); + + try + { + Ioc.Default.ConfigureServices(new ServiceCollection() + .AddSingleton(api.Object) + .AddSingleton(new Settings()) + .BuildServiceProvider()); + } + catch (InvalidOperationException) + { + // Ioc.Default can only be configured once per process; another fixture got there first + } + } + + private class FakePlugin : IAsyncPlugin, IContextMenu, IAsyncHomeQuery + { + public Task InitAsync(PluginInitContext context) => Task.CompletedTask; + + public Task> QueryAsync(Query query, CancellationToken token) => Task.FromResult(new List()); + + public List LoadContextMenus(Result selectedResult) => new(); + + public Task> HomeQueryAsync(CancellationToken token) => Task.FromResult(new List()); + } + + private static PluginPair CreateFakePluginPair(string id, string actionKeyword) + { + return new PluginPair + { + Plugin = new FakePlugin(), + Metadata = new PluginMetadata + { + ID = id, + Name = "HotReloadFakePlugin", + Language = AllowedLanguage.Executable, + ActionKeywords = new List { actionKeyword }, + ActionKeyword = actionKeyword, + IcoPath = string.Empty, + // Must be set before PluginDirectory, whose setter combines it into ExecuteFilePath + ExecuteFileName = "run.exe", + PluginDirectory = Path.GetTempPath() + } + }; + } + + [Test] + public async Task GivenInitializedPluginWhenUnloadedThenAllRegistrationsShouldBeRemovedAsync() + { + // Given + const string id = "HOTRELOAD00000000000000000000001"; + const string keyword = "hotreloadtest"; + var pair = CreateFakePluginPair(id, keyword); + + await PluginManager.InitializePluginAsync(pair); + + Assert.That(PluginManager.GetAllInitializedPlugins(includeFailed: true).Any(p => p.Metadata.ID == id), Is.True); + Assert.That(PluginManager.IsHomePlugin(id), Is.True); + Assert.That(PluginManager.GetNonGlobalPlugins().TryGetValue(keyword, out var registered), Is.True); + Assert.That(registered.Any(p => p.Metadata.ID == id), Is.True); + + // When + var unloaded = await PluginManager.UnloadPluginAsync(pair); + + // Then + Assert.That(unloaded, Is.True); + Assert.That(PluginManager.GetAllInitializedPlugins(includeFailed: true).Any(p => p.Metadata.ID == id), Is.False); + Assert.That(PluginManager.IsHomePlugin(id), Is.False); + Assert.That(PluginManager.GetNonGlobalPlugins().ContainsKey(keyword), Is.False); + } + + [Test] + public async Task GivenDotNetPluginAssemblyWhenUnloadedThenLoadContextShouldBeCollectedAsync() + { + // Given an assembly that is present in the test output but not loaded into the default context + var assemblyPath = Path.Combine(AppContext.BaseDirectory, "Flow.Launcher.Plugin.Shell.dll"); + Assert.That(File.Exists(assemblyPath), Is.True, $"Test assembly not found: {assemblyPath}"); + + // When + var weakReference = LoadAndUnload(assemblyPath); + var unloaded = await PluginAssemblyLoader.WaitForUnloadAsync(weakReference); + + // Then + Assert.That(unloaded, Is.True, "Collectible load context was not collected after unload"); + } + + // Kept in a separate non-inlined method so no strong reference to the load context or its + // assembly survives in the test method's stack frame, which would prevent collection + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference LoadAndUnload(string assemblyPath) + { + var loader = new PluginAssemblyLoader(assemblyPath); + var assembly = loader.LoadAssemblyAndDependencies(); + Assert.That(assembly, Is.Not.Null); + return PluginAssemblyLoader.UnloadAndGetWeakReference(loader); + } + + [Test] + public void GivenPluginDirectoryWhenParsedThenMetadataShouldBeLoaded() + { + // Given + var pluginDirectory = Path.Combine(Path.GetTempPath(), $"HotReloadMetadataTest-{Guid.NewGuid()}"); + Directory.CreateDirectory(pluginDirectory); + try + { + const string executeFileName = "run.exe"; + File.WriteAllText(Path.Combine(pluginDirectory, executeFileName), string.Empty); + File.WriteAllText(Path.Combine(pluginDirectory, "plugin.json"), $$""" + { + "ID": "HOTRELOAD00000000000000000000002", + "ActionKeyword": "hrmeta", + "Name": "HotReloadMetadataTest", + "Author": "test", + "Version": "1.0.0", + "Language": "Executable", + "Website": "", + "IcoPath": "icon.png", + "ExecuteFileName": "{{executeFileName}}" + } + """); + + // When + var metadata = PluginConfig.GetPluginMetadata(pluginDirectory); + + // Then + Assert.That(metadata, Is.Not.Null); + Assert.That(metadata.ID, Is.EqualTo("HOTRELOAD00000000000000000000002")); + Assert.That(metadata.PluginDirectory, Is.EqualTo(pluginDirectory)); + Assert.That(metadata.ActionKeywords, Is.EqualTo(new List { "hrmeta" })); + Assert.That(metadata.Language, Is.EqualTo("Executable")); + } + finally + { + Directory.Delete(pluginDirectory, true); + } + } + } +} diff --git a/Flow.Launcher/Flow.Launcher.csproj b/Flow.Launcher/Flow.Launcher.csproj index 2a1332b4260..ab3b7ed4925 100644 --- a/Flow.Launcher/Flow.Launcher.csproj +++ b/Flow.Launcher/Flow.Launcher.csproj @@ -179,7 +179,7 @@ - + diff --git a/Flow.Launcher/Languages/en.xaml b/Flow.Launcher/Languages/en.xaml index cf67bffeafa..dee4cd4c993 100644 --- a/Flow.Launcher/Languages/en.xaml +++ b/Flow.Launcher/Languages/en.xaml @@ -185,6 +185,8 @@ Overrides other programs' 'Always on Top' setting and displays Flow in the foremost position. Restart after modifying plugin via Plugin Store Restart Flow Launcher automatically after installing/uninstalling/updating plugin via Plugin Store + Hot reload after modifying plugin via Plugin Store + Reload plugins in place after installing/uninstalling/updating them via Plugin Store, without restarting Flow Launcher. If a reload fails, a restart is still required. Show unknown source warning Show warning when installing plugins from unknown sources Auto update plugins @@ -267,6 +269,10 @@ Plugin {0} successfully installed. Please restart Flow. Plugin {0} successfully uninstalled. Please restart Flow. Plugin {0} successfully updated. Please restart Flow. + Plugin {0} successfully installed and ready to use. + The ID in the package's plugin.json does not match the plugin being installed. + Plugin {0} successfully uninstalled. + Plugin {0} successfully updated and ready to use. Plugin install {0} by {1} {2}{2}Would you like to install this plugin? Plugin uninstall @@ -287,6 +293,7 @@ Update plugins Check plugin updates Plugins are successfully updated. Please restart Flow. + Plugins are successfully updated and ready to use. Theme diff --git a/Flow.Launcher/PublicAPIInstance.cs b/Flow.Launcher/PublicAPIInstance.cs index d3b84b937c0..11a7e106cbd 100644 --- a/Flow.Launcher/PublicAPIInstance.cs +++ b/Flow.Launcher/PublicAPIInstance.cs @@ -626,6 +626,10 @@ public bool InstallPlugin(UserPlugin plugin, string zipFilePath) => public Task UninstallPluginAsync(PluginMetadata pluginMetadata, bool removePluginSettings = false) => PluginManager.UninstallPluginAsync(pluginMetadata, removePluginSettings); + public Task ReloadPluginAsync(string id) => PluginManager.ReloadPluginAsync(id); + + public Task ReloadAllPluginsAsync() => PluginManager.ReloadAllPluginsAsync(); + public long StopwatchLogDebug(string className, string message, Action action, [CallerMemberName] string methodName = "") => Stopwatch.Debug(className, message, action, methodName); diff --git a/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml b/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml index 5779071534f..a38e81a326d 100644 --- a/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml +++ b/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml @@ -285,6 +285,20 @@ OnContent="{DynamicResource enable}" /> + + + + + + + + _resultsUpdateChannelWriter; + + private readonly ConcurrentDictionary _resultsUpdatedHandlers = new(); private Task _resultsViewUpdateTask; private readonly IReadOnlyList _emptyResult = new List(); @@ -284,7 +286,7 @@ public void RegisterResultsUpdatedEvent(PluginPair pair) { if (pair.Plugin is not IResultUpdated plugin) return; - plugin.ResultsUpdated += (s, e) => + ResultUpdatedEventHandler handler = (s, e) => { if (_updateQuery == null || e.Query.OriginalQuery != _updateQuery.OriginalQuery || e.Token.IsCancellationRequested) { @@ -324,6 +326,22 @@ public void RegisterResultsUpdatedEvent(PluginPair pair) App.API.LogError(ClassName, "Unable to add item to Result Update Queue"); } }; + + // Detach any stale handler left by a previous instance that was not unloaded through + // the hot reload path, so the new instance always ends up subscribed exactly once + UnregisterResultsUpdatedEvent(pair); + + // Track the handler so it can be detached when the plugin is unloaded or reloaded + _resultsUpdatedHandlers[pair.Metadata.ID] = (plugin, handler); + plugin.ResultsUpdated += handler; + } + + public void UnregisterResultsUpdatedEvent(PluginPair pair) + { + if (_resultsUpdatedHandlers.TryRemove(pair.Metadata.ID, out var entry)) + { + entry.Plugin.ResultsUpdated -= entry.Handler; + } } private async Task RegisterClockAndDateUpdateAsync() diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/en.xaml b/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/en.xaml index fa2e65240ae..cf398b7fb91 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/en.xaml +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/en.xaml @@ -44,6 +44,10 @@ Plugin {0} successfully uninstalled. Please restart Flow. Plugin {0} successfully updated. Please restart Flow. {0} plugins successfully updated. Please restart Flow. + Plugin {0} successfully installed and ready to use. + Plugin {0} successfully uninstalled. + Plugin {0} successfully updated and ready to use. + {0} plugins successfully updated and ready to use. Plugin {0} has already been modified. Please restart Flow before making any further changes. {0} modified already Please restart Flow before making any further changes @@ -69,4 +73,5 @@ Install from unknown source warning Restart Flow Launcher automatically after installing/uninstalling/updating plugin via Plugins Manager + Reload plugins in place after installing/uninstalling/updating them via Plugins Manager, without restarting Flow Launcher \ No newline at end of file diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs b/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs index efbe8d7ba7d..bd70016cac1 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs @@ -123,7 +123,7 @@ internal async Task InstallOrUpdateAsync(UserPlugin plugin) } string message; - if (Settings.AutoRestartAfterChanging) + if (Settings.AutoRestartAfterChanging && !Settings.HotReloadAfterChanging) { message = string.Format(Context.API.GetTranslation("plugin_pluginsmanager_install_prompt"), plugin.Name, plugin.Author, @@ -190,7 +190,13 @@ await DownloadFileAsync( return; } - if (Settings.AutoRestartAfterChanging) + if (Settings.HotReloadAfterChanging && await Context.API.ReloadPluginAsync(plugin.ID)) + { + Context.API.ShowMsg(Context.API.GetTranslation("plugin_pluginsmanager_installing_plugin"), + string.Format(Context.API.GetTranslation("plugin_pluginsmanager_install_success_hot_reload"), + plugin.Name)); + } + else if (Settings.AutoRestartAfterChanging) { Context.API.ShowMsg(Context.API.GetTranslation("plugin_pluginsmanager_installing_plugin"), string.Format(Context.API.GetTranslation("plugin_pluginsmanager_install_success_restart"), @@ -321,7 +327,7 @@ where string.Compare(existingPlugin.Metadata.Version, pluginUpdateSource.Version } string message; - if (Settings.AutoRestartAfterChanging) + if (Settings.AutoRestartAfterChanging && !Settings.HotReloadAfterChanging) { message = string.Format( Context.API.GetTranslation("plugin_pluginsmanager_update_prompt"), @@ -376,7 +382,16 @@ await DownloadFileAsync( return; } - if (Settings.AutoRestartAfterChanging) + if (Settings.HotReloadAfterChanging && await Context.API.ReloadPluginAsync(x.ID)) + { + Context.API.ShowMsg( + Context.API.GetTranslation("plugin_pluginsmanager_update_title"), + string.Format( + Context.API.GetTranslation( + "plugin_pluginsmanager_update_success_hot_reload"), + x.Name)); + } + else if (Settings.AutoRestartAfterChanging) { Context.API.ShowMsg( Context.API.GetTranslation("plugin_pluginsmanager_update_title"), @@ -448,7 +463,7 @@ await DownloadFileAsync( } string message; - if (Settings.AutoRestartAfterChanging) + if (Settings.AutoRestartAfterChanging && !Settings.HotReloadAfterChanging) { message = string.Format( Context.API.GetTranslation("plugin_pluginsmanager_update_all_prompt"), @@ -469,6 +484,7 @@ await DownloadFileAsync( } var anyPluginSuccess = false; + var allPluginsHotReloaded = true; await Task.WhenAll(resultsForUpdate.Select(async plugin => { var downloadToFilePath = Path.Combine(Path.GetTempPath(), @@ -484,16 +500,27 @@ await DownloadFileAsync( // check if user cancelled download before installing plugin if (cts.IsCancellationRequested) + { + allPluginsHotReloaded = false; return; - else - if (!await Context.API.UpdatePluginAsync(plugin.PluginExistingMetadata, plugin.PluginNewUserPlugin, - downloadToFilePath)) - return; + } + else if (!await Context.API.UpdatePluginAsync(plugin.PluginExistingMetadata, plugin.PluginNewUserPlugin, + downloadToFilePath)) + { + allPluginsHotReloaded = false; + return; + } anyPluginSuccess = true; + + if (!Settings.HotReloadAfterChanging || !await Context.API.ReloadPluginAsync(plugin.ID)) + { + allPluginsHotReloaded = false; + } } catch (Exception ex) { + allPluginsHotReloaded = false; Context.API.LogException(ClassName, $"Update failed for {plugin.Name}", ex.InnerException); Context.API.ShowMsgError( Context.API.GetTranslation("plugin_pluginsmanager_install_error_title"), @@ -505,7 +532,14 @@ await DownloadFileAsync( if (!anyPluginSuccess) return false; - if (Settings.AutoRestartAfterChanging) + if (allPluginsHotReloaded) + { + Context.API.ShowMsg(Context.API.GetTranslation("plugin_pluginsmanager_update_title"), + string.Format( + Context.API.GetTranslation("plugin_pluginsmanager_update_all_success_hot_reload"), + resultsForUpdate.Count)); + } + else if (Settings.AutoRestartAfterChanging) { Context.API.ShowMsg(Context.API.GetTranslation("plugin_pluginsmanager_update_title"), string.Format( @@ -774,7 +808,7 @@ internal List RequestUninstall(string search) } string message; - if (Settings.AutoRestartAfterChanging) + if (Settings.AutoRestartAfterChanging && !Settings.HotReloadAfterChanging) { message = string.Format( Context.API.GetTranslation("plugin_pluginsmanager_uninstall_prompt"), @@ -798,7 +832,17 @@ internal List RequestUninstall(string search) { return false; } - if (Settings.AutoRestartAfterChanging) + if (Settings.HotReloadAfterChanging && !Context.API.PluginModified(x.Metadata.ID)) + { + // The plugin was fully unloaded and removed, so no restart is needed + Context.API.ShowMsg( + Context.API.GetTranslation("plugin_pluginsmanager_uninstall_title"), + string.Format( + Context.API.GetTranslation( + "plugin_pluginsmanager_uninstall_success_hot_reload"), + x.Metadata.Name)); + } + else if (Settings.AutoRestartAfterChanging) { Context.API.RestartApp(); } diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/Settings.cs b/Plugins/Flow.Launcher.Plugin.PluginsManager/Settings.cs index f23ff71f01f..599d594e95a 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/Settings.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/Settings.cs @@ -9,7 +9,9 @@ internal class Settings internal const string UpdateCommand = "update"; public bool WarnFromUnknownSource { get; set; } = true; - + public bool AutoRestartAfterChanging { get; set; } = false; + + public bool HotReloadAfterChanging { get; set; } = true; } } diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/ViewModels/SettingsViewModel.cs b/Plugins/Flow.Launcher.Plugin.PluginsManager/ViewModels/SettingsViewModel.cs index 1c71507fc54..cd0bdf7a7fd 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/ViewModels/SettingsViewModel.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/ViewModels/SettingsViewModel.cs @@ -19,9 +19,15 @@ public bool WarnFromUnknownSource } public bool AutoRestartAfterChanging - { + { get => Settings.AutoRestartAfterChanging; set => Settings.AutoRestartAfterChanging = value; } + + public bool HotReloadAfterChanging + { + get => Settings.HotReloadAfterChanging; + set => Settings.HotReloadAfterChanging = value; + } } } diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/Views/PluginsManagerSettings.xaml b/Plugins/Flow.Launcher.Plugin.PluginsManager/Views/PluginsManagerSettings.xaml index 5b767eb53c8..066057e7204 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/Views/PluginsManagerSettings.xaml +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/Views/PluginsManagerSettings.xaml @@ -12,6 +12,7 @@ + + diff --git a/Plugins/Flow.Launcher.Plugin.Sys/Languages/en.xaml b/Plugins/Flow.Launcher.Plugin.Sys/Languages/en.xaml index 33b936ead9d..5846387b477 100644 --- a/Plugins/Flow.Launcher.Plugin.Sys/Languages/en.xaml +++ b/Plugins/Flow.Launcher.Plugin.Sys/Languages/en.xaml @@ -26,6 +26,7 @@ Restart Flow Launcher Settings Reload Plugin Data + Reload All Plugins Check For Update Open Log Location Flow Launcher Tips @@ -51,6 +52,7 @@ Hibernate computer Save all Flow Launcher settings Refreshes plugin data with new content + Fully reloads all plugins in place; a restart may still be needed if a reload fails Open Flow Launcher's log location Check for new Flow Launcher update Visit Flow Launcher's documentation for more help and how to use tips @@ -62,6 +64,8 @@ Success All Flow Launcher settings saved Reloaded all applicable plugin data + Reloaded all plugins + Some plugins failed to reload. A restart may be required. Are you sure you want to shut the computer down? Are you sure you want to restart the computer? Are you sure you want to restart the computer with Advanced Boot Options? diff --git a/Plugins/Flow.Launcher.Plugin.Sys/Main.cs b/Plugins/Flow.Launcher.Plugin.Sys/Main.cs index 78effb6d21f..59e73e01c49 100644 --- a/Plugins/Flow.Launcher.Plugin.Sys/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Sys/Main.cs @@ -36,6 +36,7 @@ public class Main : IPlugin, ISettingProvider, IPluginI18n {"Restart Flow Launcher", "flowlauncher_plugin_sys_restart_cmd"}, {"Settings", "flowlauncher_plugin_sys_setting_cmd"}, {"Reload Plugin Data", "flowlauncher_plugin_sys_reload_plugin_data_cmd"}, + {"Reload All Plugins", "flowlauncher_plugin_sys_reload_all_plugins_cmd"}, {"Check For Update", "flowlauncher_plugin_sys_check_for_update_cmd"}, {"Open Log Location", "flowlauncher_plugin_sys_open_log_location_cmd"}, {"Flow Launcher Tips", "flowlauncher_plugin_sys_open_docs_tips_cmd"}, @@ -141,6 +142,15 @@ public void Init(PluginInitContext context) // Remove _cmd in the last of the strings KeywordDescriptionMappings[key] = KeywordTitleMappings[key][..^4]; } + + // Add commands introduced after the user's settings were first saved + foreach (var command in new Settings().Commands) + { + if (_settings.Commands.All(x => x.Key != command.Key)) + { + _settings.Commands.Add(command); + } + } } private void UpdateLocalizedNameDescription(bool force) @@ -439,6 +449,38 @@ private List Commands(Query query) } }, new Result + { + Title = "Reload All Plugins", + IcoPath = "Images\\app.png", + Glyph = new GlyphInfo (FontFamily:"/Resources/#Segoe Fluent Icons", Glyph:"\xe72c"), + Action = c => + { + // Hide the window first then show msg after done because the reload could take a while, so not to make user think it's frozen. + Context.API.HideMainWindow(); + _ = Context.API.ReloadAllPluginsAsync().ContinueWith(t => + { + if (t.Status == TaskStatus.RanToCompletion && t.Result) + { + Context.API.ShowMsg( + Localize.flowlauncher_plugin_sys_dlgtitle_success(), + Localize.flowlauncher_plugin_sys_dlgtext_all_plugins_reloaded()); + } + else + { + if (t.Exception != null) + { + Context.API.LogException(ClassName, "Failed to reload all plugins", t.Exception); + } + Context.API.ShowMsgError( + Localize.flowlauncher_plugin_sys_reload_all_plugins_cmd(), + Localize.flowlauncher_plugin_sys_dlgtext_all_plugins_reload_failed()); + } + }, + TaskScheduler.Current); + return true; + } + }, + new Result { Title = "Check For Update", Glyph = new GlyphInfo (FontFamily:"/Resources/#Segoe Fluent Icons", Glyph:"\xede4"), diff --git a/Plugins/Flow.Launcher.Plugin.Sys/Settings.cs b/Plugins/Flow.Launcher.Plugin.Sys/Settings.cs index c24c51961d8..baccc3e3418 100644 --- a/Plugins/Flow.Launcher.Plugin.Sys/Settings.cs +++ b/Plugins/Flow.Launcher.Plugin.Sys/Settings.cs @@ -91,6 +91,11 @@ public Settings() Keyword = "Reload Plugin Data" }, new() + { + Key = "Reload All Plugins", + Keyword = "Reload All Plugins" + }, + new() { Key = "Check For Update", Keyword = "Check For Update"