Merge branch 'dev' into rename-file

This commit is contained in:
Jack Ye 2025-07-07 20:39:20 +08:00 committed by GitHub
commit 35474f2cbc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 689 additions and 118 deletions

View file

@ -99,3 +99,7 @@ pluginsmanager
alreadyexists alreadyexists
Softpedia Softpedia
img img
Reloadable
metadatas
WMP
VSTHRD

View file

@ -133,3 +133,4 @@
\bPortuguês (Brasil)\b \bPortuguês (Brasil)\b
\bčeština\b \bčeština\b
\bPortuguês\b \bPortuguês\b
\bIoc\b

View file

@ -0,0 +1,353 @@
using System;
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.Infrastructure.UserSettings;
using Flow.Launcher.Plugin;
namespace Flow.Launcher.Core.Plugin;
/// <summary>
/// Class for installing, updating, and uninstalling plugins.
/// </summary>
public static class PluginInstaller
{
private static readonly string ClassName = nameof(PluginInstaller);
private static readonly Settings Settings = Ioc.Default.GetRequiredService<Settings>();
// 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>();
/// <summary>
/// Installs a plugin and restarts the application if required by settings. Prompts user for confirmation and handles download if needed.
/// </summary>
/// <param name="newPlugin">The plugin to install.</param>
/// <returns>A Task representing the asynchronous install operation.</returns>
public static async Task InstallPluginAndCheckRestartAsync(UserPlugin newPlugin)
{
if (API.PluginModified(newPlugin.ID))
{
API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), newPlugin.Name),
API.GetTranslation("pluginModifiedAlreadyMessage"));
return;
}
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;
}
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"Plugin {newPlugin.ID} zip file not found at {filePath}", filePath);
}
if (!API.InstallPlugin(newPlugin, filePath))
{
return;
}
if (!newPlugin.IsFromLocalInstallPath)
{
File.Delete(filePath);
}
}
catch (Exception e)
{
API.LogException(ClassName, "Failed to install plugin", e);
API.ShowMsgError(API.GetTranslation("ErrorInstallingPlugin"));
return; // do not restart on failure
}
if (Settings.AutoRestartAfterChanging)
{
API.RestartApp();
}
else
{
API.ShowMsg(
API.GetTranslation("installbtn"),
string.Format(
API.GetTranslation(
"InstallSuccessNoRestart"),
newPlugin.Name));
}
}
/// <summary>
/// Installs a plugin from a local zip file and restarts the application if required by settings. Validates the zip and prompts user for confirmation.
/// </summary>
/// <param name="filePath">The path to the plugin zip file.</param>
/// <returns>A Task representing the asynchronous install operation.</returns>
public static async Task InstallPluginAndCheckRestartAsync(string filePath)
{
UserPlugin plugin;
try
{
using ZipArchive archive = ZipFile.OpenRead(filePath);
var pluginJsonEntry = archive.Entries.FirstOrDefault(x => x.Name == "plugin.json") ??
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 (API.PluginModified(plugin.ID))
{
API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), plugin.Name),
API.GetTranslation("pluginModifiedAlreadyMessage"));
return;
}
if (Settings.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);
}
/// <summary>
/// Uninstalls a plugin and restarts the application if required by settings. Prompts user for confirmation and whether to keep plugin settings.
/// </summary>
/// <param name="oldPlugin">The plugin metadata to uninstall.</param>
/// <returns>A Task representing the asynchronous uninstall operation.</returns>
public static async Task UninstallPluginAndCheckRestartAsync(PluginMetadata oldPlugin)
{
if (API.PluginModified(oldPlugin.ID))
{
API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), oldPlugin.Name),
API.GetTranslation("pluginModifiedAlreadyMessage"));
return;
}
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
{
if (!await API.UninstallPluginAsync(oldPlugin, removePluginSettings))
{
return;
}
}
catch (Exception e)
{
API.LogException(ClassName, "Failed to uninstall plugin", e);
API.ShowMsgError(API.GetTranslation("ErrorUninstallingPlugin"));
return; // don not restart on failure
}
if (Settings.AutoRestartAfterChanging)
{
API.RestartApp();
}
else
{
API.ShowMsg(
API.GetTranslation("uninstallbtn"),
string.Format(
API.GetTranslation(
"UninstallSuccessNoRestart"),
oldPlugin.Name));
}
}
/// <summary>
/// Updates a plugin to a new version and restarts the application if required by settings. Prompts user for confirmation and handles download if needed.
/// </summary>
/// <param name="newPlugin">The new plugin version to install.</param>
/// <param name="oldPlugin">The existing plugin metadata to update.</param>
/// <returns>A Task representing the asynchronous update operation.</returns>
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;
}
if (!await API.UpdatePluginAsync(oldPlugin, newPlugin, filePath))
{
return;
}
}
catch (Exception e)
{
API.LogException(ClassName, "Failed to update plugin", e);
API.ShowMsgError(API.GetTranslation("ErrorUpdatingPlugin"));
return; // do not restart on failure
}
if (Settings.AutoRestartAfterChanging)
{
API.RestartApp();
}
else
{
API.ShowMsg(
API.GetTranslation("updatebtn"),
string.Format(
API.GetTranslation(
"UpdateSuccessNoRestart"),
newPlugin.Name));
}
}
/// <summary>
/// Downloads a file from a URL to a local path, optionally showing a progress box and handling cancellation.
/// </summary>
/// <param name="progressBoxTitle">The title for the progress box.</param>
/// <param name="downloadUrl">The URL to download from.</param>
/// <param name="filePath">The local file path to save to.</param>
/// <param name="cts">Cancellation token source for cancelling the download.</param>
/// <param name="deleteFile">Whether to delete the file if it already exists.</param>
/// <param name="showProgress">Whether to show a progress box during download.</param>
/// <returns>A Task representing the asynchronous download operation.</returns>
private static async Task DownloadFileAsync(string progressBoxTitle, 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(progressBoxTitle,
async (reportProgress) =>
{
if (reportProgress == null)
{
// when reportProgress is null, it means there is exception 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);
}
}
/// <summary>
/// Determines if the plugin install source is a known/approved source (e.g., GitHub and matches an existing plugin author).
/// </summary>
/// <param name="url">The URL to check.</param>
/// <returns>True if the source is known, otherwise false.</returns>
private static bool InstallSourceKnown(string url)
{
if (string.IsNullOrEmpty(url))
return false;
var pieces = url.Split('/');
if (pieces.Length < 4)
return false;
var author = pieces[3];
var acceptedHost = "github.com";
var acceptedSource = "https://github.com";
var constructedUrlPart = string.Format("{0}/{1}/", acceptedSource, author);
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Host != acceptedHost)
return false;
return API.GetAllPlugins().Any(x =>
!string.IsNullOrEmpty(x.Metadata.Website) &&
x.Metadata.Website.StartsWith(constructedUrlPart)
);
}
}

View file

