Flow.Launcher/Flow.Launcher.Core/Plugin/PluginManager.cs
2025-06-09 20:28:18 +08:00

973 lines
39 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Core.ExternalPlugins;
using Flow.Launcher.Infrastructure;
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>
/// The entry for managing Flow Launcher plugins
/// </summary>
public static class PluginManager
{
private static readonly string ClassName = nameof(PluginManager);
private static readonly Settings FlowSettings = Ioc.Default.GetRequiredService<Settings>();
private static IEnumerable<PluginPair> _contextMenuPlugins;
private static IEnumerable<PluginPair> _homePlugins;
public static List<PluginPair> AllPlugins { get; private set; }
public static readonly HashSet<PluginPair> GlobalPlugins = new();
public static readonly Dictionary<string, PluginPair> NonGlobalPlugins = new();
// We should not initialize API in static constructor because it will create another API instance
private static IPublicAPI api = null;
private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService<IPublicAPI>();
private static PluginsSettings Settings;
private static List<PluginMetadata> _metadatas;
private static readonly List<string> _modifiedPlugins = new();
/// <summary>
/// Directories that will hold Flow Launcher plugin directory
/// </summary>
private static readonly string[] Directories =
{
Constant.PreinstalledDirectory, DataLocation.PluginsDirectory
};
private static void DeletePythonBinding()
{
const string binding = "flowlauncher.py";
foreach (var subDirectory in Directory.GetDirectories(DataLocation.PluginsDirectory))
{
File.Delete(Path.Combine(subDirectory, binding));
}
}
/// <summary>
/// Save json and ISavable
/// </summary>
public static void Save()
{
foreach (var pluginPair in AllPlugins)
{
var savable = pluginPair.Plugin as ISavable;
try
{
savable?.Save();
}
catch (Exception e)
{
API.LogException(ClassName, $"Failed to save plugin {pluginPair.Metadata.Name}", e);
}
}
API.SavePluginSettings();
API.SavePluginCaches();
}
public static async ValueTask DisposePluginsAsync()
{
foreach (var pluginPair in AllPlugins)
{
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)
{
API.LogException(ClassName, $"Failed to dispose plugin {pluginPair.Metadata.Name}", e);
}
}
public static async Task ReloadDataAsync()
{
await Task.WhenAll(AllPlugins.Select(plugin => plugin.Plugin switch
{
IReloadable p => Task.Run(p.ReloadData),
IAsyncReloadable p => p.ReloadDataAsync(),
_ => Task.CompletedTask,
}).ToArray());
}
public static async Task OpenExternalPreviewAsync(string path, bool sendFailToast = true)
{
await Task.WhenAll(AllPlugins.Select(plugin => plugin.Plugin switch
{
IAsyncExternalPreview p => p.OpenPreviewAsync(path, sendFailToast),
_ => Task.CompletedTask,
}).ToArray());
}
public static async Task CloseExternalPreviewAsync()
{
await Task.WhenAll(AllPlugins.Select(plugin => plugin.Plugin switch
{
IAsyncExternalPreview p => p.ClosePreviewAsync(),
_ => Task.CompletedTask,
}).ToArray());
}
public static async Task SwitchExternalPreviewAsync(string path, bool sendFailToast = true)
{
await Task.WhenAll(AllPlugins.Select(plugin => plugin.Plugin switch
{
IAsyncExternalPreview p => p.SwitchPreviewAsync(path, sendFailToast),
_ => Task.CompletedTask,
}).ToArray());
}
public static bool UseExternalPreview()
{
return GetPluginsForInterface<IAsyncExternalPreview>().Any(x => !x.Metadata.Disabled);
}
public static bool AllowAlwaysPreview()
{
var plugin = GetPluginsForInterface<IAsyncExternalPreview>().FirstOrDefault(x => !x.Metadata.Disabled);
if (plugin is null)
return false;
return ((IAsyncExternalPreview)plugin.Plugin).AllowAlwaysPreview();
}
static PluginManager()
{
// validate user directory
Directory.CreateDirectory(DataLocation.PluginsDirectory);
// force old plugins use new python binding
DeletePythonBinding();
}
/// <summary>
/// because InitializePlugins needs API, so LoadPlugins needs to be called first
/// todo happlebao The API should be removed
/// </summary>
/// <param name="settings"></param>
public static void LoadPlugins(PluginsSettings settings)
{
_metadatas = PluginConfig.Parse(Directories);
Settings = settings;
Settings.UpdatePluginSettings(_metadatas);
AllPlugins = PluginsLoader.Plugins(_metadatas, Settings);
// 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))
{
API.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))
{
API.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>
/// Call initialize for all plugins
/// </summary>
/// <returns>return the list of failed to init plugins or null for none</returns>
public static async Task InitializePluginsAsync()
{
var failedPlugins = new ConcurrentQueue<PluginPair>();
var InitTasks = AllPlugins.Select(pair => Task.Run(async delegate
{
try
{
var milliseconds = await API.StopwatchLogDebugAsync(ClassName, $"Init method time cost for <{pair.Metadata.Name}>",
() => pair.Plugin.InitAsync(new PluginInitContext(pair.Metadata, API)));
pair.Metadata.InitTime += milliseconds;
API.LogInfo(ClassName,
$"Total init cost for <{pair.Metadata.Name}> is <{pair.Metadata.InitTime}ms>");
}
catch (Exception e)
{
API.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
API.LogDebug(ClassName, $"Skipped init for <{pair.Metadata.Name}> due to error");
}
else
{
pair.Metadata.Disabled = true;
pair.Metadata.HomeDisabled = true;
failedPlugins.Enqueue(pair);
API.LogDebug(ClassName, $"Disable plugin <{pair.Metadata.Name}> because init failed");
}
}
}));
await Task.WhenAll(InitTasks);
_contextMenuPlugins = GetPluginsForInterface<IContextMenu>();
_homePlugins = GetPluginsForInterface<IAsyncHomeQuery>();
foreach (var plugin in AllPlugins)
{
// 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 plugin.Metadata.ActionKeywords.Distinct())
{
switch (actionKeyword)
{
case Query.GlobalPluginWildcardSign:
GlobalPlugins.Add(plugin);
break;
default:
NonGlobalPlugins[actionKeyword] = plugin;
break;
}
}
}
if (failedPlugins.Any())
{
var failed = string.Join(",", failedPlugins.Select(x => x.Metadata.Name));
API.ShowMsg(
API.GetTranslation("failedToInitializePluginsTitle"),
string.Format(
API.GetTranslation("failedToInitializePluginsMessage"),
failed
),
"",
false
);
}
}
public static ICollection<PluginPair> ValidPluginsForQuery(Query query)
{
if (query is null)
return Array.Empty<PluginPair>();
if (!NonGlobalPlugins.TryGetValue(query.ActionKeyword, out var plugin))
return GlobalPlugins;
return new List<PluginPair>
{
plugin
};
}
public static ICollection<PluginPair> ValidPluginsForHomeQuery()
{
return _homePlugins.ToList();
}
public static async Task<List<Result>> QueryForPluginAsync(PluginPair pair, Query query, CancellationToken token)
{
var results = new List<Result>();
var metadata = pair.Metadata;
try
{
var milliseconds = await API.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 = $"{metadata.Name}: Failed to respond!",
SubTitle = "Select this result for more info",
IcoPath = Constant.ErrorIcon,
PluginDirectory = metadata.PluginDirectory,
ActionKeywordAssigned = query.ActionKeyword,
PluginID = metadata.ID,
OriginQuery = query,
Action = _ => { throw new FlowPluginException(metadata, e);},
Score = -100
};
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;
try
{
var milliseconds = await API.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)
{
API.LogException(ClassName, $"Failed to query home for plugin: {metadata.Name}", e);
return null;
}
return results;
}
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>
/// <param name="id"></param>
/// <returns></returns>
public static PluginPair GetPluginForId(string id)
{
return AllPlugins.FirstOrDefault(o => o.Metadata.ID == id);
}
public static IEnumerable<PluginPair> GetPluginsForInterface<T>() where T : IFeatures
{
// Handle scenario where this is called before all plugins are instantiated, e.g. language change on startup
return AllPlugins?.Where(p => p.Plugin is T) ?? Array.Empty<PluginPair>();
}
public static List<Result> GetContextMenusForPlugin(Result result)
{
var results = new List<Result>();
var pluginPair = _contextMenuPlugins.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)
{
API.LogException(ClassName,
$"Can't load context menus for plugin <{pluginPair.Metadata.Name}>",
e);
}
}
return results;
}
public static bool IsHomePlugin(string id)
{
return _homePlugins.Any(p => p.Metadata.ID == id);
}
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.Add(plugin);
}
else
{
NonGlobalPlugins[newActionKeyword] = 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.Remove(plugin);
}
if (oldActionkeyword != Query.GlobalPluginWildcardSign)
NonGlobalPlugins.Remove(oldActionkeyword);
// 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;
}
}
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(string metadataPath)
{
var newMetadata = JsonSerializer.Deserialize<PluginMetadata>(File.ReadAllText(metadataPath));
return AllPlugins.Any(x => x.Metadata.ID == newMetadata.ID
&& newMetadata.Version.CompareTo(x.Metadata.Version) <= 0);
}
#region Public functions
public static bool PluginModified(string id)
{
return _modifiedPlugins.Contains(id);
}
public static async Task UpdatePluginAsync(PluginMetadata existingVersion, UserPlugin newVersion, string zipFilePath)
{
InstallPlugin(newVersion, zipFilePath, checkModified:false);
await UninstallPluginAsync(existingVersion, removePluginFromSettings:false, removePluginSettings:false, checkModified: false);
_modifiedPlugins.Add(existingVersion.ID);
}
public static void InstallPlugin(UserPlugin plugin, string zipFilePath)
{
InstallPlugin(plugin, zipFilePath, checkModified: true);
}
public static async Task UninstallPluginAsync(PluginMetadata plugin, bool removePluginFromSettings = true, bool removePluginSettings = false)
{
await UninstallPluginAsync(plugin, removePluginFromSettings, removePluginSettings, true);
}
public static async Task InstallPluginAndCheckRestartAsync(UserPlugin newPlugin)
{
if (API.ShowMsgBox(
string.Format(
API.GetTranslation("InstallPromptSubtitle"),
newPlugin.Name, newPlugin.Author, Environment.NewLine),
API.GetTranslation("InstallPromptTitle"),
button: MessageBoxButton.YesNo) != MessageBoxResult.Yes) return;
try
{
// at minimum should provide a name, but handle plugin that is not downloaded from plugins manifest and is a url download
var downloadFilename = string.IsNullOrEmpty(newPlugin.Version)
? $"{newPlugin.Name}-{Guid.NewGuid()}.zip"
: $"{newPlugin.Name}-{newPlugin.Version}.zip";
var filePath = Path.Combine(Path.GetTempPath(), downloadFilename);
using var cts = new CancellationTokenSource();
if (!newPlugin.IsFromLocalInstallPath)
{
await DownloadFileAsync(
$"{API.GetTranslation("DownloadingPlugin")} {newPlugin.Name}",
newPlugin.UrlDownload, filePath, cts);
}
else
{
filePath = newPlugin.LocalInstallPath;
}
// check if user cancelled download before installing plugin
if (cts.IsCancellationRequested)
{
return;
}
else
{
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"Plugin {newPlugin.ID} zip file not found at {filePath}", filePath);
}
API.InstallPlugin(newPlugin, filePath);
if (!newPlugin.IsFromLocalInstallPath)
{
File.Delete(filePath);
}
}
}
catch (Exception e)
{
API.LogException(ClassName, "Failed to install plugin", e);
API.ShowMsgError(API.GetTranslation("ErrorInstallingPlugin"));
return; // dont restart on failure
}
if (FlowSettings.AutoRestartAfterChanging)
{
API.RestartApp();
}
else
{
API.ShowMsg(
API.GetTranslation("installbtn"),
string.Format(
API.GetTranslation(
"InstallSuccessNoRestart"),
newPlugin.Name));
}
}
public static async Task InstallPluginAndCheckRestartAsync(string filePath)
{
UserPlugin plugin;
try
{
using ZipArchive archive = ZipFile.OpenRead(filePath);
var pluginJsonPath = archive.Entries.FirstOrDefault(x => x.Name == "plugin.json") ??
throw new FileNotFoundException("The zip file does not contain a plugin.json file.");
var pluginJsonEntry = archive.GetEntry(pluginJsonPath.ToString()) ??
throw new FileNotFoundException("The zip file does not contain a plugin.json file.");
using Stream stream = pluginJsonEntry.Open();
plugin = JsonSerializer.Deserialize<UserPlugin>(stream);
plugin.IcoPath = "Images\\zipfolder.png";
plugin.LocalInstallPath = filePath;
}
catch (Exception e)
{
API.LogException(ClassName, "Failed to validate zip file", e);
API.ShowMsgError(API.GetTranslation("ZipFileNotHavePluginJson"));
return;
}
if (FlowSettings.ShowUnknownSourceWarning)
{
if (!InstallSourceKnown(plugin.Website)
&& API.ShowMsgBox(string.Format(
API.GetTranslation("InstallFromUnknownSourceSubtitle"), Environment.NewLine),
API.GetTranslation("InstallFromUnknownSourceTitle"),
MessageBoxButton.YesNo) == MessageBoxResult.No)
return;
}
await InstallPluginAndCheckRestartAsync(plugin);
}
public static async Task UninstallPluginAndCheckRestartAsync(PluginMetadata oldPlugin)
{
if (API.ShowMsgBox(
string.Format(
API.GetTranslation("UninstallPromptSubtitle"),
oldPlugin.Name, oldPlugin.Author, Environment.NewLine),
API.GetTranslation("UninstallPromptTitle"),
button: MessageBoxButton.YesNo) != MessageBoxResult.Yes) return;
var removePluginSettings = API.ShowMsgBox(
API.GetTranslation("KeepPluginSettingsSubtitle"),
API.GetTranslation("KeepPluginSettingsTitle"),
button: MessageBoxButton.YesNo) == MessageBoxResult.No;
try
{
await API.UninstallPluginAsync(oldPlugin, removePluginSettings);
}
catch (Exception e)
{
API.LogException(ClassName, "Failed to uninstall plugin", e);
API.ShowMsgError(API.GetTranslation("ErrorUninstallingPlugin"));
return; // dont restart on failure
}
if (FlowSettings.AutoRestartAfterChanging)
{
API.RestartApp();
}
else
{
API.ShowMsg(
API.GetTranslation("uninstallbtn"),
string.Format(
API.GetTranslation(
"UninstallSuccessNoRestart"),
oldPlugin.Name));
}
}
public static async Task UpdatePluginAndCheckRestartAsync(UserPlugin newPlugin, PluginMetadata oldPlugin)
{
if (API.ShowMsgBox(
string.Format(
API.GetTranslation("UpdatePromptSubtitle"),
oldPlugin.Name, oldPlugin.Author, Environment.NewLine),
API.GetTranslation("UpdatePromptTitle"),
button: MessageBoxButton.YesNo) != MessageBoxResult.Yes) return;
try
{
var filePath = Path.Combine(Path.GetTempPath(), $"{newPlugin.Name}-{newPlugin.Version}.zip");
using var cts = new CancellationTokenSource();
if (!newPlugin.IsFromLocalInstallPath)
{
await DownloadFileAsync(
$"{API.GetTranslation("DownloadingPlugin")} {newPlugin.Name}",
newPlugin.UrlDownload, filePath, cts);
}
else
{
filePath = newPlugin.LocalInstallPath;
}
// check if user cancelled download before installing plugin
if (cts.IsCancellationRequested)
{
return;
}
else
{
await API.UpdatePluginAsync(oldPlugin, newPlugin, filePath);
}
}
catch (Exception e)
{
API.LogException(ClassName, "Failed to update plugin", e);
API.ShowMsgError(API.GetTranslation("ErrorUpdatingPlugin"));
return; // dont restart on failure
}
if (FlowSettings.AutoRestartAfterChanging)
{
API.RestartApp();
}
else
{
API.ShowMsg(
API.GetTranslation("updatebtn"),
string.Format(
API.GetTranslation(
"UpdateSuccessNoRestart"),
newPlugin.Name));
}
}
#endregion
#region Internal functions
internal static void InstallPlugin(UserPlugin plugin, string zipFilePath, bool checkModified)
{
if (checkModified && PluginModified(plugin.ID))
{
// Distinguish exception from installing same or less version
throw new ArgumentException($"Plugin {plugin.Name} {plugin.ID} has been modified.", nameof(plugin));
}
// Unzip plugin files to temp folder
var tempFolderPluginPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
System.IO.Compression.ZipFile.ExtractToDirectory(zipFilePath, tempFolderPluginPath);
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))
{
throw new FileNotFoundException($"Unable to find plugin.json from the extracted zip file, or this path {pluginFolderPath} does not exist");
}
if (SameOrLesserPluginVersionExists(metadataJsonFilePath))
{
throw new InvalidOperationException($"A plugin with the same ID and version already exists, or the version is greater than this downloaded plugin {plugin.Name}");
}
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) => API.ShowMsgBox(s));
try
{
if (Directory.Exists(tempFolderPluginPath))
Directory.Delete(tempFolderPluginPath, true);
}
catch (Exception e)
{
API.LogException(ClassName, $"Failed to delete temp folder {tempFolderPluginPath}", e);
}
if (checkModified)
{
_modifiedPlugins.Add(plugin.ID);
}
}
internal static async Task UninstallPluginAsync(PluginMetadata plugin, bool removePluginFromSettings, bool removePluginSettings, bool checkModified)
{
if (checkModified && PluginModified(plugin.ID))
{
throw new ArgumentException($"Plugin {plugin.Name} has been modified");
}
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 = AllPlugins.FindAll(p => p.Metadata.ID == plugin.ID);
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) && API 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)
{
API.LogException(ClassName, $"Failed to delete plugin settings folder for {plugin.Name}", e);
API.ShowMsg(API.GetTranslation("failedToRemovePluginSettingsTitle"),
string.Format(API.GetTranslation("failedToRemovePluginSettingsMessage"), plugin.Name));
}
}
if (removePluginFromSettings)
{
try
{
var pluginCacheDirectory = plugin.PluginCacheDirectoryPath;
if (Directory.Exists(pluginCacheDirectory))
Directory.Delete(pluginCacheDirectory, true);
}
catch (Exception e)
{
API.LogException(ClassName, $"Failed to delete plugin cache folder for {plugin.Name}", e);
API.ShowMsg(API.GetTranslation("failedToRemovePluginCacheTitle"),
string.Format(API.GetTranslation("failedToRemovePluginCacheMessage"), plugin.Name));
}
Settings.RemovePluginSettings(plugin.ID);
AllPlugins.RemoveAll(p => p.Metadata.ID == plugin.ID);
}
// Marked for deletion. Will be deleted on next start up
using var _ = File.CreateText(Path.Combine(plugin.PluginDirectory, "NeedDelete.txt"));
if (checkModified)
{
_modifiedPlugins.Add(plugin.ID);
}
}
internal static async Task DownloadFileAsync(string prgBoxTitle, string downloadUrl, string filePath, CancellationTokenSource cts, bool deleteFile = true, bool showProgress = true)
{
if (deleteFile && File.Exists(filePath))
File.Delete(filePath);
if (showProgress)
{
var exceptionHappened = false;
await API.ShowProgressBoxAsync(prgBoxTitle,
async (reportProgress) =>
{
if (reportProgress == null)
{
// when reportProgress is null, it means there is expcetion with the progress box
// so we record it with exceptionHappened and return so that progress box will close instantly
exceptionHappened = true;
return;
}
else
{
await API.HttpDownloadAsync(downloadUrl, filePath, reportProgress, cts.Token).ConfigureAwait(false);
}
}, cts.Cancel);
// if exception happened while downloading and user does not cancel downloading,
// we need to redownload the plugin
if (exceptionHappened && (!cts.IsCancellationRequested))
await API.HttpDownloadAsync(downloadUrl, filePath, token: cts.Token).ConfigureAwait(false);
}
else
{
await API.HttpDownloadAsync(downloadUrl, filePath, token: cts.Token).ConfigureAwait(false);
}
}
private static bool InstallSourceKnown(string url)
{
var pieces = url.Split('/');
if (pieces.Length < 4)
return false;
var author = pieces[3];
var acceptedSource = "https://github.com";
var constructedUrlPart = string.Format("{0}/{1}/", acceptedSource, author);
return url.StartsWith(acceptedSource) &&
API.GetAllPlugins().Any(x =>
!string.IsNullOrEmpty(x.Metadata.Website) &&
x.Metadata.Website.StartsWith(constructedUrlPart)
);
}
#endregion
}
}