mirror of
https://github.com/Flow-Launcher/Flow.Launcher.git
synced 2026-03-11 08:54:32 +00:00
973 lines
39 KiB
C#
973 lines
39 KiB
C#
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; // don’t 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; // don’t 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; // don’t 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
|
||
}
|
||
}
|