@ -20,7 +20,7 @@ using ISavable = Flow.Launcher.Plugin.ISavable;
namespace Flow.Launcher.Core.Plugin namespace Flow.Launcher.Core.Plugin
{ {
/// <summary> /// <summary>
/// The entry for managing Flow Launcher plugins /// Class for co-ordinating and managing all plugin lifecycle.
/// </summary> /// </summary>
public static class PluginManager public static class PluginManager
{ {
@ -693,8 +693,13 @@ namespace Flow.Launcher.Core.Plugin
private static bool SameOrLesserPluginVersionExists(string metadataPath) private static bool SameOrLesserPluginVersionExists(string metadataPath)
{ {
var newMetadata = JsonSerializer.Deserialize<PluginMetadata>(File.ReadAllText(metadataPath)); var newMetadata = JsonSerializer.Deserialize<PluginMetadata>(File.ReadAllText(metadataPath));
if (!Version.TryParse(newMetadata.Version, out var newVersion))
return true; // If version is not valid, we assume it is lesser than any existing version
return AllPlugins.Any(x => x.Metadata.ID == newMetadata.ID return AllPlugins.Any(x => x.Metadata.ID == newMetadata.ID
&& newMetadata.Version.CompareTo(x.Metadata.Version) <= 0); && Version.TryParse(x.Metadata.Version, out var version)
&& newVersion <= version);
} }
#region Public functions #region Public functions
@ -704,33 +709,46 @@ namespace Flow.Launcher.Core.Plugin
return ModifiedPlugins.Contains(id); return ModifiedPlugins.Contains(id);
} }
public static async Task UpdatePluginAsync(PluginMetadata existingVersion, UserPlugin newVersion, string zipFilePath) public static async Task<bool> UpdatePluginAsync(PluginMetadata existingVersion, UserPlugin newVersion, string zipFilePath)
{ {
InstallPlugin(newVersion, zipFilePath, checkModified: false); if (PluginModified(existingVersion.ID))
await UninstallPluginAsync(existingVersion, removePluginFromSettings: false, removePluginSettings: false, checkModified: false); {
API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), existingVersion.Name),
API.GetTranslation("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); ModifiedPlugins.Add(existingVersion.ID);
return true;
} }
public static void InstallPlugin(UserPlugin plugin, string zipFilePath) public static bool InstallPlugin(UserPlugin plugin, string zipFilePath)
{ {
InstallPlugin(plugin, zipFilePath, checkModified: true); return InstallPlugin(plugin, zipFilePath, checkModified: true);
} }
public static async Task UninstallPluginAsync(PluginMetadata plugin, bool removePluginSettings = false) public static async Task<bool> UninstallPluginAsync(PluginMetadata plugin, bool removePluginSettings = false)
{ {
await UninstallPluginAsync(plugin, removePluginFromSettings: true, removePluginSettings: removePluginSettings, checkModified: true); return await UninstallPluginAsync(plugin, removePluginFromSettings: true, removePluginSettings: removePluginSettings, checkModified: true);
} }
#endregion #endregion
#region Internal functions #region Internal functions
internal static void InstallPlugin(UserPlugin plugin, string zipFilePath, bool checkModified) internal static bool InstallPlugin(UserPlugin plugin, string zipFilePath, bool checkModified)
{ {
if (checkModified && PluginModified(plugin.ID)) if (checkModified && PluginModified(plugin.ID))
{ {
// Distinguish exception from installing same or less version API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), plugin.Name),
throw new ArgumentException($"Plugin {plugin.Name} {plugin.ID} has been modified.", nameof(plugin)); API.GetTranslation("pluginModifiedAlreadyMessage"));
return false;
} }
// Unzip plugin files to temp folder // Unzip plugin files to temp folder
@ -748,12 +766,16 @@ namespace Flow.Launcher.Core.Plugin
if (string.IsNullOrEmpty(metadataJsonFilePath) || string.IsNullOrEmpty(pluginFolderPath)) 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"); API.ShowMsgError(string.Format(API.GetTranslation("failedToInstallPluginTitle"), plugin.Name),
string.Format(API.GetTranslation("fileNotFoundMessage"), pluginFolderPath));
return false;
} }
if (SameOrLesserPluginVersionExists(metadataJsonFilePath)) 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}"); API.ShowMsgError(string.Format(API.GetTranslation("failedToInstallPluginTitle"), plugin.Name),
API.GetTranslation("pluginExistAlreadyMessage"));
return false;
} }
var folderName = string.IsNullOrEmpty(plugin.Version) ? $"{plugin.Name}-{Guid.NewGuid()}" : $"{plugin.Name}-{plugin.Version}"; var folderName = string.IsNullOrEmpty(plugin.Version) ? $"{plugin.Name}-{Guid.NewGuid()}" : $"{plugin.Name}-{plugin.Version}";
@ -797,13 +819,17 @@ namespace Flow.Launcher.Core.Plugin
{ {
ModifiedPlugins.Add(plugin.ID); ModifiedPlugins.Add(plugin.ID);
} }
return true;
} }
internal static async Task UninstallPluginAsync(PluginMetadata plugin, bool removePluginFromSettings, bool removePluginSettings, bool checkModified) internal static async Task<bool> UninstallPluginAsync(PluginMetadata plugin, bool removePluginFromSettings, bool removePluginSettings, bool checkModified)
{ {
if (checkModified && PluginModified(plugin.ID)) if (checkModified && PluginModified(plugin.ID))
{ {
throw new ArgumentException($"Plugin {plugin.Name} has been modified"); API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), plugin.Name),
API.GetTranslation("pluginModifiedAlreadyMessage"));
return false;
} }
if (removePluginSettings || removePluginFromSettings) if (removePluginSettings || removePluginFromSettings)
@ -872,6 +898,8 @@ namespace Flow.Launcher.Core.Plugin
{ {
ModifiedPlugins.Add(plugin.ID); ModifiedPlugins.Add(plugin.ID);
} }
return true;
} }
#endregion #endregion

View file

@ -398,6 +398,9 @@ namespace Flow.Launcher.Infrastructure.UserSettings
public int MaxHistoryResultsToShowForHomePage { get; set; } = 5; public int MaxHistoryResultsToShowForHomePage { get; set; } = 5;
public bool AutoRestartAfterChanging { get; set; } = false;
public bool ShowUnknownSourceWarning { get; set; } = true;
public int CustomExplorerIndex { get; set; } = 0; public int CustomExplorerIndex { get; set; } = 0;
[JsonIgnore] [JsonIgnore]

View file

