Skip to content

Add internal model for plugin management #3572

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 41 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 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
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
317 changes: 317 additions & 0 deletions Flow.Launcher.Core/Plugin/PluginInstaller.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
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>
/// Helper 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>();

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

View workflow job for this annotation

GitHub Actions / Check Spelling

`Ioc` is not a recognized word. (unrecognized-spelling)

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));
}
}

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);
}

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));
}
}

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));
}
}

private 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,

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

View workflow job for this annotation

GitHub Actions / Check Spelling

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

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