Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
949344a
Add internal model for plugin management
Jack251970 May 22, 2025
76736b7
Fix typo
Jack251970 May 22, 2025
c6c7ff8
Handle default
Jack251970 May 22, 2025
6044f87
Do not restart on failure
Jack251970 May 22, 2025
383c0ae
Improve code quality
Jack251970 May 23, 2025
406c29f
Merge branch 'dev' into plugin_store_item_vm_null
Jack251970 Jun 1, 2025
6bf7f00
Add unknown source warning setting
Jack251970 Jun 1, 2025
f9e7b82
Merge branch 'dev' into plugin_store_item_vm_null
Jack251970 Jun 3, 2025
248b098
Support installing from local path
Jack251970 Jun 9, 2025
e79239e
Merge branch 'dev' into plugin_store_item_vm_null
Jack251970 Jun 13, 2025
19c8104
Merge branch 'dev' into plugin_store_item_vm_null
Jack251970 Jun 15, 2025
a948636
Merge branch 'dev' into plugin_store_item_vm_null
Jack251970 Jun 28, 2025
7c3c768
Add type for card elements inside card group
Jack251970 Jun 29, 2025
73a6fb6
Move codes to new place
Jack251970 Jun 29, 2025
135fd03
Improve code quality
Jack251970 Jun 29, 2025
c5dd19e
Use Microsoft.Win32.OpenFileDialog instead
Jack251970 Jun 29, 2025
104b4b2
Improve code quality
Jack251970 Jun 29, 2025
3e9e91d
Fix possible exception when extracting zip file
Jack251970 Jun 29, 2025
a3a0c59
Check url nullability
Jack251970 Jun 29, 2025
bdb3616
Improve string resource
Jack251970 Jun 29, 2025
8e6a410
Use url host
Jack251970 Jun 29, 2025
19cb3ea
Fix typos
Jack251970 Jun 29, 2025
9e868e7
Move plugin installer location
Jack251970 Jun 29, 2025
dafb0ca
Remove unused using
Jack251970 Jun 30, 2025
ea25a66
Fix an issue that after uninstalling pm, store no longer fetches plug…
Jack251970 Jun 30, 2025
5b8b84a
Fix code comments
Jack251970 Jun 30, 2025
01e749a
Fix an issue that store install/uninstall same plugin without restart…
Jack251970 Jun 30, 2025
6318bbe
Fix an issue that pm install/uninstall same plugin without restart sa…
Jack251970 Jun 30, 2025
4e4b59e
Merge branch 'dev' into plugin_store_item_vm_null
jjw24 Jun 30, 2025
1bb7286
Show error message instead of message
Jack251970 Jul 1, 2025
a8bc55d
Add return for UninstallPluginAsync
Jack251970 Jul 1, 2025
ce6c2cb
Add return value for api functions
Jack251970 Jul 1, 2025
2a4bd50
Resolve conflicts
Jack251970 Jul 1, 2025
71043be
Fix string format issue
Jack251970 Jul 1, 2025
07947c5
Fix an issue that pm install/uninstall same plugin without restart sa…
Jack251970 Jul 1, 2025
5bfdc58
Change the notification message title about plugin already installed/…
Jack251970 Jul 1, 2025
650a156
Improve strings
Jack251970 Jul 1, 2025
e1f9d60
Merge branch 'dev' into plugin_store_item_vm_null
jjw24 Jul 3, 2025
ae206c3
update Plugin Store & Plugins Manager texts
jjw24 Jul 3, 2025
c06e6d1
Check modified state when updating plugins
Jack251970 Jul 3, 2025
a0dff3f
Merge branch 'dev' into plugin_store_item_vm_null
Jack251970 Jul 5, 2025
052bbb9
Fix format
Jack251970 Jul 6, 2025
c23eb50
Merge branch 'dev' into plugin_store_item_vm_null
Jack251970 Jul 6, 2025
edb1450
Fix build issue
Jack251970 Jul 6, 2025
a63c8b0
updates to method summary and correct spell check errors
jjw24 Jul 7, 2025
236bff1
fix spelling
jjw24 Jul 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/actions/spelling/expect.txt
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,7 @@ pluginsmanager
alreadyexists
Softpedia
img
Reloadable
metadatas
WMP
VSTHRD
1 change: 1 addition & 0 deletions .github/actions/spelling/patterns.txt
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,4 @@
\bPortuguês (Brasil)\b
\bčeština\b
\bPortuguês\b
\bIoc\b
353 changes: 353 additions & 0 deletions Flow.Launcher.Core/Plugin/PluginInstaller.cs
Original file line number Diff line number Diff line change
@@ -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"),

Check warning on line 106 in Flow.Launcher.Core/Plugin/PluginInstaller.cs

View workflow job for this annotation

GitHub Actions / Check Spelling

`installbtn` is not a recognized word. (unrecognized-spelling)
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)
);
}
}
Loading
Loading