@ -23,8 +23,8 @@ namespace Flow.Launcher.Plugin
/// </summary> /// </summary>
/// <param name="query">query text</param> /// <param name="query">query text</param>
/// <param name="requery"> /// <param name="requery">
/// Force requery. By default, Flow Launcher will not fire query if your query is same with existing one. /// Force requery. By default, Flow Launcher will not fire query if your query is same with existing one.
/// Set this to <see langword="true"/> to force Flow Launcher requerying /// Set this to <see langword="true"/> to force Flow Launcher re-querying
/// </param> /// </param>
void ChangeQuery(string query, bool requery = false); void ChangeQuery(string query, bool requery = false);
@ -49,7 +49,7 @@ namespace Flow.Launcher.Plugin
/// </summary> /// </summary>
/// <param name="text">Text to save on clipboard</param> /// <param name="text">Text to save on clipboard</param>
/// <param name="directCopy">When true it will directly copy the file/folder from the path specified in text</param> /// <param name="directCopy">When true it will directly copy the file/folder from the path specified in text</param>
/// <param name="showDefaultNotification">Whether to show the default notification from this method after copy is done. /// <param name="showDefaultNotification">Whether to show the default notification from this method after copy is done.
/// It will show file/folder/text is copied successfully. /// It will show file/folder/text is copied successfully.
/// Turn this off to show your own notification after copy is done.</param>> /// Turn this off to show your own notification after copy is done.</param>>
public void CopyToClipboard(string text, bool directCopy = false, bool showDefaultNotification = true); public void CopyToClipboard(string text, bool directCopy = false, bool showDefaultNotification = true);
@ -65,7 +65,7 @@ namespace Flow.Launcher.Plugin
void SavePluginSettings(); void SavePluginSettings();
/// <summary> /// <summary>
/// Reloads any Plugins that have the /// Reloads any Plugins that have the
/// IReloadable implemented. It refeshes /// IReloadable implemented. It refeshes
/// Plugin's in memory data with new content /// Plugin's in memory data with new content
/// added by user. /// added by user.
@ -97,7 +97,7 @@ namespace Flow.Launcher.Plugin
/// Show the MainWindow when hiding /// Show the MainWindow when hiding
/// </summary> /// </summary>
void ShowMainWindow(); void ShowMainWindow();
/// <summary> /// <summary>
/// Focus the query text box in the main window /// Focus the query text box in the main window
/// </summary> /// </summary>
@ -115,7 +115,7 @@ namespace Flow.Launcher.Plugin
bool IsMainWindowVisible(); bool IsMainWindowVisible();
/// <summary> /// <summary>
/// Invoked when the visibility of the main window has changed. Currently, the plugin will continue to be subscribed even if it is turned off. /// Invoked when the visibility of the main window has changed. Currently, the plugin will continue to be subscribed even if it is turned off.
/// </summary> /// </summary>
event VisibilityChangedEventHandler VisibilityChanged; event VisibilityChangedEventHandler VisibilityChanged;
@ -171,7 +171,7 @@ namespace Flow.Launcher.Plugin
string GetTranslation(string key); string GetTranslation(string key);
/// <summary> /// <summary>
/// Get all loaded plugins /// Get all loaded plugins
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
List<PluginPair> GetAllPlugins(); List<PluginPair> GetAllPlugins();
@ -229,7 +229,7 @@ namespace Flow.Launcher.Plugin
MatchResult FuzzySearch(string query, string stringToCompare); MatchResult FuzzySearch(string query, string stringToCompare);
/// <summary> /// <summary>
/// Http download the spefic url and return as string /// Http download the specific url and return as string
/// </summary> /// </summary>
/// <param name="url">URL to call Http Get</param> /// <param name="url">URL to call Http Get</param>
/// <param name="token">Cancellation Token</param> /// <param name="token">Cancellation Token</param>
@ -237,7 +237,7 @@ namespace Flow.Launcher.Plugin
Task<string> HttpGetStringAsync(string url, CancellationToken token = default); Task<string> HttpGetStringAsync(string url, CancellationToken token = default);
/// <summary> /// <summary>
/// Http download the spefic url and return as stream /// Http download the specific url and return as stream
/// </summary> /// </summary>
/// <param name="url">URL to call Http Get</param> /// <param name="url">URL to call Http Get</param>
/// <param name="token">Cancellation Token</param> /// <param name="token">Cancellation Token</param>
@ -305,8 +305,8 @@ namespace Flow.Launcher.Plugin
void LogError(string className, string message, [CallerMemberName] string methodName = ""); void LogError(string className, string message, [CallerMemberName] string methodName = "");
/// <summary> /// <summary>
/// Log an Exception. Will throw if in debug mode so developer will be aware, /// Log an Exception. Will throw if in debug mode so developer will be aware,
/// otherwise logs the eror message. This is the primary logging method used for Flow /// otherwise logs the eror message. This is the primary logging method used for Flow
/// </summary> /// </summary>
void LogException(string className, string message, Exception e, [CallerMemberName] string methodName = ""); void LogException(string className, string message, Exception e, [CallerMemberName] string methodName = "");
@ -393,7 +393,7 @@ namespace Flow.Launcher.Plugin
/// <summary> /// <summary>
/// Reloads the query. /// Reloads the query.
/// When current results are from context menu or history, it will go back to query results before requerying. /// When current results are from context menu or history, it will go back to query results before re-querying.
/// </summary> /// </summary>
/// <param name="reselect">Choose the first result after reload if true; keep the last selected result if false. Default is true.</param> /// <param name="reselect">Choose the first result after reload if true; keep the last selected result if false. Default is true.</param>
public void ReQuery(bool reselect = true); public void ReQuery(bool reselect = true);
@ -547,8 +547,10 @@ namespace Flow.Launcher.Plugin
/// <param name="zipFilePath"> /// <param name="zipFilePath">
/// Path to the zip file containing the plugin. It will be unzipped to the temporary directory, removed and installed. /// Path to the zip file containing the plugin. It will be unzipped to the temporary directory, removed and installed.
/// </param> /// </param>
/// <returns></returns> /// <returns>
public Task UpdatePluginAsync(PluginMetadata pluginMetadata, UserPlugin plugin, string zipFilePath); /// True if the plugin is updated successfully, false otherwise.
/// </returns>
public Task<bool> UpdatePluginAsync(PluginMetadata pluginMetadata, UserPlugin plugin, string zipFilePath);
/// <summary> /// <summary>
/// Install a plugin. By default will remove the zip file if installation is from url, /// Install a plugin. By default will remove the zip file if installation is from url,
@ -558,7 +560,10 @@ namespace Flow.Launcher.Plugin
/// <param name="zipFilePath"> /// <param name="zipFilePath">
/// Path to the zip file containing the plugin. It will be unzipped to the temporary directory, removed and installed. /// Path to the zip file containing the plugin. It will be unzipped to the temporary directory, removed and installed.
/// </param> /// </param>
public void InstallPlugin(UserPlugin plugin, string zipFilePath); /// <returns>
/// True if the plugin is installed successfully, false otherwise.
/// </returns>
public bool InstallPlugin(UserPlugin plugin, string zipFilePath);
/// <summary> /// <summary>
/// Uninstall a plugin /// Uninstall a plugin
@ -567,8 +572,10 @@ namespace Flow.Launcher.Plugin
/// <param name="removePluginSettings"> /// <param name="removePluginSettings">
/// Plugin has their own settings. If this is set to true, the plugin settings will be removed. /// Plugin has their own settings. If this is set to true, the plugin settings will be removed.
/// </param> /// </param>
/// <returns></returns> /// <returns>
public Task UninstallPluginAsync(PluginMetadata pluginMetadata, bool removePluginSettings = false); /// True if the plugin is updated successfully, false otherwise.
/// </returns>
public Task<bool> UninstallPluginAsync(PluginMetadata pluginMetadata, bool removePluginSettings = false);
/// <summary> /// <summary>
/// Log debug message of the time taken to execute a method /// Log debug message of the time taken to execute a method
@ -603,7 +610,7 @@ namespace Flow.Launcher.Plugin
bool IsApplicationDarkTheme(); bool IsApplicationDarkTheme();
/// <summary> /// <summary>
/// Invoked when the actual theme of the application has changed. Currently, the plugin will continue to be subscribed even if it is turned off. /// Invoked when the actual theme of the application has changed. Currently, the plugin will continue to be subscribed even if it is turned off.
/// </summary> /// </summary>
event ActualApplicationThemeChangedEventHandler ActualApplicationThemeChanged; event ActualApplicationThemeChangedEventHandler ActualApplicationThemeChanged;
} }

