using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using Flow.Launcher.Core.ExternalPlugins;
using Flow.Launcher.Core.Resource;
using Flow.Launcher.Infrastructure;
using Flow.Launcher.Infrastructure.DialogJump;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin;
using Flow.Launcher.Plugin.SharedCommands;
using IRemovable = Flow.Launcher.Core.Storage.IRemovable;
using ISavable = Flow.Launcher.Plugin.ISavable;
namespace Flow.Launcher.Core.Plugin
{
/// <summary>
/// Class for co-ordinating and managing all plugin lifecycle.
/// </summary>
public static class PluginManager
{
private static readonly string ClassName = nameof(PluginManager);
private static readonly ConcurrentDictionary<string, PluginPair> _allLoadedPlugins = [];
private static readonly ConcurrentDictionary<string, PluginPair> _allInitializedPlugins = [];
private static readonly ConcurrentDictionary<string, PluginPair> _initFailedPlugins = [];
private static readonly ConcurrentDictionary<string, PluginPair> _globalPlugins = [];
private static readonly ConcurrentDictionary<string, PluginPair> _nonGlobalPlugins = [];
private static PluginsSettings Settings;
private static readonly ConcurrentBag<string> ModifiedPlugins = [];
private static readonly ConcurrentBag<PluginPair> _contextMenuPlugins = [];
private static readonly ConcurrentBag<PluginPair> _homePlugins = [];
private static readonly ConcurrentBag<PluginPair> _translationPlugins = [];
private static readonly ConcurrentBag<PluginPair> _externalPreviewPlugins = [];
/// <summary>
/// Directories that will hold Flow Launcher plugin directory
/// </summary>
public static readonly string[] Directories =
[
Constant.PreinstalledDirectory, DataLocation.PluginsDirectory
];
#region Save & Dispose & Reload Plugin
/// <summary>
/// Save json and ISavable
/// </summary>
public static void Save()
{
foreach (var pluginPair in GetAllInitializedPlugins(includeFailed: false))
{
var savable = pluginPair.Plugin as ISavable;
try
{
savable?.Save();
}
catch (Exception e)
{
PublicApi.Instance.LogException(ClassName, $"Failed to save plugin {pluginPair.Metadata.Name}", e);
}
}
PublicApi.Instance.SavePluginSettings();
PublicApi.Instance.SavePluginCaches();
}
public static async ValueTask DisposePluginsAsync()
{
// Still call dispose for all plugins even if initialization failed, so that we can clean up resources
foreach (var pluginPair in GetAllInitializedPlugins(includeFailed: true))
{
await DisposePluginAsync(pluginPair);
}
}
private static async Task DisposePluginAsync(PluginPair pluginPair)
{
try
{
switch (pluginPair.Plugin)
{
case IDisposable disposable:
disposable.Dispose();
break;
case IAsyncDisposable asyncDisposable:
await asyncDisposable.DisposeAsync();
break;
}
}
catch (Exception e)
{
PublicApi.Instance.LogException(ClassName, $"Failed to dispose plugin {pluginPair.Metadata.Name}", e);
}
}
public static async Task ReloadDataAsync()
{
await Task.WhenAll([.. GetAllInitializedPlugins(includeFailed: false).Select(plugin => plugin.Plugin switch
{
IReloadable p => Task.Run(p.ReloadData),
IAsyncReloadable p => p.ReloadDataAsync(),
_ => Task.CompletedTask,
})]);
}
#endregion
#region External Preview
public static async Task OpenExternalPreviewAsync(string path, bool sendFailToast = true)
{
await Task.WhenAll([.. GetAllInitializedPlugins(includeFailed: false).Select(plugin => plugin.Plugin switch
{
IAsyncExternalPreview p => p.OpenPreviewAsync(path, sendFailToast),
_ => Task.CompletedTask,
})]);
}
public static async Task CloseExternalPreviewAsync()
{
await Task.WhenAll([.. GetAllInitializedPlugins(includeFailed: false).Select(plugin => plugin.Plugin switch
{
IAsyncExternalPreview p => p.ClosePreviewAsync(),
_ => Task.CompletedTask,
})]);
}
public static async Task SwitchExternalPreviewAsync(string path, bool sendFailToast = true)
{
await Task.WhenAll([.. GetAllInitializedPlugins(includeFailed: false).Select(plugin => plugin.Plugin switch
{
IAsyncExternalPreview p => p.SwitchPreviewAsync(path, sendFailToast),
_ => Task.CompletedTask,
})]);
}
public static bool UseExternalPreview()
{
return GetExternalPreviewPlugins().Any(x => !x.Metadata.Disabled);
}
public static bool AllowAlwaysPreview()
{
var plugin = GetExternalPreviewPlugins().FirstOrDefault(x => !x.Metadata.Disabled);
if (plugin is null)
return false;
return ((IAsyncExternalPreview)plugin.Plugin).AllowAlwaysPreview();
}
private static IList<PluginPair> GetExternalPreviewPlugins()
{
return [.. _externalPreviewPlugins.Where(p => !PluginModified(p.Metadata.ID))];
}
#endregion
#region Constructor
static PluginManager()
{
// validate user directory
Directory.CreateDirectory(DataLocation.PluginsDirectory);
// force old plugins use new python binding
DeletePythonBinding();
}
private static void DeletePythonBinding()
{
const string binding = "flowlauncher.py";
foreach (var subDirectory in Directory.GetDirectories(DataLocation.PluginsDirectory))
{
try
{
File.Delete(Path.Combine(subDirectory, binding));
}
catch (Exception e)
{
PublicApi.Instance.LogDebug(ClassName, $"Failed to delete {binding} in {subDirectory}: {e.Message}");
}
}
}
#endregion
#region Load & Initialize Plugins
/// <summary>
/// Load plugins from the directories specified in Directories.
/// </summary>
/// <param name="settings"></param>
public static void LoadPlugins(PluginsSettings settings)
{
var metadatas = PluginConfig.Parse(Directories);
Settings = settings;
Settings.UpdatePluginSettings(metadatas);
// Load plugins
var allLoadedPlugins = PluginsLoader.Plugins(metadatas, Settings);
foreach (var plugin in allLoadedPlugins)
{
if (plugin != null)
{
if (!_allLoadedPlugins.TryAdd(plugin.Metadata.ID, plugin))
{
PublicApi.Instance.LogError(ClassName, $"Plugin with ID {plugin.Metadata.ID} already loaded");
}
}
}
// Since dotnet plugins need to get assembly name first, we should update plugin directory after loading plugins
UpdatePluginDirectory(metadatas);
}
private static void UpdatePluginDirectory(List<PluginMetadata> metadatas)
{
foreach (var metadata in metadatas)
{
if (AllowedLanguage.IsDotNet(metadata.Language))
{
if (string.IsNullOrEmpty(metadata.AssemblyName))
{
PublicApi.Instance.LogWarn(ClassName, $"AssemblyName is empty for plugin with metadata: {metadata.Name}");
continue; // Skip if AssemblyName is not set, which can happen for erroneous plugins
}
metadata.PluginSettingsDirectoryPath = Path.Combine(DataLocation.PluginSettingsDirectory, metadata.AssemblyName);
metadata.PluginCacheDirectoryPath = Path.Combine(DataLocation.PluginCacheDirectory, metadata.AssemblyName);
}
else
{
if (string.IsNullOrEmpty(metadata.Name))
{
PublicApi.Instance.LogWarn(ClassName, $"Name is empty for plugin with metadata: {metadata.Name}");
continue; // Skip if Name is not set, which can happen for erroneous plugins
}
metadata.PluginSettingsDirectoryPath = Path.Combine(DataLocation.PluginSettingsDirectory, metadata.Name);
metadata.PluginCacheDirectoryPath = Path.Combine(DataLocation.PluginCacheDirectory, metadata.Name);
}
}
}
/// <summary>
/// Initialize all plugins asynchronously.
/// </summary>
/// <param name="register">The register to register results updated event for each plugin.</param>
/// <returns>return the list of failed to init plugins or null for none</returns>
public static async Task InitializePluginsAsync(IResultUpdateRegister register)
{
var initTasks = _allLoadedPlugins.Select(x => Task.Run(async () =>
{
var pair = x.Value;
// 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)));
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");
}
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;
}
// Register ResultsUpdated event so that plugin query can use results updated interface
register.RegisterResultsUpdatedEvent(pair);
// Update plugin metadata translation after the plugin is initialized with IPublicAPI instance
Internationalization.UpdatePluginMetadataTranslation(pair);
// Add plugin to Dialog Jump plugin list after the plugin is initialized
DialogJump.InitializeDialogJumpPlugin(pair);
// Add plugin to lists after the plugin is initialized
AddPluginToLists(pair);
}));
await Task.WhenAll(initTasks);
if (!_initFailedPlugins.IsEmpty)
{
var failed = string.Join(",", _initFailedPlugins.Values.Select(x => x.Metadata.Name));
PublicApi.Instance.ShowMsg(
Localize.failedToInitializePluginsTitle(),
Localize.failedToInitializePluginsMessage(failed),
"",
false
);
}
}
private static void RegisterPluginActionKeywords(PluginPair pair)
{
// set distinct on each plugin's action keywords helps only firing global(*) and action keywords once where a plugin
// has multiple global and action keywords because we will only add them here once.
foreach (var actionKeyword in pair.Metadata.ActionKeywords.Distinct())
{
switch (actionKeyword)
{
case Query.GlobalPluginWildcardSign:
_globalPlugins.TryAdd(pair.Metadata.ID, pair);
break;
default:
_nonGlobalPlugins.TryAdd(actionKeyword, pair);
break;
}
}
}
private static void AddPluginToLists(PluginPair pair)
{
if (pair.Plugin is IContextMenu)
{
_contextMenuPlugins.Add(pair);
}
if (pair.Plugin is IAsyncHomeQuery)
{
_homePlugins.Add(pair);
}
if (pair.Plugin is IPluginI18n)
{
_translationPlugins.Add(pair);
}
if (pair.Plugin is IAsyncExternalPreview)
{
_externalPreviewPlugins.Add(pair);
}
_allInitializedPlugins.TryAdd(pair.Metadata.ID, pair);
}
#endregion
#region Validate & Query Plugins
public static ICollection<PluginPair> ValidPluginsForQuery(Query query, bool dialogJump)
{
if (query is null)
return Array.Empty<PluginPair>();
if (!_nonGlobalPlugins.TryGetValue(query.ActionKeyword, out var plugin))
{
if (dialogJump)
return [.. GetGlobalPlugins().Where(p => p.Plugin is IAsyncDialogJump && !PluginModified(p.Metadata.ID))];
else
return [.. GetGlobalPlugins().Where(p => !PluginModified(p.Metadata.ID))];
}
if (dialogJump && plugin.Plugin is not IAsyncDialogJump)
return Array.Empty<PluginPair>();
if (PluginModified(plugin.Metadata.ID))
return Array.Empty<PluginPair>();
return [plugin];
}
public static ICollection<PluginPair> ValidPluginsForHomeQuery()
{
return [.. _homePlugins.Where(p => !PluginModified(p.Metadata.ID))];
}
public static async Task<List<Result>> QueryForPluginAsync(PluginPair pair, Query query, CancellationToken token)
{
var results = new List<Result>();
var metadata = pair.Metadata;
if (IsPluginInitializing(metadata))
{
Result r = new()
{
Title = Localize.pluginStillInitializing(metadata.Name),
SubTitle = Localize.pluginStillInitializingSubtitle(),
AutoCompleteText = query.TrimmedQuery,
IcoPath = metadata.IcoPath,
PluginDirectory = metadata.PluginDirectory,
ActionKeywordAssigned = query.ActionKeyword,
PluginID = metadata.ID,
OriginQuery = query,
Action = _ =>
{
PublicApi.Instance.ReQuery();
return false;
}
};
results.Add(r);
return results;
}
try
{
var milliseconds = await PublicApi.Instance.StopwatchLogDebugAsync(ClassName, $"Cost for {metadata.Name}",
async () => results = await pair.Plugin.QueryAsync(query, token).ConfigureAwait(false));
token.ThrowIfCancellationRequested();
if (results == null)
return null;
UpdatePluginMetadata(results, metadata, query);
metadata.QueryCount += 1;
metadata.AvgQueryTime =
metadata.QueryCount == 1 ? milliseconds : (metadata.AvgQueryTime + milliseconds) / 2;
token.ThrowIfCancellationRequested();
}
catch (OperationCanceledException)
{
// null will be fine since the results will only be added into queue if the token hasn't been cancelled
return null;
}
catch (Exception e)
{
Result r = new()
{
Title = Localize.pluginFailedToRespond(metadata.Name),
SubTitle = Localize.pluginFailedToRespondSubtitle(),
AutoCompleteText = query.TrimmedQuery,
IcoPath = Constant.ErrorIcon,
PluginDirectory = metadata.PluginDirectory,
ActionKeywordAssigned = query.ActionKeyword,
PluginID = metadata.ID,
OriginQuery = query,
Action = _ => { throw new FlowPluginException(metadata, e);}
};
results.Add(r);
}
return results;
}
public static async Task<List<Result>> QueryHomeForPluginAsync(PluginPair pair, Query query, CancellationToken token)
{
var results = new List<Result>();
var metadata = pair.Metadata;
if (IsPluginInitializing(metadata))
{
Result r = new()
{
Title = Localize.pluginStillInitializing(metadata.Name),
SubTitle = Localize.pluginStillInitializingSubtitle(),
AutoCompleteText = query.TrimmedQuery,
IcoPath = metadata.IcoPath,
PluginDirectory = metadata.PluginDirectory,
ActionKeywordAssigned = query.ActionKeyword,
PluginID = metadata.ID,
OriginQuery = query,
Action = _ =>
{
PublicApi.Instance.ReQuery();
return false;
}
};
results.Add(r);
return results;
}
try
{
var milliseconds = await PublicApi.Instance.StopwatchLogDebugAsync(ClassName, $"Cost for {metadata.Name}",
async () => results = await ((IAsyncHomeQuery)pair.Plugin).HomeQueryAsync(token).ConfigureAwait(false));
token.ThrowIfCancellationRequested();
if (results == null)
return null;
UpdatePluginMetadata(results, metadata, query);
token.ThrowIfCancellationRequested();
}
catch (OperationCanceledException)
{
// null will be fine since the results will only be added into queue if the token hasn't been cancelled
return null;
}
catch (Exception e)
{
PublicApi.Instance.LogException(ClassName, $"Failed to query home for plugin: {metadata.Name}", e);
return null;
}
return results;
}
public static async Task<List<DialogJumpResult>> QueryDialogJumpForPluginAsync(PluginPair pair, Query query, CancellationToken token)
{
var results = new List<DialogJumpResult>();
var metadata = pair.Metadata;
if (IsPluginInitializing(metadata))
{
// null will be fine since the results will only be added into queue if the token hasn't been cancelled
return null;
}
try
{
var milliseconds = await PublicApi.Instance.StopwatchLogDebugAsync(ClassName, $"Cost for {metadata.Name}",
async () => results = await ((IAsyncDialogJump)pair.Plugin).QueryDialogJumpAsync(query, token).ConfigureAwait(false));
token.ThrowIfCancellationRequested();
if (results == null)
return null;
UpdatePluginMetadata(results, metadata, query);
token.ThrowIfCancellationRequested();
}
catch (OperationCanceledException)
{
// null will be fine since the results will only be added into queue if the token hasn't been cancelled
return null;
}
catch (Exception e)
{
PublicApi.Instance.LogException(ClassName, $"Failed to query Dialog Jump for plugin: {metadata.Name}", e);
return null;
}
return results;
}
private static bool IsPluginInitializing(PluginMetadata metadata)
{
return !_allInitializedPlugins.ContainsKey(metadata.ID);
}
#endregion
#region Get Plugin List
public static List<PluginPair> GetAllLoadedPlugins()
{
return [.. _allLoadedPlugins.Values];
}
public static List<PluginPair> GetAllInitializedPlugins(bool includeFailed)
{
if (includeFailed)
{
return [.. _allInitializedPlugins.Values];
}
else
{
return [.. _allInitializedPlugins.Values
.Where(p => !_initFailedPlugins.ContainsKey(p.Metadata.ID))];
}
}
private static List<PluginPair> GetGlobalPlugins()
{
return [.. _globalPlugins.Values];
}
public static Dictionary<string, PluginPair> GetNonGlobalPlugins()
{
return _nonGlobalPlugins.ToDictionary();
}
public static List<PluginPair> GetTranslationPlugins()
{
return [.. _translationPlugins.Where(p => !PluginModified(p.Metadata.ID))];
}
#endregion
#region Update Metadata & Get Plugin
public static void UpdatePluginMetadata(IReadOnlyList<Result> results, PluginMetadata metadata, Query query)
{
foreach (var r in results)
{
r.PluginDirectory = metadata.PluginDirectory;
r.PluginID = metadata.ID;
r.OriginQuery = query;
// ActionKeywordAssigned is used for constructing MainViewModel's query text auto-complete suggestions
// Plugins may have multi-actionkeywords eg. WebSearches. In this scenario it needs to be overriden on the plugin level
if (metadata.ActionKeywords.Count == 1)
r.ActionKeywordAssigned = query.ActionKeyword;
}
}
/// <summary>
/// get specified plugin, return null if not found
/// </summary>
/// <remarks>
/// Plugin may not be initialized, so do not use its plugin model to execute any commands
/// </remarks>
/// <param name="id"></param>
/// <returns></returns>
public static PluginPair GetPluginForId(string id)
{
return GetAllLoadedPlugins().FirstOrDefault(o => o.Metadata.ID == id);
}
#endregion
#region Get Context Menus
public static List<Result> GetContextMenusForPlugin(Result result)
{
var results = new List<Result>();
var pluginPair = _contextMenuPlugins.Where(p => !PluginModified(p.Metadata.ID)).FirstOrDefault(o => o.Metadata.ID == result.PluginID);
if (pluginPair != null)
{
var plugin = (IContextMenu)pluginPair.Plugin;
try
{
results = plugin.LoadContextMenus(result) ?? results;
foreach (var r in results)
{
r.PluginDirectory = pluginPair.Metadata.PluginDirectory;
r.PluginID = pluginPair.Metadata.ID;
r.OriginQuery = result.OriginQuery;
}
}
catch (Exception e)
{
PublicApi.Instance.LogException(ClassName,
$"Can't load context menus for plugin <{pluginPair.Metadata.Name}>",
e);
}
}
return results;
}
#endregion
#region Check Home Plugin
public static bool IsHomePlugin(string id)
{
return _homePlugins.Where(p => !PluginModified(p.Metadata.ID)).Any(p => p.Metadata.ID == id);
}
#endregion
#region Check Initializing & Init Failed
public static bool IsInitializingOrInitFailed(string id)
{
// Id does not exist in loaded plugins
if (!_allLoadedPlugins.ContainsKey(id)) return false;
// Plugin initialized already
if (_allInitializedPlugins.ContainsKey(id))
{
// Check if the plugin initialization failed
return _initFailedPlugins.ContainsKey(id);
}
// Plugin is still initializing
else
{
return true;
}
}
public static bool IsInitializing(string id)
{
// Id does not exist in loaded plugins
if (!_allLoadedPlugins.ContainsKey(id)) return false;
// Plugin initialized already
if (_allInitializedPlugins.ContainsKey(id))
{
return false;
}
// Plugin is still initializing
else
{
return true;
}
}
public static bool IsInitializationFailed(string id)
{
// Id does not exist in loaded plugins
if (!_allLoadedPlugins.ContainsKey(id)) return false;
// Plugin initialized already
if (_allInitializedPlugins.ContainsKey(id))
{
// Check if the plugin initialization failed
return _initFailedPlugins.ContainsKey(id);
}
// Plugin is still initializing
else
{
return false;
}
}
#endregion
#region Plugin Action Keyword
public static bool ActionKeywordRegistered(string actionKeyword)
{
// this method is only checking for action keywords (defined as not '*') registration
// hence the actionKeyword != Query.GlobalPluginWildcardSign logic
return actionKeyword != Query.GlobalPluginWildcardSign
&& _nonGlobalPlugins.ContainsKey(actionKeyword);
}
/// <summary>
/// used to add action keyword for multiple action keyword plugin
/// e.g. web search
/// </summary>
public static void AddActionKeyword(string id, string newActionKeyword)
{
var plugin = GetPluginForId(id);
if (newActionKeyword == Query.GlobalPluginWildcardSign)
{
_globalPlugins.TryAdd(id, plugin);
}
else
{
_nonGlobalPlugins.AddOrUpdate(newActionKeyword, plugin, (key, oldValue) => plugin);
}
// Update action keywords and action keyword in plugin metadata
plugin.Metadata.ActionKeywords.Add(newActionKeyword);
if (plugin.Metadata.ActionKeywords.Count > 0)
{
plugin.Metadata.ActionKeyword = plugin.Metadata.ActionKeywords[0];
}
else
{
plugin.Metadata.ActionKeyword = string.Empty;
}
}
/// <summary>
/// used to remove action keyword for multiple action keyword plugin
/// e.g. web search
/// </summary>
public static void RemoveActionKeyword(string id, string oldActionkeyword)
{
var plugin = GetPluginForId(id);
if (oldActionkeyword == Query.GlobalPluginWildcardSign
&& // Plugins may have multiple ActionKeywords that are global, eg. WebSearch
plugin.Metadata.ActionKeywords
.Count(x => x == Query.GlobalPluginWildcardSign) == 1)
{
_globalPlugins.TryRemove(id, out _);
}
if (oldActionkeyword != Query.GlobalPluginWildcardSign)
{
_nonGlobalPlugins.TryRemove(oldActionkeyword, out _);
}
// Update action keywords and action keyword in plugin metadata
plugin.Metadata.ActionKeywords.Remove(oldActionkeyword);
if (plugin.Metadata.ActionKeywords.Count > 0)
{
plugin.Metadata.ActionKeyword = plugin.Metadata.ActionKeywords[0];
}
else
{
plugin.Metadata.ActionKeyword = string.Empty;
}
}
#endregion
#region Plugin Install & Uninstall & Update
#region Private Functions
private static string GetContainingFolderPathAfterUnzip(string unzippedParentFolderPath)
{
var unzippedFolderCount = Directory.GetDirectories(unzippedParentFolderPath).Length;
var unzippedFilesCount = Directory.GetFiles(unzippedParentFolderPath).Length;
// adjust path depending on how the plugin is zipped up
// the recommended should be to zip up the folder not the contents
if (unzippedFolderCount == 1 && unzippedFilesCount == 0)
// folder is zipped up, unzipped plugin directory structure: tempPath/unzippedParentPluginFolder/pluginFolderName/
return Directory.GetDirectories(unzippedParentFolderPath)[0];
if (unzippedFilesCount > 1)
// content is zipped up, unzipped plugin directory structure: tempPath/unzippedParentPluginFolder/
return unzippedParentFolderPath;
return string.Empty;
}
private static bool SameOrLesserPluginVersionExists(PluginMetadata metadata)
{
if (!Version.TryParse(metadata.Version, out var newVersion))
return true; // If version is not valid, we assume it is lesser than any existing version
// Get all plugins even if initialization failed so that we can check if the plugin with the same ID exists
return GetAllInitializedPlugins(includeFailed: true).Any(x => x.Metadata.ID == metadata.ID
&& Version.TryParse(x.Metadata.Version, out var version)
&& newVersion <= version);
}
#endregion
#region Public Functions
public static bool PluginModified(string id)
{
return ModifiedPlugins.Contains(id);
}
public static async Task<bool> UpdatePluginAsync(PluginMetadata existingVersion, UserPlugin newVersion, string zipFilePath)
{
if (PluginModified(existingVersion.ID))
{
PublicApi.Instance.ShowMsgError(Localize.pluginModifiedAlreadyTitle(existingVersion.Name),
Localize.pluginModifiedAlreadyMessage());
return false;
}
var installSuccess = InstallPlugin(newVersion, zipFilePath, checkModified: false);
if (!installSuccess) return false;
var uninstallSuccess = await UninstallPluginAsync(existingVersion, removePluginFromSettings: false, removePluginSettings: false, checkModified: false);
if (!uninstallSuccess) return false;
ModifiedPlugins.Add(existingVersion.ID);
return true;
}
public static bool InstallPlugin(UserPlugin plugin, string zipFilePath)
{
return InstallPlugin(plugin, zipFilePath, checkModified: true);
}
public static async Task<bool> UninstallPluginAsync(PluginMetadata plugin, bool removePluginSettings = false)
{
return await UninstallPluginAsync(plugin, removePluginFromSettings: true, removePluginSettings: removePluginSettings, checkModified: true);
}
#endregion
#region Internal Functions
internal static bool InstallPlugin(UserPlugin plugin, string zipFilePath, bool checkModified)
{
if (checkModified && PluginModified(plugin.ID))
{
PublicApi.Instance.ShowMsgError(Localize.pluginModifiedAlreadyTitle(plugin.Name),
Localize.pluginModifiedAlreadyMessage());
return false;
}
// Unzip plugin files to temp folder
var tempFolderPluginPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
System.IO.Compression.ZipFile.ExtractToDirectory(zipFilePath, tempFolderPluginPath);
try
{
if (!plugin.IsFromLocalInstallPath)
File.Delete(zipFilePath);
var pluginFolderPath = GetContainingFolderPathAfterUnzip(tempFolderPluginPath);
var metadataJsonFilePath = string.Empty;
if (File.Exists(Path.Combine(pluginFolderPath, Constant.PluginMetadataFileName)))
metadataJsonFilePath = Path.Combine(pluginFolderPath, Constant.PluginMetadataFileName);
if (string.IsNullOrEmpty(metadataJsonFilePath) || string.IsNullOrEmpty(pluginFolderPath))
{
PublicApi.Instance.ShowMsgError(Localize.failedToInstallPluginTitle(plugin.Name),
Localize.fileNotFoundMessage(pluginFolderPath));
return false;
}
PluginMetadata newMetadata;
try
{
newMetadata = JsonSerializer.Deserialize<PluginMetadata>(File.ReadAllText(metadataJsonFilePath)) ??
throw new JsonException("Deserialized metadata is null");
}
catch (Exception ex)
{
PublicApi.Instance.ShowMsgError(Localize.failedToInstallPluginTitle(plugin.Name),
Localize.pluginJsonInvalidOrCorrupted());
PublicApi.Instance.LogException(ClassName,
$"Failed to deserialize plugin metadata for plugin {plugin.Name} from file {metadataJsonFilePath}", ex);
return false;
}
if (SameOrLesserPluginVersionExists(newMetadata))
{
PublicApi.Instance.ShowMsgError(Localize.failedToInstallPluginTitle(plugin.Name),
Localize.pluginExistAlreadyMessage());
return false;
}
if (!IsMinimumAppVersionSatisfied(newMetadata.Name, newMetadata.MinimumAppVersion))
{
// Ask users if they want to install the plugin that doesn't satisfy the minimum app version requirement
if (PublicApi.Instance.ShowMsgBox(
Localize.pluginMinimumAppVersionUnsatisfiedMessage(newMetadata.Name, Environment.NewLine),
Localize.pluginMinimumAppVersionUnsatisfiedTitle(newMetadata.Name, newMetadata.MinimumAppVersion),
MessageBoxButton.YesNo) == MessageBoxResult.No)
{
return false;
}
}
var folderName = string.IsNullOrEmpty(plugin.Version) ? $"{plugin.Name}-{Guid.NewGuid()}" : $"{plugin.Name}-{plugin.Version}";
var defaultPluginIDs = new List<string>
{
"0ECADE17459B49F587BF81DC3A125110", // BrowserBookmark
"CEA0FDFC6D3B4085823D60DC76F28855", // Calculator
"572be03c74c642baae319fc283e561a8", // Explorer
"6A122269676E40EB86EB543B945932B9", // PluginIndicator
"9f8f9b14-2518-4907-b211-35ab6290dee7", // PluginsManager
"b64d0a79-329a-48b0-b53f-d658318a1bf6", // ProcessKiller
"791FC278BA414111B8D1886DFE447410", // Program
"D409510CD0D2481F853690A07E6DC426", // Shell
"CEA08895D2544B019B2E9C5009600DF4", // Sys
"0308FD86DE0A4DEE8D62B9B535370992", // URL
"565B73353DBF4806919830B9202EE3BF", // WebSearch
"5043CETYU6A748679OPA02D27D99677A" // WindowsSettings
};
// Treat default plugin differently, it needs to be removable along with each flow release
var installDirectory = !defaultPluginIDs.Any(x => x == plugin.ID)
? DataLocation.PluginsDirectory
: Constant.PreinstalledDirectory;
var newPluginPath = Path.Combine(installDirectory, folderName);
FilesFolders.CopyAll(pluginFolderPath, newPluginPath, (s) => PublicApi.Instance.ShowMsgBox(s));
// Check if marker file exists and delete it
try
{
var markerFilePath = Path.Combine(newPluginPath, DataLocation.PluginDeleteFile);
if (File.Exists(markerFilePath))
File.Delete(markerFilePath);
}
catch (Exception e)
{
PublicApi.Instance.LogException(ClassName, $"Failed to delete plugin marker file in {newPluginPath}", e);
}
if (checkModified)
{
ModifiedPlugins.Add(plugin.ID);
}
return true;
}
finally
{
try
{
if (Directory.Exists(tempFolderPluginPath))
Directory.Delete(tempFolderPluginPath, true);
}
catch (Exception e)
{
PublicApi.Instance.LogException(ClassName, $"Failed to delete temp folder {tempFolderPluginPath}", e);
}
}
}
internal static async Task<bool> UninstallPluginAsync(PluginMetadata plugin, bool removePluginFromSettings, bool removePluginSettings, bool checkModified)
{
if (checkModified && PluginModified(plugin.ID))
{
PublicApi.Instance.ShowMsgError(Localize.pluginModifiedAlreadyTitle(plugin.Name),
Localize.pluginModifiedAlreadyMessage());
return false;
}
if (removePluginSettings || removePluginFromSettings)
{
// If we want to remove plugin from AllPlugins,
// we need to dispose them so that they can release file handles
// which can help FL to delete the plugin settings & cache folders successfully
var pluginPairs = GetAllInitializedPlugins(includeFailed: true).Where(p => p.Metadata.ID == plugin.ID).ToList();
foreach (var pluginPair in pluginPairs)
{
await DisposePluginAsync(pluginPair);
}
}
if (removePluginSettings)
{
// For dotnet plugins, we need to remove their PluginJsonStorage and PluginBinaryStorage instances
if (AllowedLanguage.IsDotNet(plugin.Language) && PublicApi.Instance is IRemovable removable)
{
removable.RemovePluginSettings(plugin.AssemblyName);
removable.RemovePluginCaches(plugin.PluginCacheDirectoryPath);
}
try
{
var pluginSettingsDirectory = plugin.PluginSettingsDirectoryPath;
if (Directory.Exists(pluginSettingsDirectory))
Directory.Delete(pluginSettingsDirectory, true);
}
catch (Exception e)
{
PublicApi.Instance.LogException(ClassName, $"Failed to delete plugin settings folder for {plugin.Name}", e);
PublicApi.Instance.ShowMsgError(Localize.failedToRemovePluginSettingsTitle(),
Localize.failedToRemovePluginSettingsMessage(plugin.Name));
}
}
if (removePluginFromSettings)
{
try
{
var pluginCacheDirectory = plugin.PluginCacheDirectoryPath;
if (Directory.Exists(pluginCacheDirectory))
Directory.Delete(pluginCacheDirectory, true);
}
catch (Exception e)
{
PublicApi.Instance.LogException(ClassName, $"Failed to delete plugin cache folder for {plugin.Name}", e);
PublicApi.Instance.ShowMsgError(Localize.failedToRemovePluginCacheTitle(),
Localize.failedToRemovePluginCacheMessage(plugin.Name));
}
Settings.RemovePluginSettings(plugin.ID);
{
_allLoadedPlugins.TryRemove(plugin.ID, out var _);
}
{
_allInitializedPlugins.TryRemove(plugin.ID, out var _);
}
{
_initFailedPlugins.TryRemove(plugin.ID, out var _);
}
{
_globalPlugins.TryRemove(plugin.ID, out var _);
}
var keysToRemove = _nonGlobalPlugins.Where(p => p.Value.Metadata.ID == plugin.ID).Select(p => p.Key).ToList();
foreach (var key in keysToRemove)
{
_nonGlobalPlugins.TryRemove(key, out var _);
}
}
// Marked for deletion. Will be deleted on next start up
using var _ = File.CreateText(Path.Combine(plugin.PluginDirectory, DataLocation.PluginDeleteFile));
if (checkModified)
{
ModifiedPlugins.Add(plugin.ID);
}
return true;
}
internal static bool IsMinimumAppVersionSatisfied(string pluginName, string minimumAppVersion)
{
// If the minimum app version is not specified in plugin.json, this plugin is compatible with all app versions
if (string.IsNullOrEmpty(minimumAppVersion))
return true;
var appVersion = Version.Parse(Constant.Version);
if (!Version.TryParse(minimumAppVersion, out var minimumVersion))
{
PublicApi.Instance.LogError(ClassName,
$"Failed to parse the minimum app version {minimumAppVersion} for plugin {pluginName}.");
return false; // If the minimum app version specified in plugin.json is invalid, we assume it is not satisfied
}
if (appVersion >= minimumVersion)
return true;
return false;
}
#endregion
#endregion
}
}