Skip to content

Commit 0f510b1

Browse files
authored
Merge pull request #3572 from Flow-Launcher/plugin_store_item_vm_null
Fix separation of Plugin Store and Plugins Manager plugin
2 parents 727390d + 236bff1 commit 0f510b1

File tree

20 files changed

+683
-117
lines changed

20 files changed

+683
-117
lines changed

.github/actions/spelling/expect.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,7 @@ pluginsmanager
9999
alreadyexists
100100
Softpedia
101101
img
102+
Reloadable
103+
metadatas
104+
WMP
105+
VSTHRD

.github/actions/spelling/patterns.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,4 @@
133133
\bPortuguês (Brasil)\b
134134
\bčeština\b
135135
\bPortuguês\b
136+
\bIoc\b
Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
using System;
2+
using System.IO;
3+
using System.IO.Compression;
4+
using System.Linq;
5+
using System.Text.Json;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using System.Windows;
9+
using CommunityToolkit.Mvvm.DependencyInjection;
10+
using Flow.Launcher.Infrastructure.UserSettings;
11+
using Flow.Launcher.Plugin;
12+
13+
namespace Flow.Launcher.Core.Plugin;
14+
15+
/// <summary>
16+
/// Class for installing, updating, and uninstalling plugins.
17+
/// </summary>
18+
public static class PluginInstaller
19+
{
20+
private static readonly string ClassName = nameof(PluginInstaller);
21+
22+
private static readonly Settings Settings = Ioc.Default.GetRequiredService<Settings>();
23+
24+
// We should not initialize API in static constructor because it will create another API instance
25+
private static IPublicAPI api = null;
26+
private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService<IPublicAPI>();
27+
28+
/// <summary>
29+
/// Installs a plugin and restarts the application if required by settings. Prompts user for confirmation and handles download if needed.
30+
/// </summary>
31+
/// <param name="newPlugin">The plugin to install.</param>
32+
/// <returns>A Task representing the asynchronous install operation.</returns>
33+
public static async Task InstallPluginAndCheckRestartAsync(UserPlugin newPlugin)
34+
{
35+
if (API.PluginModified(newPlugin.ID))
36+
{
37+
API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), newPlugin.Name),
38+
API.GetTranslation("pluginModifiedAlreadyMessage"));
39+
return;
40+
}
41+
42+
if (API.ShowMsgBox(
43+
string.Format(
44+
API.GetTranslation("InstallPromptSubtitle"),
45+
newPlugin.Name, newPlugin.Author, Environment.NewLine),
46+
API.GetTranslation("InstallPromptTitle"),
47+
button: MessageBoxButton.YesNo) != MessageBoxResult.Yes) return;
48+
49+
try
50+
{
51+
// at minimum should provide a name, but handle plugin that is not downloaded from plugins manifest and is a url download
52+
var downloadFilename = string.IsNullOrEmpty(newPlugin.Version)
53+
? $"{newPlugin.Name}-{Guid.NewGuid()}.zip"
54+
: $"{newPlugin.Name}-{newPlugin.Version}.zip";
55+
56+
var filePath = Path.Combine(Path.GetTempPath(), downloadFilename);
57+
58+
using var cts = new CancellationTokenSource();
59+
60+
if (!newPlugin.IsFromLocalInstallPath)
61+
{
62+
await DownloadFileAsync(
63+
$"{API.GetTranslation("DownloadingPlugin")} {newPlugin.Name}",
64+
newPlugin.UrlDownload, filePath, cts);
65+
}
66+
else
67+
{
68+
filePath = newPlugin.LocalInstallPath;
69+
}
70+
71+
// check if user cancelled download before installing plugin
72+
if (cts.IsCancellationRequested)
73+
{
74+
return;
75+
}
76+
77+
if (!File.Exists(filePath))
78+
{
79+
throw new FileNotFoundException($"Plugin {newPlugin.ID} zip file not found at {filePath}", filePath);
80+
}
81+
82+
if (!API.InstallPlugin(newPlugin, filePath))
83+
{
84+
return;
85+
}
86+
87+
if (!newPlugin.IsFromLocalInstallPath)
88+
{
89+
File.Delete(filePath);
90+
}
91+
}
92+
catch (Exception e)
93+
{
94+
API.LogException(ClassName, "Failed to install plugin", e);
95+
API.ShowMsgError(API.GetTranslation("ErrorInstallingPlugin"));
96+
return; // do not restart on failure
97+
}
98+
99+
if (Settings.AutoRestartAfterChanging)
100+
{
101+
API.RestartApp();
102+
}
103+
else
104+
{
105+
API.ShowMsg(
106+
API.GetTranslation("installbtn"),
107+
string.Format(
108+
API.GetTranslation(
109+
"InstallSuccessNoRestart"),
110+
newPlugin.Name));
111+
}
112+
}
113+
114+
/// <summary>
115+
/// Installs a plugin from a local zip file and restarts the application if required by settings. Validates the zip and prompts user for confirmation.
116+
/// </summary>
117+
/// <param name="filePath">The path to the plugin zip file.</param>
118+
/// <returns>A Task representing the asynchronous install operation.</returns>
119+
public static async Task InstallPluginAndCheckRestartAsync(string filePath)
120+
{
121+
UserPlugin plugin;
122+
try
123+
{
124+
using ZipArchive archive = ZipFile.OpenRead(filePath);
125+
var pluginJsonEntry = archive.Entries.FirstOrDefault(x => x.Name == "plugin.json") ??
126+
throw new FileNotFoundException("The zip file does not contain a plugin.json file.");
127+
128+
using Stream stream = pluginJsonEntry.Open();
129+
plugin = JsonSerializer.Deserialize<UserPlugin>(stream);
130+
plugin.IcoPath = "Images\\zipfolder.png";
131+
plugin.LocalInstallPath = filePath;
132+
}
133+
catch (Exception e)
134+
{
135+
API.LogException(ClassName, "Failed to validate zip file", e);
136+
API.ShowMsgError(API.GetTranslation("ZipFileNotHavePluginJson"));
137+
return;
138+
}
139+
140+
if (API.PluginModified(plugin.ID))
141+
{
142+
API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), plugin.Name),
143+
API.GetTranslation("pluginModifiedAlreadyMessage"));
144+
return;
145+
}
146+
147+
if (Settings.ShowUnknownSourceWarning)
148+
{
149+
if (!InstallSourceKnown(plugin.Website)
150+
&& API.ShowMsgBox(string.Format(
151+
API.GetTranslation("InstallFromUnknownSourceSubtitle"), Environment.NewLine),
152+
API.GetTranslation("InstallFromUnknownSourceTitle"),
153+
MessageBoxButton.YesNo) == MessageBoxResult.No)
154+
return;
155+
}
156+
157+
await InstallPluginAndCheckRestartAsync(plugin);
158+
}
159+
160+
/// <summary>
161+
/// Uninstalls a plugin and restarts the application if required by settings. Prompts user for confirmation and whether to keep plugin settings.
162+
/// </summary>
163+
/// <param name="oldPlugin">The plugin metadata to uninstall.</param>
164+
/// <returns>A Task representing the asynchronous uninstall operation.</returns>
165+
public static async Task UninstallPluginAndCheckRestartAsync(PluginMetadata oldPlugin)
166+
{
167+
if (API.PluginModified(oldPlugin.ID))
168+
{
169+
API.ShowMsgError(string.Format(API.GetTranslation("pluginModifiedAlreadyTitle"), oldPlugin.Name),
170+
API.GetTranslation("pluginModifiedAlreadyMessage"));
171+
return;
172+
}
173+
174+
if (API.ShowMsgBox(
175+
string.Format(
176+
API.GetTranslation("UninstallPromptSubtitle"),
177+
oldPlugin.Name, oldPlugin.Author, Environment.NewLine),
178+
API.GetTranslation("UninstallPromptTitle"),
179+
button: MessageBoxButton.YesNo) != MessageBoxResult.Yes) return;
180+
181+
var removePluginSettings = API.ShowMsgBox(
182+
API.GetTranslation("KeepPluginSettingsSubtitle"),
183+
API.GetTranslation("KeepPluginSettingsTitle"),
184+
button: MessageBoxButton.YesNo) == MessageBoxResult.No;
185+
186+
try
187+
{
188+
if (!await API.UninstallPluginAsync(oldPlugin, removePluginSettings))
189+
{
190+
return;
191+
}
192+
}
193+
catch (Exception e)
194+
{
195+
API.LogException(ClassName, "Failed to uninstall plugin", e);
196+
API.ShowMsgError(API.GetTranslation("ErrorUninstallingPlugin"));
197+
return; // don not restart on failure
198+
}
199+
200+
if (Settings.AutoRestartAfterChanging)
201+
{
202+
API.RestartApp();
203+
}
204+
else
205+
{
206+
API.ShowMsg(
207+
API.GetTranslation("uninstallbtn"),
208+
string.Format(
209+
API.GetTranslation(
210+
"UninstallSuccessNoRestart"),
211+
oldPlugin.Name));
212+
}
213+
}
214+
215+
/// <summary>
216+
/// Updates a plugin to a new version and restarts the application if required by settings. Prompts user for confirmation and handles download if needed.
217+
/// </summary>
218+
/// <param name="newPlugin">The new plugin version to install.</param>
219+
/// <param name="oldPlugin">The existing plugin metadata to update.</param>
220+
/// <returns>A Task representing the asynchronous update operation.</returns>
221+
public static async Task UpdatePluginAndCheckRestartAsync(UserPlugin newPlugin, PluginMetadata oldPlugin)
222+
{
223+
if (API.ShowMsgBox(
224+
string.Format(
225+
API.GetTranslation("UpdatePromptSubtitle"),
226+
oldPlugin.Name, oldPlugin.Author, Environment.NewLine),
227+
API.GetTranslation("UpdatePromptTitle"),
228+
button: MessageBoxButton.YesNo) != MessageBoxResult.Yes) return;
229+
230+
try
231+
{
232+
var filePath = Path.Combine(Path.GetTempPath(), $"{newPlugin.Name}-{newPlugin.Version}.zip");
233+
234+
using var cts = new CancellationTokenSource();
235+
236+
if (!newPlugin.IsFromLocalInstallPath)
237+
{
238+
await DownloadFileAsync(
239+
$"{API.GetTranslation("DownloadingPlugin")} {newPlugin.Name}",
240+
newPlugin.UrlDownload, filePath, cts);
241+
}
242+
else
243+
{
244+
filePath = newPlugin.LocalInstallPath;
245+
}
246+
247+
// check if user cancelled download before installing plugin
248+
if (cts.IsCancellationRequested)
249+
{
250+
return;
251+
}
252+
253+
if (!await API.UpdatePluginAsync(oldPlugin, newPlugin, filePath))
254+
{
255+
return;
256+
}
257+
}
258+
catch (Exception e)
259+
{
260+
API.LogException(ClassName, "Failed to update plugin", e);
261+
API.ShowMsgError(API.GetTranslation("ErrorUpdatingPlugin"));
262+
return; // do not restart on failure
263+
}
264+
265+
if (Settings.AutoRestartAfterChanging)
266+
{
267+
API.RestartApp();
268+
}
269+
else
270+
{
271+
API.ShowMsg(
272+
API.GetTranslation("updatebtn"),
273+
string.Format(
274+
API.GetTranslation(
275+
"UpdateSuccessNoRestart"),
276+
newPlugin.Name));
277+
}
278+
}
279+
280+
/// <summary>
281+
/// Downloads a file from a URL to a local path, optionally showing a progress box and handling cancellation.
282+
/// </summary>
283+
/// <param name="progressBoxTitle">The title for the progress box.</param>
284+
/// <param name="downloadUrl">The URL to download from.</param>
285+
/// <param name="filePath">The local file path to save to.</param>
286+
/// <param name="cts">Cancellation token source for cancelling the download.</param>
287+
/// <param name="deleteFile">Whether to delete the file if it already exists.</param>
288+
/// <param name="showProgress">Whether to show a progress box during download.</param>
289+
/// <returns>A Task representing the asynchronous download operation.</returns>
290+
private static async Task DownloadFileAsync(string progressBoxTitle, string downloadUrl, string filePath, CancellationTokenSource cts, bool deleteFile = true, bool showProgress = true)
291+
{
292+
if (deleteFile && File.Exists(filePath))
293+
File.Delete(filePath);
294+
295+
if (showProgress)
296+
{
297+
var exceptionHappened = false;
298+
await API.ShowProgressBoxAsync(progressBoxTitle,
299+
async (reportProgress) =>
300+
{
301+
if (reportProgress == null)
302+
{
303+
// when reportProgress is null, it means there is exception with the progress box
304+
// so we record it with exceptionHappened and return so that progress box will close instantly
305+
exceptionHappened = true;
306+
return;
307+
}
308+
else
309+
{
310+
await API.HttpDownloadAsync(downloadUrl, filePath, reportProgress, cts.Token).ConfigureAwait(false);
311+
}
312+
}, cts.Cancel);
313+
314+
// if exception happened while downloading and user does not cancel downloading,
315+
// we need to redownload the plugin
316+
if (exceptionHappened && (!cts.IsCancellationRequested))
317+
await API.HttpDownloadAsync(downloadUrl, filePath, token: cts.Token).ConfigureAwait(false);
318+
}
319+
else
320+
{
321+
await API.HttpDownloadAsync(downloadUrl, filePath, token: cts.Token).ConfigureAwait(false);
322+
}
323+
}
324+
325+
/// <summary>
326+
/// Determines if the plugin install source is a known/approved source (e.g., GitHub and matches an existing plugin author).
327+
/// </summary>
328+
/// <param name="url">The URL to check.</param>
329+
/// <returns>True if the source is known, otherwise false.</returns>
330+
private static bool InstallSourceKnown(string url)
331+
{
332+
if (string.IsNullOrEmpty(url))
333+
return false;
334+
335+
var pieces = url.Split('/');
336+
337+
if (pieces.Length < 4)
338+
return false;
339+
340+
var author = pieces[3];
341+
var acceptedHost = "github.com";
342+
var acceptedSource = "https://github.com";
343+
var constructedUrlPart = string.Format("{0}/{1}/", acceptedSource, author);
344+
345+
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Host != acceptedHost)
346+
return false;
347+
348+
return API.GetAllPlugins().Any(x =>
349+
!string.IsNullOrEmpty(x.Metadata.Website) &&
350+
x.Metadata.Website.StartsWith(constructedUrlPart)
351+
);
352+
}
353+
}

0 commit comments

Comments
 (0)