View file

@ -211,6 +211,9 @@ namespace Flow.Launcher
Http.Proxy = _settings.Proxy; Http.Proxy = _settings.Proxy;
// Initialize plugin manifest before initializing plugins so that they can use the manifest instantly
await API.UpdatePluginManifestAsync();
await PluginManager.InitializePluginsAsync(); await PluginManager.InitializePluginsAsync();
// Change language after all plugins are initialized because we need to update plugin title based on their api // Change language after all plugins are initialized because we need to update plugin title based on their api

View file

@ -135,6 +135,10 @@
<system:String x:Key="homeToggleBoxToolTip">This can only be edited if plugin supports Home feature and Home Page is enabled.</system:String> <system:String x:Key="homeToggleBoxToolTip">This can only be edited if plugin supports Home feature and Home Page is enabled.</system:String>
<system:String x:Key="showAtTopmost">Show Search Window at Foremost</system:String> <system:String x:Key="showAtTopmost">Show Search Window at Foremost</system:String>
<system:String x:Key="showAtTopmostToolTip">Overrides other programs' 'Always on Top' setting and displays Flow in the foremost position.</system:String> <system:String x:Key="showAtTopmostToolTip">Overrides other programs' 'Always on Top' setting and displays Flow in the foremost position.</system:String>
<system:String x:Key="autoRestartAfterChanging">Restart after modifying plugin via Plugin Store</system:String>
<system:String x:Key="autoRestartAfterChangingToolTip">Restart Flow Launcher automatically after installing/uninstalling/updating plugin via Plugin Store</system:String>
<system:String x:Key="showUnknownSourceWarning">Show unknown source warning</system:String>
<system:String x:Key="showUnknownSourceWarningToolTip">Show warning when installing plugins from unknown sources</system:String>
<!-- Setting Plugin --> <!-- Setting Plugin -->
<system:String x:Key="searchplugin">Search Plugin</system:String> <system:String x:Key="searchplugin">Search Plugin</system:String>
@ -173,6 +177,12 @@
<system:String x:Key="failedToRemovePluginSettingsMessage">Plugins: {0} - Fail to remove plugin settings files, please remove them manually</system:String> <system:String x:Key="failedToRemovePluginSettingsMessage">Plugins: {0} - Fail to remove plugin settings files, please remove them manually</system:String>
<system:String x:Key="failedToRemovePluginCacheTitle">Fail to remove plugin cache</system:String> <system:String x:Key="failedToRemovePluginCacheTitle">Fail to remove plugin cache</system:String>
<system:String x:Key="failedToRemovePluginCacheMessage">Plugins: {0} - Fail to remove plugin cache files, please remove them manually</system:String> <system:String x:Key="failedToRemovePluginCacheMessage">Plugins: {0} - Fail to remove plugin cache files, please remove them manually</system:String>
<system:String x:Key="pluginModifiedAlreadyTitle">{0} modified already</system:String>
<system:String x:Key="pluginModifiedAlreadyMessage">Please restart Flow before making any further changes</system:String>
<system:String x:Key="failedToInstallPluginTitle">Fail to install {0}</system:String>
<system:String x:Key="failedToUninstallPluginTitle">Fail to uninstall {0}</system:String>
<system:String x:Key="fileNotFoundMessage">Unable to find plugin.json from the extracted zip file, or this path {0} does not exist</system:String>
<system:String x:Key="pluginExistAlreadyMessage">A plugin with the same ID and version already exists, or the version is greater than this downloaded plugin</system:String>
<!-- Setting Plugin Store --> <!-- Setting Plugin Store -->
<system:String x:Key="pluginStore">Plugin Store</system:String> <system:String x:Key="pluginStore">Plugin Store</system:String>
@ -188,6 +198,28 @@
<system:String x:Key="LabelNew">New Version</system:String> <system:String x:Key="LabelNew">New Version</system:String>
<system:String x:Key="LabelNewToolTip">This plugin has been updated within the last 7 days</system:String> <system:String x:Key="LabelNewToolTip">This plugin has been updated within the last 7 days</system:String>
<system:String x:Key="LabelUpdateToolTip">New Update is Available</system:String> <system:String x:Key="LabelUpdateToolTip">New Update is Available</system:String>
<system:String x:Key="ErrorInstallingPlugin">Error installing plugin</system:String>
<system:String x:Key="ErrorUninstallingPlugin">Error uninstalling plugin</system:String>
<system:String x:Key="ErrorUpdatingPlugin">Error updating plugin</system:String>
<system:String x:Key="KeepPluginSettingsTitle">Keep plugin settings</system:String>
<system:String x:Key="KeepPluginSettingsSubtitle">Do you want to keep the settings of the plugin for the next usage?</system:String>
<system:String x:Key="InstallSuccessNoRestart">Plugin {0} successfully installed. Please restart Flow.</system:String>
<system:String x:Key="UninstallSuccessNoRestart">Plugin {0} successfully uninstalled. Please restart Flow.</system:String>
<system:String x:Key="UpdateSuccessNoRestart">Plugin {0} successfully updated. Please restart Flow.</system:String>
<system:String x:Key="InstallPromptTitle">Plugin install</system:String>
<system:String x:Key="InstallPromptSubtitle">{0} by {1} {2}{2}Would you like to install this plugin?</system:String>
<system:String x:Key="UninstallPromptTitle">Plugin uninstall</system:String>
<system:String x:Key="UninstallPromptSubtitle">{0} by {1} {2}{2}Would you like to uninstall this plugin?</system:String>
<system:String x:Key="UpdatePromptTitle">Plugin update</system:String>
<system:String x:Key="UpdatePromptSubtitle">{0} by {1} {2}{2}Would you like to update this plugin?</system:String>
<system:String x:Key="DownloadingPlugin">Downloading plugin</system:String>
<system:String x:Key="AutoRestartAfterChange">Automatically restart after installing/uninstalling/updating plugins in plugin store</system:String>
<system:String x:Key="ZipFileNotHavePluginJson">Zip file does not have a valid plugin.json configuration</system:String>
<system:String x:Key="InstallFromUnknownSourceTitle">Installing from an unknown source</system:String>
<system:String x:Key="InstallFromUnknownSourceSubtitle">This plugin is from an unknown source and it may contain potential risks!{0}{0}Please ensure you understand where this plugin is from and that it is safe.{0}{0}Would you like to continue still?{0}{0}(You can switch off this warning in general section of setting window)</system:String>
<system:String x:Key="ZipFiles">Zip files</system:String>
<system:String x:Key="SelectZipFile">Please select zip file</system:String>
<system:String x:Key="installLocalPluginTooltip">Install plugin from local path</system:String>
<!-- Setting Theme --> <!-- Setting Theme -->
<system:String x:Key="theme">Theme</system:String> <system:String x:Key="theme">Theme</system:String>

View file

