2025-06-29 07:48:08 +00:00
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 ;
2025-06-29 14:35:14 +00:00
namespace Flow.Launcher.Core.Plugin ;
2025-06-29 07:48:08 +00:00
/// <summary>
2025-07-07 11:35:12 +00:00
/// Class for installing, updating, and uninstalling plugins.
2025-06-29 07:48:08 +00:00
/// </summary>
2025-06-29 14:35:14 +00:00
public static class PluginInstaller
2025-06-29 07:48:08 +00:00
{
2025-06-29 14:35:14 +00:00
private static readonly string ClassName = nameof ( PluginInstaller ) ;
2025-06-29 07:48:08 +00:00
private static readonly Settings Settings = Ioc . Default . GetRequiredService < Settings > ( ) ;
2025-06-29 14:35:14 +00:00
// 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 > ( ) ;
2025-07-07 11:35:12 +00:00
/// <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>
2025-06-29 07:48:08 +00:00
public static async Task InstallPluginAndCheckRestartAsync ( UserPlugin newPlugin )
{
2025-06-30 05:16:59 +00:00
if ( API . PluginModified ( newPlugin . ID ) )
{
2025-07-01 05:18:26 +00:00
API . ShowMsgError ( string . Format ( API . GetTranslation ( "pluginModifiedAlreadyTitle" ) , newPlugin . Name ) ,
2025-06-30 05:16:59 +00:00
API . GetTranslation ( "pluginModifiedAlreadyMessage" ) ) ;
return ;
}
2025-06-29 14:35:14 +00:00
if ( API . ShowMsgBox (
2025-06-29 07:48:08 +00:00
string . Format (
2025-06-29 14:35:14 +00:00
API . GetTranslation ( "InstallPromptSubtitle" ) ,
2025-06-29 07:48:08 +00:00
newPlugin . Name , newPlugin . Author , Environment . NewLine ) ,
2025-06-29 14:35:14 +00:00
API . GetTranslation ( "InstallPromptTitle" ) ,
2025-06-29 07:48:08 +00:00
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 (
2025-06-29 14:35:14 +00:00
$"{API.GetTranslation(" DownloadingPlugin ")} {newPlugin.Name}" ,
2025-06-29 07:48:08 +00:00
newPlugin . UrlDownload , filePath , cts ) ;
}
else
{
filePath = newPlugin . LocalInstallPath ;
}
// check if user cancelled download before installing plugin
if ( cts . IsCancellationRequested )
{
return ;
}
2025-06-29 07:58:52 +00:00
if ( ! File . Exists ( filePath ) )
2025-06-29 07:48:08 +00:00
{
2025-06-29 07:58:52 +00:00
throw new FileNotFoundException ( $"Plugin {newPlugin.ID} zip file not found at {filePath}" , filePath ) ;
}
2025-06-29 07:48:08 +00:00
2025-07-01 01:05:21 +00:00
if ( ! API . InstallPlugin ( newPlugin , filePath ) )
{
return ;
}
2025-06-29 07:48:08 +00:00
2025-06-29 07:58:52 +00:00
if ( ! newPlugin . IsFromLocalInstallPath )
{
File . Delete ( filePath ) ;
2025-06-29 07:48:08 +00:00
}
}
catch ( Exception e )
{
2025-06-29 14:35:14 +00:00
API . LogException ( ClassName , "Failed to install plugin" , e ) ;
API . ShowMsgError ( API . GetTranslation ( "ErrorInstallingPlugin" ) ) ;
2025-06-30 04:58:44 +00:00
return ; // do not restart on failure
2025-06-29 07:48:08 +00:00
}
if ( Settings . AutoRestartAfterChanging )
{
2025-06-29 14:35:14 +00:00
API . RestartApp ( ) ;
2025-06-29 07:48:08 +00:00
}
else
{
2025-06-29 14:35:14 +00:00
API . ShowMsg (
API . GetTranslation ( "installbtn" ) ,
2025-06-29 07:48:08 +00:00
string . Format (
2025-06-29 14:35:14 +00:00
API . GetTranslation (
2025-06-29 07:48:08 +00:00
"InstallSuccessNoRestart" ) ,
newPlugin . Name ) ) ;
}
}
2025-07-07 11:35:12 +00:00
/// <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>
2025-06-29 07:48:08 +00:00
public static async Task InstallPluginAndCheckRestartAsync ( string filePath )
{
UserPlugin plugin ;
try
{
using ZipArchive archive = ZipFile . OpenRead ( filePath ) ;
2025-06-29 08:09:06 +00:00
var pluginJsonEntry = archive . Entries . FirstOrDefault ( x = > x . Name = = "plugin.json" ) ? ?
2025-06-29 07:48:08 +00:00
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 )
{
2025-06-29 14:35:14 +00:00
API . LogException ( ClassName , "Failed to validate zip file" , e ) ;
API . ShowMsgError ( API . GetTranslation ( "ZipFileNotHavePluginJson" ) ) ;
2025-06-29 07:48:08 +00:00
return ;
}
2025-06-30 05:16:59 +00:00
if ( API . PluginModified ( plugin . ID ) )
{
2025-07-01 05:18:26 +00:00
API . ShowMsgError ( string . Format ( API . GetTranslation ( "pluginModifiedAlreadyTitle" ) , plugin . Name ) ,
2025-06-30 05:16:59 +00:00
API . GetTranslation ( "pluginModifiedAlreadyMessage" ) ) ;
return ;
}
2025-06-29 07:48:08 +00:00
if ( Settings . ShowUnknownSourceWarning )
{
if ( ! InstallSourceKnown ( plugin . Website )
2025-06-29 14:35:14 +00:00
& & API . ShowMsgBox ( string . Format (
API . GetTranslation ( "InstallFromUnknownSourceSubtitle" ) , Environment . NewLine ) ,
API . GetTranslation ( "InstallFromUnknownSourceTitle" ) ,
2025-06-29 07:48:08 +00:00
MessageBoxButton . YesNo ) = = MessageBoxResult . No )
return ;
}
await InstallPluginAndCheckRestartAsync ( plugin ) ;
}
2025-07-07 11:35:12 +00:00
/// <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>
2025-06-29 07:48:08 +00:00
public static async Task UninstallPluginAndCheckRestartAsync ( PluginMetadata oldPlugin )
{
2025-06-30 05:16:59 +00:00
if ( API . PluginModified ( oldPlugin . ID ) )
{
2025-07-01 05:18:26 +00:00
API . ShowMsgError ( string . Format ( API . GetTranslation ( "pluginModifiedAlreadyTitle" ) , oldPlugin . Name ) ,
2025-06-30 05:16:59 +00:00
API . GetTranslation ( "pluginModifiedAlreadyMessage" ) ) ;
return ;
}
2025-06-29 14:35:14 +00:00
if ( API . ShowMsgBox (
2025-06-29 07:48:08 +00:00
string . Format (
2025-06-29 14:35:14 +00:00
API . GetTranslation ( "UninstallPromptSubtitle" ) ,
2025-06-29 07:48:08 +00:00
oldPlugin . Name , oldPlugin . Author , Environment . NewLine ) ,
2025-06-29 14:35:14 +00:00
API . GetTranslation ( "UninstallPromptTitle" ) ,
2025-06-29 07:48:08 +00:00
button : MessageBoxButton . YesNo ) ! = MessageBoxResult . Yes ) return ;
2025-06-29 14:35:14 +00:00
var removePluginSettings = API . ShowMsgBox (
API . GetTranslation ( "KeepPluginSettingsSubtitle" ) ,
API . GetTranslation ( "KeepPluginSettingsTitle" ) ,
2025-06-29 07:48:08 +00:00
button : MessageBoxButton . YesNo ) = = MessageBoxResult . No ;
try
{
2025-07-01 01:05:21 +00:00
if ( ! await API . UninstallPluginAsync ( oldPlugin , removePluginSettings ) )
{
return ;
}
2025-06-29 07:48:08 +00:00
}
catch ( Exception e )
{
2025-06-29 14:35:14 +00:00
API . LogException ( ClassName , "Failed to uninstall plugin" , e ) ;
API . ShowMsgError ( API . GetTranslation ( "ErrorUninstallingPlugin" ) ) ;
2025-06-30 04:58:44 +00:00
return ; // don not restart on failure
2025-06-29 07:48:08 +00:00
}
if ( Settings . AutoRestartAfterChanging )
{
2025-06-29 14:35:14 +00:00
API . RestartApp ( ) ;
2025-06-29 07:48:08 +00:00
}
else
{
2025-06-29 14:35:14 +00:00
API . ShowMsg (
API . GetTranslation ( "uninstallbtn" ) ,
2025-06-29 07:48:08 +00:00
string . Format (
2025-06-29 14:35:14 +00:00
API . GetTranslation (
2025-06-29 07:48:08 +00:00
"UninstallSuccessNoRestart" ) ,
oldPlugin . Name ) ) ;
}
}
2025-07-07 11:35:12 +00:00
/// <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>
2025-06-29 07:48:08 +00:00
public static async Task UpdatePluginAndCheckRestartAsync ( UserPlugin newPlugin , PluginMetadata oldPlugin )
{
2025-06-29 14:35:14 +00:00
if ( API . ShowMsgBox (
2025-06-29 07:48:08 +00:00
string . Format (
2025-06-29 14:35:14 +00:00
API . GetTranslation ( "UpdatePromptSubtitle" ) ,
2025-06-29 07:48:08 +00:00
oldPlugin . Name , oldPlugin . Author , Environment . NewLine ) ,
2025-06-29 14:35:14 +00:00
API . GetTranslation ( "UpdatePromptTitle" ) ,
2025-06-29 07:48:08 +00:00
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 (
2025-06-29 14:35:14 +00:00
$"{API.GetTranslation(" DownloadingPlugin ")} {newPlugin.Name}" ,
2025-06-29 07:48:08 +00:00
newPlugin . UrlDownload , filePath , cts ) ;
}
else
{
filePath = newPlugin . LocalInstallPath ;
}
// check if user cancelled download before installing plugin
if ( cts . IsCancellationRequested )
{
return ;
}
2025-06-29 07:58:52 +00:00
2025-07-01 01:05:21 +00:00
if ( ! await API . UpdatePluginAsync ( oldPlugin , newPlugin , filePath ) )
{
return ;
}
2025-06-29 07:48:08 +00:00
}
catch ( Exception e )
{
2025-06-29 14:35:14 +00:00
API . LogException ( ClassName , "Failed to update plugin" , e ) ;
API . ShowMsgError ( API . GetTranslation ( "ErrorUpdatingPlugin" ) ) ;
2025-06-30 04:58:44 +00:00
return ; // do not restart on failure
2025-06-29 07:48:08 +00:00
}
if ( Settings . AutoRestartAfterChanging )
{
2025-06-29 14:35:14 +00:00
API . RestartApp ( ) ;
2025-06-29 07:48:08 +00:00
}
else
{
2025-06-29 14:35:14 +00:00
API . ShowMsg (
API . GetTranslation ( "updatebtn" ) ,
2025-06-29 07:48:08 +00:00
string . Format (
2025-06-29 14:35:14 +00:00
API . GetTranslation (
2025-06-29 07:48:08 +00:00
"UpdateSuccessNoRestart" ) ,
newPlugin . Name ) ) ;
}
}
2025-07-07 11:35:12 +00:00
/// <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 )
2025-06-29 07:48:08 +00:00
{
if ( deleteFile & & File . Exists ( filePath ) )
File . Delete ( filePath ) ;
if ( showProgress )
{
var exceptionHappened = false ;
2025-07-07 11:35:12 +00:00
await API . ShowProgressBoxAsync ( progressBoxTitle ,
2025-06-29 07:48:08 +00:00
async ( reportProgress ) = >
{
if ( reportProgress = = null )
{
2025-06-29 10:20:47 +00:00
// when reportProgress is null, it means there is exception with the progress box
2025-06-29 07:48:08 +00:00
// so we record it with exceptionHappened and return so that progress box will close instantly
exceptionHappened = true ;
return ;
}
else
{
2025-06-29 14:35:14 +00:00
await API . HttpDownloadAsync ( downloadUrl , filePath , reportProgress , cts . Token ) . ConfigureAwait ( false ) ;
2025-06-29 07:48:08 +00:00
}
} , cts . Cancel ) ;
// if exception happened while downloading and user does not cancel downloading,
// we need to redownload the plugin
if ( exceptionHappened & & ( ! cts . IsCancellationRequested ) )
2025-06-29 14:35:14 +00:00
await API . HttpDownloadAsync ( downloadUrl , filePath , token : cts . Token ) . ConfigureAwait ( false ) ;
2025-06-29 07:48:08 +00:00
}
else
{
2025-06-29 14:35:14 +00:00
await API . HttpDownloadAsync ( downloadUrl , filePath , token : cts . Token ) . ConfigureAwait ( false ) ;
2025-06-29 07:48:08 +00:00
}
}
2025-07-07 11:35:12 +00:00
/// <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>
2025-06-29 07:48:08 +00:00
private static bool InstallSourceKnown ( string url )
{
2025-06-29 08:29:45 +00:00
if ( string . IsNullOrEmpty ( url ) )
return false ;
2025-06-29 07:48:08 +00:00
var pieces = url . Split ( '/' ) ;
if ( pieces . Length < 4 )
return false ;
var author = pieces [ 3 ] ;
2025-06-29 09:06:29 +00:00
var acceptedHost = "github.com" ;
2025-06-29 07:48:08 +00:00
var acceptedSource = "https://github.com" ;
var constructedUrlPart = string . Format ( "{0}/{1}/" , acceptedSource , author ) ;
2025-06-29 09:06:29 +00:00
if ( ! Uri . TryCreate ( url , UriKind . Absolute , out var uri ) | | uri . Host ! = acceptedHost )
return false ;
2025-06-29 14:35:14 +00:00
return API . GetAllPlugins ( ) . Any ( x = >
2025-06-29 09:06:29 +00:00
! string . IsNullOrEmpty ( x . Metadata . Website ) & &
x . Metadata . Website . StartsWith ( constructedUrlPart )
) ;
2025-06-29 07:48:08 +00:00
}
}