@ -19,32 +19,32 @@ namespace Flow.Launcher
public static async Task ShowAsync(string caption, Func<Action<double>, Task> reportProgressAsync, Action cancelProgress = null) public static async Task ShowAsync(string caption, Func<Action<double>, Task> reportProgressAsync, Action cancelProgress = null)
{ {
ProgressBoxEx prgBox = null; ProgressBoxEx progressBox = null;
try try
{ {
if (!Application.Current.Dispatcher.CheckAccess()) if (!Application.Current.Dispatcher.CheckAccess())
{ {
await Application.Current.Dispatcher.InvokeAsync(() => await Application.Current.Dispatcher.InvokeAsync(() =>
{ {
prgBox = new ProgressBoxEx(cancelProgress) progressBox = new ProgressBoxEx(cancelProgress)
{ {
Title = caption Title = caption
}; };
prgBox.TitleTextBlock.Text = caption; progressBox.TitleTextBlock.Text = caption;
prgBox.Show(); progressBox.Show();
}); });
} }
else else
{ {
prgBox = new ProgressBoxEx(cancelProgress) progressBox = new ProgressBoxEx(cancelProgress)
{ {
Title = caption Title = caption
}; };
prgBox.TitleTextBlock.Text = caption; progressBox.TitleTextBlock.Text = caption;
prgBox.Show(); progressBox.Show();
} }
await reportProgressAsync(prgBox.ReportProgress).ConfigureAwait(false); await reportProgressAsync(progressBox.ReportProgress).ConfigureAwait(false);
} }
catch (Exception e) catch (Exception e)
{ {
@ -58,12 +58,12 @@ namespace Flow.Launcher
{ {
await Application.Current.Dispatcher.InvokeAsync(() => await Application.Current.Dispatcher.InvokeAsync(() =>
{ {
prgBox?.Close(); progressBox?.Close();
}); });
} }
else else
{ {
prgBox?.Close(); progressBox?.Close();
} }
} }
} }

View file

@ -567,13 +567,13 @@ namespace Flow.Launcher
public bool PluginModified(string id) => PluginManager.PluginModified(id); public bool PluginModified(string id) => PluginManager.PluginModified(id);
public Task UpdatePluginAsync(PluginMetadata pluginMetadata, UserPlugin plugin, string zipFilePath) => public Task<bool> UpdatePluginAsync(PluginMetadata pluginMetadata, UserPlugin plugin, string zipFilePath) =>
PluginManager.UpdatePluginAsync(pluginMetadata, plugin, zipFilePath); PluginManager.UpdatePluginAsync(pluginMetadata, plugin, zipFilePath);
public void InstallPlugin(UserPlugin plugin, string zipFilePath) => public bool InstallPlugin(UserPlugin plugin, string zipFilePath) =>
PluginManager.InstallPlugin(plugin, zipFilePath); PluginManager.InstallPlugin(plugin, zipFilePath);
public Task UninstallPluginAsync(PluginMetadata pluginMetadata, bool removePluginSettings = false) => public Task<bool> UninstallPluginAsync(PluginMetadata pluginMetadata, bool removePluginSettings = false) =>
PluginManager.UninstallPluginAsync(pluginMetadata, removePluginSettings); PluginManager.UninstallPluginAsync(pluginMetadata, removePluginSettings);
public long StopwatchLogDebug(string className, string message, Action action, [CallerMemberName] string methodName = "") => public long StopwatchLogDebug(string className, string message, Action action, [CallerMemberName] string methodName = "") =>

View file

@ -1,7 +1,9 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Flow.Launcher.Core.Plugin;
using Flow.Launcher.Plugin; using Flow.Launcher.Plugin;
using Flow.Launcher.ViewModel; using Flow.Launcher.ViewModel;
@ -96,6 +98,35 @@ public partial class SettingsPanePluginStoreViewModel : BaseModel
} }
} }
[RelayCommand]
private async Task InstallPluginAsync()
{
var file = GetFileFromDialog(
App.API.GetTranslation("SelectZipFile"),
$"{App.API.GetTranslation("ZipFiles")} (*.zip)|*.zip");
if (!string.IsNullOrEmpty(file))
await PluginInstaller.InstallPluginAndCheckRestartAsync(file);
}
private static string GetFileFromDialog(string title, string filter = "")
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "\\Downloads",
Multiselect = false,
CheckFileExists = true,
CheckPathExists = true,
Title = title,
Filter = filter
};
var result = dlg.ShowDialog();
if (result == true)
return dlg.FileName;
return string.Empty;
}
public bool SatisfiesFilter(PluginStoreItemViewModel plugin) public bool SatisfiesFilter(PluginStoreItemViewModel plugin)
{ {
// Check plugin language // Check plugin language

View file

@ -225,6 +225,30 @@
</cc:Card> </cc:Card>
</cc:CardGroup> </cc:CardGroup>
<cc:CardGroup Margin="0 14 0 0">
<cc:Card
Title="{DynamicResource autoRestartAfterChanging}"
Icon="&#xF83E;"
Sub="{DynamicResource autoRestartAfterChangingToolTip}"
Type="First">
<ui:ToggleSwitch
IsOn="{Binding Settings.AutoRestartAfterChanging}"
OffContent="{DynamicResource disable}"
OnContent="{DynamicResource enable}" />
</cc:Card>
<cc:Card
Title="{DynamicResource showUnknownSourceWarning}"
Icon="&#xE7BA;"
Sub="{DynamicResource showUnknownSourceWarningToolTip}"
Type="Last">
<ui:ToggleSwitch
IsOn="{Binding Settings.ShowUnknownSourceWarning}"
OffContent="{DynamicResource disable}"
OnContent="{DynamicResource enable}" />
</cc:Card>
</cc:CardGroup>
<cc:ExCard <cc:ExCard
Title="{DynamicResource searchDelay}" Title="{DynamicResource searchDelay}"
Margin="0 14 0 0" Margin="0 14 0 0"

View file

@ -92,6 +92,13 @@
</ui:MenuFlyout> </ui:MenuFlyout>
</ui:FlyoutService.Flyout> </ui:FlyoutService.Flyout>
</Button> </Button>
<Button
Height="34"
Margin="0 0 10 0"
Command="{Binding InstallPluginCommand}"
ToolTip="{DynamicResource installLocalPluginTooltip}">
<ui:FontIcon FontSize="14" Glyph="&#xE8DA;" />
</Button>
<TextBox <TextBox
Name="PluginStoreFilterTextbox" Name="PluginStoreFilterTextbox"
Width="150" Width="150"

View file

@ -1,5 +1,5 @@
using System; using System;
using System.Linq; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Flow.Launcher.Core.Plugin; using Flow.Launcher.Core.Plugin;
using Flow.Launcher.Plugin; using Flow.Launcher.Plugin;
@ -9,27 +9,28 @@ namespace Flow.Launcher.ViewModel
{ {
public partial class PluginStoreItemViewModel : BaseModel public partial class PluginStoreItemViewModel : BaseModel
{ {
private PluginPair PluginManagerData => PluginManager.GetPluginForId("9f8f9b14-2518-4907-b211-35ab6290dee7"); private readonly UserPlugin _newPlugin;
private readonly PluginPair _oldPluginPair;
public PluginStoreItemViewModel(UserPlugin plugin) public PluginStoreItemViewModel(UserPlugin plugin)
{ {
_plugin = plugin; _newPlugin = plugin;
_oldPluginPair = PluginManager.GetPluginForId(plugin.ID);
} }
private UserPlugin _plugin; public string ID => _newPlugin.ID;
public string Name => _newPlugin.Name;
public string Description => _newPlugin.Description;
public string Author => _newPlugin.Author;
public string Version => _newPlugin.Version;
public string Language => _newPlugin.Language;
public string Website => _newPlugin.Website;
public string UrlDownload => _newPlugin.UrlDownload;
public string UrlSourceCode => _newPlugin.UrlSourceCode;
public string IcoPath => _newPlugin.IcoPath;
public string ID => _plugin.ID; public bool LabelInstalled => _oldPluginPair != null;
public string Name => _plugin.Name; public bool LabelUpdate => LabelInstalled && new Version(_newPlugin.Version) > new Version(_oldPluginPair.Metadata.Version);
public string Description => _plugin.Description;
public string Author => _plugin.Author;
public string Version => _plugin.Version;
public string Language => _plugin.Language;
public string Website => _plugin.Website;
public string UrlDownload => _plugin.UrlDownload;
public string UrlSourceCode => _plugin.UrlSourceCode;
public string IcoPath => _plugin.IcoPath;
public bool LabelInstalled => PluginManager.GetPluginForId(_plugin.ID) != null;
public bool LabelUpdate => LabelInstalled && new Version(_plugin.Version) > new Version(PluginManager.GetPluginForId(_plugin.ID).Metadata.Version);
internal const string None = "None"; internal const string None = "None";
internal const string RecentlyUpdated = "RecentlyUpdated"; internal const string RecentlyUpdated = "RecentlyUpdated";
@ -41,15 +42,15 @@ namespace Flow.Launcher.ViewModel
get get
{ {
string category = None; string category = None;
if (DateTime.Now - _plugin.LatestReleaseDate < TimeSpan.FromDays(7)) if (DateTime.Now - _newPlugin.LatestReleaseDate < TimeSpan.FromDays(7))
{ {
category = RecentlyUpdated; category = RecentlyUpdated;
} }
if (DateTime.Now - _plugin.DateAdded < TimeSpan.FromDays(7)) if (DateTime.Now - _newPlugin.DateAdded < TimeSpan.FromDays(7))
{ {
category = NewRelease; category = NewRelease;
} }
if (PluginManager.GetPluginForId(_plugin.ID) != null) if (_oldPluginPair != null)
{ {
category = Installed; category = Installed;
} }
@ -59,11 +60,22 @@ namespace Flow.Launcher.ViewModel
} }
[RelayCommand] [RelayCommand]
private void ShowCommandQuery(string action) private async Task ShowCommandQueryAsync(string action)
{ {
var actionKeyword = PluginManagerData.Metadata.ActionKeywords.Any() ? PluginManagerData.Metadata.ActionKeywords[0] + " " : String.Empty; switch (action)
App.API.ChangeQuery($"{actionKeyword}{action} {_plugin.Name}"); {
App.API.ShowMainWindow(); case "install":
await PluginInstaller.InstallPluginAndCheckRestartAsync(_newPlugin);
break;
case "uninstall":
await PluginInstaller.UninstallPluginAndCheckRestartAsync(_oldPluginPair.Metadata);
break;
case "update":
await PluginInstaller.UpdatePluginAndCheckRestartAsync(_newPlugin, _oldPluginPair.Metadata);
break;
default:
break;
}
} }
} }
} }

View file

@ -1,5 +1,4 @@
using System.Linq; using System.Threading.Tasks;
using System.Threading.Tasks;
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Media; using System.Windows.Media;
@ -32,21 +31,6 @@ namespace Flow.Launcher.ViewModel
} }
} }
private static string PluginManagerActionKeyword
{
get
{
var keyword = PluginManager
.GetPluginForId("9f8f9b14-2518-4907-b211-35ab6290dee7")
.Metadata.ActionKeywords.FirstOrDefault();
return keyword switch
{
null or "*" => string.Empty,
_ => keyword
};
}
}
private async Task LoadIconAsync() private async Task LoadIconAsync()
{ {
Image = await App.API.LoadImageAsync(PluginPair.Metadata.IcoPath); Image = await App.API.LoadImageAsync(PluginPair.Metadata.IcoPath);
@ -186,10 +170,9 @@ namespace Flow.Launcher.ViewModel
} }
[RelayCommand] [RelayCommand]
private void OpenDeletePluginWindow() private async Task OpenDeletePluginWindowAsync()
{ {
App.API.ChangeQuery($"{PluginManagerActionKeyword} uninstall {PluginPair.Metadata.Name}".Trim(), true); await PluginInstaller.UninstallPluginAndCheckRestartAsync(PluginPair.Metadata);
App.API.ShowMainWindow();
} }
[RelayCommand] [RelayCommand]

View file

@ -1,6 +1,5 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Windows;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin; using Flow.Launcher.Plugin;

View file

@ -45,10 +45,15 @@
<system:String x:Key="plugin_pluginsmanager_update_success_no_restart">Plugin {0} successfully updated. Please restart Flow.</system:String> <system:String x:Key="plugin_pluginsmanager_update_success_no_restart">Plugin {0} successfully updated. Please restart Flow.</system:String>
<system:String x:Key="plugin_pluginsmanager_update_all_success_no_restart">{0} plugins successfully updated. Please restart Flow.</system:String> <system:String x:Key="plugin_pluginsmanager_update_all_success_no_restart">{0} plugins successfully updated. Please restart Flow.</system:String>
<system:String x:Key="plugin_pluginsmanager_plugin_modified_error">Plugin {0} has already been modified. Please restart Flow before making any further changes.</system:String> <system:String x:Key="plugin_pluginsmanager_plugin_modified_error">Plugin {0} has already been modified. Please restart Flow before making any further changes.</system:String>
<system:String x:Key="plugin_pluginsmanager_plugin_modified_error_title">{0} modified already</system:String>
<system:String x:Key="plugin_pluginsmanager_plugin_modified_error_message">Please restart Flow before making any further changes</system:String>
<system:String x:Key="plugin_pluginsmanager_invalid_zip_title">Invalid zip installer file</system:String>
<system:String x:Key="plugin_pluginsmanager_invalid_zip_subtitle">Please check if there is a plugin.json in {0}</system:String>
<!-- Plugin Infos --> <!-- Plugin Infos -->
<system:String x:Key="plugin_pluginsmanager_plugin_name">Plugins Manager</system:String> <system:String x:Key="plugin_pluginsmanager_plugin_name">Plugins Manager</system:String>
<system:String x:Key="plugin_pluginsmanager_plugin_description">Management of installing, uninstalling or updating Flow Launcher plugins</system:String> <system:String x:Key="plugin_pluginsmanager_plugin_description">Install, uninstall or update Flow Launcher plugins via the search window</system:String>
<system:String x:Key="plugin_pluginsmanager_unknown_author">Unknown Author</system:String> <system:String x:Key="plugin_pluginsmanager_unknown_author">Unknown Author</system:String>
<!-- Context menu items --> <!-- Context menu items -->
@ -63,5 +68,5 @@
<!-- Settings menu items --> <!-- Settings menu items -->
<system:String x:Key="plugin_pluginsmanager_plugin_settings_unknown_source">Install from unknown source warning</system:String> <system:String x:Key="plugin_pluginsmanager_plugin_settings_unknown_source">Install from unknown source warning</system:String>
<system:String x:Key="plugin_pluginsmanager_plugin_settings_auto_restart">Automatically restart Flow Launcher after installing/uninstalling/updating plugins</system:String> <system:String x:Key="plugin_pluginsmanager_plugin_settings_auto_restart">Restart Flow Launcher automatically after installing/uninstalling/updating plugin via Plugins Manager</system:String>
</ResourceDictionary> </ResourceDictionary>

View file

@ -114,6 +114,14 @@ namespace Flow.Launcher.Plugin.PluginsManager
return; return;
} }
if (Context.API.PluginModified(plugin.ID))
{
Context.API.ShowMsgError(
string.Format(Context.API.GetTranslation("plugin_pluginsmanager_plugin_modified_error_title"), plugin.Name),
Context.API.GetTranslation("plugin_pluginsmanager_plugin_modified_error_message"));
return;
}
string message; string message;
if (Settings.AutoRestartAfterChanging) if (Settings.AutoRestartAfterChanging)
{ {
@ -158,7 +166,8 @@ namespace Flow.Launcher.Plugin.PluginsManager
if (cts.IsCancellationRequested) if (cts.IsCancellationRequested)
return; return;
else else
Install(plugin, filePath); if (!Install(plugin, filePath))
return;
} }
catch (HttpRequestException e) catch (HttpRequestException e)
{ {
@ -196,7 +205,7 @@ namespace Flow.Launcher.Plugin.PluginsManager
} }
} }
private async Task DownloadFileAsync(string prgBoxTitle, string downloadUrl, string filePath, CancellationTokenSource cts, bool deleteFile = true, bool showProgress = true) private async Task DownloadFileAsync(string progressBoxTitle, string downloadUrl, string filePath, CancellationTokenSource cts, bool deleteFile = true, bool showProgress = true)
{ {
if (deleteFile && File.Exists(filePath)) if (deleteFile && File.Exists(filePath))
File.Delete(filePath); File.Delete(filePath);
@ -204,12 +213,12 @@ namespace Flow.Launcher.Plugin.PluginsManager
if (showProgress) if (showProgress)
{ {
var exceptionHappened = false; var exceptionHappened = false;
await Context.API.ShowProgressBoxAsync(prgBoxTitle, await Context.API.ShowProgressBoxAsync(progressBoxTitle,
async (reportProgress) => async (reportProgress) =>
{ {
if (reportProgress == null) if (reportProgress == null)
{ {
// when reportProgress is null, it means there is expcetion with the progress box // when reportProgress is null, it means there is exception with the progress box
// so we record it with exceptionHappened and return so that progress box will close instantly // so we record it with exceptionHappened and return so that progress box will close instantly
exceptionHappened = true; exceptionHappened = true;
return; return;
@ -242,6 +251,18 @@ namespace Flow.Launcher.Plugin.PluginsManager
if (FilesFolders.IsZipFilePath(search, checkFileExists: true)) if (FilesFolders.IsZipFilePath(search, checkFileExists: true))
{ {
pluginFromLocalPath = Utilities.GetPluginInfoFromZip(search); pluginFromLocalPath = Utilities.GetPluginInfoFromZip(search);
if (pluginFromLocalPath == null) return new List<Result>
{
new()
{
Title = Context.API.GetTranslation("plugin_pluginsmanager_invalid_zip_title"),
SubTitle = string.Format(Context.API.GetTranslation("plugin_pluginsmanager_invalid_zip_subtitle"),
search),
IcoPath = icoPath
}
};
pluginFromLocalPath.LocalInstallPath = search; pluginFromLocalPath.LocalInstallPath = search;
updateFromLocalPath = true; updateFromLocalPath = true;
} }
@ -261,6 +282,7 @@ namespace Flow.Launcher.Plugin.PluginsManager
select select
new new
{ {
existingPlugin.Metadata.ID,
pluginUpdateSource.Name, pluginUpdateSource.Name,
pluginUpdateSource.Author, pluginUpdateSource.Author,
CurrentVersion = existingPlugin.Metadata.Version, CurrentVersion = existingPlugin.Metadata.Version,
@ -290,6 +312,14 @@ namespace Flow.Launcher.Plugin.PluginsManager
IcoPath = x.IcoPath, IcoPath = x.IcoPath,
Action = e => Action = e =>
{ {
if (Context.API.PluginModified(x.ID))
{
Context.API.ShowMsgError(
string.Format(Context.API.GetTranslation("plugin_pluginsmanager_plugin_modified_error_title"), x.Name),
Context.API.GetTranslation("plugin_pluginsmanager_plugin_modified_error_message"));
return false;
}
string message; string message;
if (Settings.AutoRestartAfterChanging) if (Settings.AutoRestartAfterChanging)
{ {
@ -340,8 +370,11 @@ namespace Flow.Launcher.Plugin.PluginsManager
} }
else else
{ {
await Context.API.UpdatePluginAsync(x.PluginExistingMetadata, x.PluginNewUserPlugin, if (!await Context.API.UpdatePluginAsync(x.PluginExistingMetadata, x.PluginNewUserPlugin,
downloadToFilePath); downloadToFilePath))
{
return;
}
if (Settings.AutoRestartAfterChanging) if (Settings.AutoRestartAfterChanging)
{ {
@ -409,6 +442,14 @@ namespace Flow.Launcher.Plugin.PluginsManager
IcoPath = icoPath, IcoPath = icoPath,
AsyncAction = async e => AsyncAction = async e =>
{ {
if (resultsForUpdate.All(x => Context.API.PluginModified(x.ID)))
{
Context.API.ShowMsgError(Context.API.GetTranslation("plugin_pluginsmanager_install_error_title"),
string.Format(Context.API.GetTranslation("plugin_pluginsmanager_plugin_modified_error"),
string.Join(" ", resultsForUpdate.Select(x => x.Name))));
return false;
}
string message; string message;
if (Settings.AutoRestartAfterChanging) if (Settings.AutoRestartAfterChanging)
{ {
@ -430,6 +471,7 @@ namespace Flow.Launcher.Plugin.PluginsManager
return false; return false;
} }
var anyPluginSuccess = false;
await Task.WhenAll(resultsForUpdate.Select(async plugin => await Task.WhenAll(resultsForUpdate.Select(async plugin =>
{ {
var downloadToFilePath = Path.Combine(Path.GetTempPath(), var downloadToFilePath = Path.Combine(Path.GetTempPath(),
@ -447,8 +489,11 @@ namespace Flow.Launcher.Plugin.PluginsManager
if (cts.IsCancellationRequested) if (cts.IsCancellationRequested)
return; return;
else else
await Context.API.UpdatePluginAsync(plugin.PluginExistingMetadata, plugin.PluginNewUserPlugin, if (!await Context.API.UpdatePluginAsync(plugin.PluginExistingMetadata, plugin.PluginNewUserPlugin,
downloadToFilePath); downloadToFilePath))
return;
anyPluginSuccess = true;
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -461,6 +506,8 @@ namespace Flow.Launcher.Plugin.PluginsManager
} }
})); }));
if (!anyPluginSuccess) return false;
if (Settings.AutoRestartAfterChanging) if (Settings.AutoRestartAfterChanging)
{ {
Context.API.ShowMsg(Context.API.GetTranslation("plugin_pluginsmanager_update_title"), Context.API.ShowMsg(Context.API.GetTranslation("plugin_pluginsmanager_update_title"),
@ -556,6 +603,20 @@ namespace Flow.Launcher.Plugin.PluginsManager
{ {
var plugin = Utilities.GetPluginInfoFromZip(localPath); var plugin = Utilities.GetPluginInfoFromZip(localPath);
if (plugin == null)
{
return new List<Result>
{
new()
{
Title = Context.API.GetTranslation("plugin_pluginsmanager_invalid_zip_title"),
SubTitle = string.Format(Context.API.GetTranslation("plugin_pluginsmanager_invalid_zip_subtitle"),
localPath),
IcoPath = icoPath
}
};
}
plugin.LocalInstallPath = localPath; plugin.LocalInstallPath = localPath;
return new List<Result> return new List<Result>
@ -597,14 +658,17 @@ namespace Flow.Launcher.Plugin.PluginsManager
return false; return false;
var author = pieces[3]; var author = pieces[3];
var acceptedHost = "github.com";
var acceptedSource = "https://github.com"; var acceptedSource = "https://github.com";
var constructedUrlPart = string.Format("{0}/{1}/", acceptedSource, author); var constructedUrlPart = string.Format("{0}/{1}/", acceptedSource, author);
return url.StartsWith(acceptedSource) && if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Host != acceptedHost)
Context.API.GetAllPlugins().Any(x => return false;
!string.IsNullOrEmpty(x.Metadata.Website) &&
x.Metadata.Website.StartsWith(constructedUrlPart) return Context.API.GetAllPlugins().Any(x =>
); !string.IsNullOrEmpty(x.Metadata.Website) &&
x.Metadata.Website.StartsWith(constructedUrlPart)
);
} }
internal async ValueTask<List<Result>> RequestInstallOrUpdateAsync(string search, CancellationToken token, internal async ValueTask<List<Result>> RequestInstallOrUpdateAsync(string search, CancellationToken token,
@ -644,7 +708,7 @@ namespace Flow.Launcher.Plugin.PluginsManager
return Search(results, search); return Search(results, search);
} }
private void Install(UserPlugin plugin, string downloadedFilePath) private bool Install(UserPlugin plugin, string downloadedFilePath)
{ {
if (!File.Exists(downloadedFilePath)) if (!File.Exists(downloadedFilePath))
throw new FileNotFoundException($"Plugin {plugin.ID} zip file not found at {downloadedFilePath}", throw new FileNotFoundException($"Plugin {plugin.ID} zip file not found at {downloadedFilePath}",
@ -652,10 +716,13 @@ namespace Flow.Launcher.Plugin.PluginsManager
try try
{ {
Context.API.InstallPlugin(plugin, downloadedFilePath); if (!Context.API.InstallPlugin(plugin, downloadedFilePath))
return false;
if (!plugin.IsFromLocalInstallPath) if (!plugin.IsFromLocalInstallPath)
File.Delete(downloadedFilePath); File.Delete(downloadedFilePath);
return true;
} }
catch (FileNotFoundException e) catch (FileNotFoundException e)
{ {
@ -677,6 +744,8 @@ namespace Flow.Launcher.Plugin.PluginsManager
plugin.Name)); plugin.Name));
Context.API.LogException(ClassName, e.Message, e); Context.API.LogException(ClassName, e.Message, e);
} }
return false;
} }
internal List<Result> RequestUninstall(string search) internal List<Result> RequestUninstall(string search)
@ -691,6 +760,14 @@ namespace Flow.Launcher.Plugin.PluginsManager
IcoPath = x.Metadata.IcoPath, IcoPath = x.Metadata.IcoPath,
AsyncAction = async e => AsyncAction = async e =>
{ {
if (Context.API.PluginModified(x.Metadata.ID))
{
Context.API.ShowMsgError(
string.Format(Context.API.GetTranslation("plugin_pluginsmanager_plugin_modified_error_title"), x.Metadata.Name),
Context.API.GetTranslation("plugin_pluginsmanager_plugin_modified_error_message"));
return false;
}
string message; string message;
if (Settings.AutoRestartAfterChanging) if (Settings.AutoRestartAfterChanging)
{ {
@ -712,7 +789,10 @@ namespace Flow.Launcher.Plugin.PluginsManager
MessageBoxButton.YesNo) == MessageBoxResult.Yes) MessageBoxButton.YesNo) == MessageBoxResult.Yes)
{ {
Context.API.HideMainWindow(); Context.API.HideMainWindow();
await UninstallAsync(x.Metadata); if (!await UninstallAsync(x.Metadata))
{
return false;
}
if (Settings.AutoRestartAfterChanging) if (Settings.AutoRestartAfterChanging)
{ {
Context.API.RestartApp(); Context.API.RestartApp();
@ -745,7 +825,7 @@ namespace Flow.Launcher.Plugin.PluginsManager
return Search(results, search); return Search(results, search);
} }
private async Task UninstallAsync(PluginMetadata plugin) private async Task<bool> UninstallAsync(PluginMetadata plugin)
{ {
try try
{ {
@ -753,13 +833,14 @@ namespace Flow.Launcher.Plugin.PluginsManager
Context.API.GetTranslation("plugin_pluginsmanager_keep_plugin_settings_subtitle"), Context.API.GetTranslation("plugin_pluginsmanager_keep_plugin_settings_subtitle"),
Context.API.GetTranslation("plugin_pluginsmanager_keep_plugin_settings_title"), Context.API.GetTranslation("plugin_pluginsmanager_keep_plugin_settings_title"),
button: MessageBoxButton.YesNo) == MessageBoxResult.No; button: MessageBoxButton.YesNo) == MessageBoxResult.No;
await Context.API.UninstallPluginAsync(plugin, removePluginSettings); return await Context.API.UninstallPluginAsync(plugin, removePluginSettings);
} }
catch (ArgumentException e) catch (ArgumentException e)
{ {
Context.API.LogException(ClassName, e.Message, e); Context.API.LogException(ClassName, e.Message, e);
Context.API.ShowMsgError(Context.API.GetTranslation("plugin_pluginsmanager_uninstall_error_title"), Context.API.ShowMsgError(Context.API.GetTranslation("plugin_pluginsmanager_uninstall_error_title"),
Context.API.GetTranslation("plugin_pluginsmanager_plugin_modified_error")); string.Format(Context.API.GetTranslation("plugin_pluginsmanager_plugin_modified_error"), plugin.Name));
return false;
} }
} }
} }

View file

@ -65,9 +65,7 @@ namespace Flow.Launcher.Plugin.PluginsManager
using (ZipArchive archive = System.IO.Compression.ZipFile.OpenRead(filePath)) using (ZipArchive archive = System.IO.Compression.ZipFile.OpenRead(filePath))
{ {
var pluginJsonPath = archive.Entries.FirstOrDefault(x => x.Name == "plugin.json").ToString(); var pluginJsonEntry = archive.Entries.FirstOrDefault(x => x.Name == "plugin.json");
ZipArchiveEntry pluginJsonEntry = archive.GetEntry(pluginJsonPath);
if (pluginJsonEntry != null) if (pluginJsonEntry != null)
{ {
using Stream stream = pluginJsonEntry.Open(); using Stream stream = pluginJsonEntry.Open();

View file

@ -4,7 +4,7 @@
"pm" "pm"
], ],
"Name": "Plugins Manager", "Name": "Plugins Manager",
"Description": "Management of installing, uninstalling or updating Flow Launcher plugins", "Description": "Install, uninstall or update Flow Launcher plugins via the search window",
"Author": "Jeremy Wu", "Author": "Jeremy Wu",
"Version": "1.0.0", "Version": "1.0.0",
"Language": "csharp", "Language": "csharp",