diff --git a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs index 63f21c1d6..ac8abcdcc 100644 --- a/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs +++ b/Flow.Launcher.Core/ExternalPlugins/PluginsManifest.cs @@ -1,4 +1,4 @@ -using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.Logger; using System; using System.Collections.Generic; using System.Threading; @@ -21,7 +21,7 @@ namespace Flow.Launcher.Core.ExternalPlugins public static List UserPlugins { get; private set; } - public static async Task UpdateManifestAsync(CancellationToken token = default, bool usePrimaryUrlOnly = false) + public static async Task UpdateManifestAsync(CancellationToken token = default, bool usePrimaryUrlOnly = false) { try { @@ -31,8 +31,14 @@ namespace Flow.Launcher.Core.ExternalPlugins { var results = await mainPluginStore.FetchAsync(token, usePrimaryUrlOnly).ConfigureAwait(false); - UserPlugins = results; - lastFetchedAt = DateTime.Now; + // If the results are empty, we shouldn't update the manifest because the results are invalid. + if (results.Count != 0) + { + UserPlugins = results; + lastFetchedAt = DateTime.Now; + + return true; + } } } catch (Exception e) @@ -43,6 +49,8 @@ namespace Flow.Launcher.Core.ExternalPlugins { manifestUpdateLock.Release(); } + + return false; } } } diff --git a/Flow.Launcher.Core/Plugin/JsonRPCV2Models/JsonRPCPublicAPI.cs b/Flow.Launcher.Core/Plugin/JsonRPCV2Models/JsonRPCPublicAPI.cs index 2d49e4840..e0a0434a2 100644 --- a/Flow.Launcher.Core/Plugin/JsonRPCV2Models/JsonRPCPublicAPI.cs +++ b/Flow.Launcher.Core/Plugin/JsonRPCV2Models/JsonRPCPublicAPI.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Runtime.CompilerServices; @@ -121,10 +120,10 @@ namespace Flow.Launcher.Core.Plugin.JsonRPCV2Models return _api.HttpGetStreamAsync(url, token); } - public Task HttpDownloadAsync([NotNull] string url, [NotNull] string filePath, + public Task HttpDownloadAsync([NotNull] string url, [NotNull] string filePath, Action reportProgress = null, CancellationToken token = default) { - return _api.HttpDownloadAsync(url, filePath, token); + return _api.HttpDownloadAsync(url, filePath, reportProgress, token); } public void AddActionKeyword(string pluginId, string newActionKeyword) @@ -162,13 +161,11 @@ namespace Flow.Launcher.Core.Plugin.JsonRPCV2Models _api.OpenDirectory(DirectoryPath, FileNameOrFilePath); } - public void OpenUrl(string url, bool? inPrivate = null) { _api.OpenUrl(url, inPrivate); } - public void OpenAppUri(string appUri) { _api.OpenAppUri(appUri); diff --git a/Flow.Launcher.Core/Plugin/PythonPlugin.cs b/Flow.Launcher.Core/Plugin/PythonPlugin.cs index 536e69b3d..e40b0330e 100644 --- a/Flow.Launcher.Core/Plugin/PythonPlugin.cs +++ b/Flow.Launcher.Core/Plugin/PythonPlugin.cs @@ -1,4 +1,5 @@ -using System.Diagnostics; +using System; +using System.Diagnostics; using System.IO; using System.Text.Json; using System.Threading; @@ -25,14 +26,13 @@ namespace Flow.Launcher.Core.Plugin var path = Path.Combine(Constant.ProgramDirectory, JsonRPC); _startInfo.EnvironmentVariables["PYTHONPATH"] = path; + // Prevent Python from writing .py[co] files. + // Because .pyc contains location infos which will prevent python portable. + _startInfo.EnvironmentVariables["PYTHONDONTWRITEBYTECODE"] = "1"; _startInfo.EnvironmentVariables["FLOW_VERSION"] = Constant.Version; _startInfo.EnvironmentVariables["FLOW_PROGRAM_DIRECTORY"] = Constant.ProgramDirectory; _startInfo.EnvironmentVariables["FLOW_APPLICATION_DIRECTORY"] = Constant.ApplicationDirectory; - - - //Add -B flag to tell python don't write .py[co] files. Because .pyc contains location infos which will prevent python portable - _startInfo.ArgumentList.Add("-B"); } protected override Task RequestAsync(JsonRPCRequestModel request, CancellationToken token = default) @@ -50,10 +50,53 @@ namespace Flow.Launcher.Core.Plugin // TODO: Async Action return Execute(_startInfo); } + public override async Task InitAsync(PluginInitContext context) { - _startInfo.ArgumentList.Add(context.CurrentPluginMetadata.ExecuteFilePath); - _startInfo.ArgumentList.Add(""); + // Run .py files via `-c ` + if (context.CurrentPluginMetadata.ExecuteFilePath.EndsWith(".py", StringComparison.OrdinalIgnoreCase)) + { + var rootDirectory = context.CurrentPluginMetadata.PluginDirectory; + var libDirectory = Path.Combine(rootDirectory, "lib"); + var libPyWin32Directory = Path.Combine(libDirectory, "win32"); + var libPyWin32LibDirectory = Path.Combine(libPyWin32Directory, "lib"); + var pluginDirectory = Path.Combine(rootDirectory, "plugin"); + + // This makes it easier for plugin authors to import their own modules. + // They won't have to add `.`, `./lib`, or `./plugin` to their sys.path manually. + // Instead of running the .py file directly, we pass the code we want to run as a CLI argument. + // This code sets sys.path for the plugin author and then runs the .py file via runpy. + _startInfo.ArgumentList.Add("-c"); + _startInfo.ArgumentList.Add( + $""" + import sys + sys.path.append(r'{rootDirectory}') + sys.path.append(r'{libDirectory}') + sys.path.append(r'{libPyWin32LibDirectory}') + sys.path.append(r'{libPyWin32Directory}') + sys.path.append(r'{pluginDirectory}') + + import runpy + runpy.run_path(r'{context.CurrentPluginMetadata.ExecuteFilePath}', None, '__main__') + """ + ); + // Plugins always expect the JSON data to be in the third argument + // (we're always setting it as _startInfo.ArgumentList[2] = ...). + _startInfo.ArgumentList.Add(""); + } + // Run .pyz files as is + else + { + // No need for -B flag because we're using PYTHONDONTWRITEBYTECODE env variable now, + // but the plugins still expect data to be sent as the third argument, so we're keeping + // the flag here, even though it's not necessary anymore. + _startInfo.ArgumentList.Add("-B"); + _startInfo.ArgumentList.Add(context.CurrentPluginMetadata.ExecuteFilePath); + // Plugins always expect the JSON data to be in the third argument + // (we're always setting it as _startInfo.ArgumentList[2] = ...). + _startInfo.ArgumentList.Add(""); + } + await base.InitAsync(context); _startInfo.WorkingDirectory = context.CurrentPluginMetadata.PluginDirectory; } diff --git a/Flow.Launcher.Core/Plugin/PythonPluginV2.cs b/Flow.Launcher.Core/Plugin/PythonPluginV2.cs index 5c36e0eea..8a9e1ff44 100644 --- a/Flow.Launcher.Core/Plugin/PythonPluginV2.cs +++ b/Flow.Launcher.Core/Plugin/PythonPluginV2.cs @@ -26,14 +26,45 @@ namespace Flow.Launcher.Core.Plugin var path = Path.Combine(Constant.ProgramDirectory, JsonRpc); StartInfo.EnvironmentVariables["PYTHONPATH"] = path; - - //Add -B flag to tell python don't write .py[co] files. Because .pyc contains location infos which will prevent python portable - StartInfo.ArgumentList.Add("-B"); + StartInfo.EnvironmentVariables["PYTHONDONTWRITEBYTECODE"] = "1"; } public override async Task InitAsync(PluginInitContext context) { - StartInfo.ArgumentList.Add(context.CurrentPluginMetadata.ExecuteFilePath); + // Run .py files via `-c ` + if (context.CurrentPluginMetadata.ExecuteFilePath.EndsWith(".py", StringComparison.OrdinalIgnoreCase)) + { + var rootDirectory = context.CurrentPluginMetadata.PluginDirectory; + var libDirectory = Path.Combine(rootDirectory, "lib"); + var libPyWin32Directory = Path.Combine(libDirectory, "win32"); + var libPyWin32LibDirectory = Path.Combine(libPyWin32Directory, "lib"); + var pluginDirectory = Path.Combine(rootDirectory, "plugin"); + var filePath = context.CurrentPluginMetadata.ExecuteFilePath; + + // This makes it easier for plugin authors to import their own modules. + // They won't have to add `.`, `./lib`, or `./plugin` to their sys.path manually. + // Instead of running the .py file directly, we pass the code we want to run as a CLI argument. + // This code sets sys.path for the plugin author and then runs the .py file via runpy. + StartInfo.ArgumentList.Add("-c"); + StartInfo.ArgumentList.Add( + $""" + import sys + sys.path.append(r'{rootDirectory}') + sys.path.append(r'{libDirectory}') + sys.path.append(r'{libPyWin32LibDirectory}') + sys.path.append(r'{libPyWin32Directory}') + sys.path.append(r'{pluginDirectory}') + + import runpy + runpy.run_path(r'{filePath}', None, '__main__') + """ + ); + } + // Run .pyz files as is + else + { + StartInfo.ArgumentList.Add(context.CurrentPluginMetadata.ExecuteFilePath); + } await base.InitAsync(context); } diff --git a/Flow.Launcher.Infrastructure/FileExplorerHelper.cs b/Flow.Launcher.Infrastructure/FileExplorerHelper.cs index d908b0fde..b97c096c3 100644 --- a/Flow.Launcher.Infrastructure/FileExplorerHelper.cs +++ b/Flow.Launcher.Infrastructure/FileExplorerHelper.cs @@ -15,7 +15,20 @@ namespace Flow.Launcher.Infrastructure { var explorerWindow = GetActiveExplorer(); string locationUrl = explorerWindow?.LocationURL; - return !string.IsNullOrEmpty(locationUrl) ? new Uri(locationUrl).LocalPath + "\\" : null; + return !string.IsNullOrEmpty(locationUrl) ? GetDirectoryPath(new Uri(locationUrl).LocalPath) : null; + } + + /// + /// Get directory path from a file path + /// + private static string GetDirectoryPath(string path) + { + if (!path.EndsWith("\\")) + { + return path + "\\"; + } + + return path; } /// diff --git a/Flow.Launcher.Infrastructure/Http/Http.cs b/Flow.Launcher.Infrastructure/Http/Http.cs index 14b8eef4e..0b3f2be65 100644 --- a/Flow.Launcher.Infrastructure/Http/Http.cs +++ b/Flow.Launcher.Infrastructure/Http/Http.cs @@ -83,15 +83,50 @@ namespace Flow.Launcher.Infrastructure.Http } } - public static async Task DownloadAsync([NotNull] string url, [NotNull] string filePath, CancellationToken token = default) + public static async Task DownloadAsync([NotNull] string url, [NotNull] string filePath, Action reportProgress = null, CancellationToken token = default) { try { using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token); + if (response.StatusCode == HttpStatusCode.OK) { - await using var fileStream = new FileStream(filePath, FileMode.CreateNew); - await response.Content.CopyToAsync(fileStream, token); + var totalBytes = response.Content.Headers.ContentLength ?? -1L; + var canReportProgress = totalBytes != -1; + + if (canReportProgress && reportProgress != null) + { + await using var contentStream = await response.Content.ReadAsStreamAsync(token); + await using var fileStream = new FileStream(filePath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 8192, true); + + var buffer = new byte[8192]; + long totalRead = 0; + int read; + double progressValue = 0; + + reportProgress(0); + + while ((read = await contentStream.ReadAsync(buffer, token)) > 0) + { + await fileStream.WriteAsync(buffer.AsMemory(0, read), token); + totalRead += read; + + progressValue = totalRead * 100.0 / totalBytes; + + if (token.IsCancellationRequested) + return; + else + reportProgress(progressValue); + } + + if (progressValue < 100) + reportProgress(100); + } + else + { + await using var fileStream = new FileStream(filePath, FileMode.CreateNew); + await response.Content.CopyToAsync(fileStream, token); + } } else { diff --git a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs index a0186b7a2..8376fd07b 100644 --- a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs +++ b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs @@ -1,4 +1,4 @@ -using Flow.Launcher.Plugin.SharedModels; +using Flow.Launcher.Plugin.SharedModels; using JetBrains.Annotations; using System; using System.Collections.Generic; @@ -181,9 +181,13 @@ namespace Flow.Launcher.Plugin /// /// URL to download file /// path to save downloaded file + /// + /// Action to report progress. The input of the action is the progress value which is a double value between 0 and 100. + /// It will be called if url support range request and the reportProgress is not null. + /// /// place to store file /// Task showing the progress - Task HttpDownloadAsync([NotNull] string url, [NotNull] string filePath, CancellationToken token = default); + Task HttpDownloadAsync([NotNull] string url, [NotNull] string filePath, Action reportProgress = null, CancellationToken token = default); /// /// Add ActionKeyword for specific plugin @@ -316,5 +320,19 @@ namespace Flow.Launcher.Plugin /// Specifies the default result of the message box. /// Specifies which message box button is clicked by the user. public MessageBoxResult ShowMsgBox(string messageBoxText, string caption = "", MessageBoxButton button = MessageBoxButton.OK, MessageBoxImage icon = MessageBoxImage.None, MessageBoxResult defaultResult = MessageBoxResult.OK); + + /// + /// Displays a standardised Flow message box. + /// If there is issue when showing the message box, it will return null. + /// + /// The caption of the message box. + /// + /// Time-consuming task function, whose input is the action to report progress. + /// The input of the action is the progress value which is a double value between 0 and 100. + /// If there are any exceptions, this action will be null. + /// + /// When user closes the progress box manually by button or esc key, this action will be called. + /// A progress box interface. + public Task ShowProgressBoxAsync(string caption, Func, Task> reportProgressAsync, Action forceClosed = null); } } diff --git a/Flow.Launcher.Plugin/Result.cs b/Flow.Launcher.Plugin/Result.cs index c6ca81cf3..bb005752e 100644 --- a/Flow.Launcher.Plugin/Result.cs +++ b/Flow.Launcher.Plugin/Result.cs @@ -185,6 +185,16 @@ namespace Flow.Launcher.Plugin TitleHighlightData = TitleHighlightData, OriginQuery = OriginQuery, PluginDirectory = PluginDirectory, + ContextData = ContextData, + PluginID = PluginID, + TitleToolTip = TitleToolTip, + SubTitleToolTip = SubTitleToolTip, + PreviewPanel = PreviewPanel, + ProgressBar = ProgressBar, + ProgressBarColor = ProgressBarColor, + Preview = Preview, + AddSelectedCount = AddSelectedCount, + RecordKey = RecordKey }; } @@ -252,6 +262,13 @@ namespace Flow.Launcher.Plugin /// public const int MaxScore = int.MaxValue; + /// + /// The key to identify the record. This is used when FL checks whether the result is the topmost record. Or FL calculates the hashcode of the result for user selected records. + /// This can be useful when your plugin will change the Title or SubTitle of the result dynamically. + /// If the plugin does not specific this, FL just uses Title and SubTitle to identify this result. + /// + public string RecordKey { get; set; } = string.Empty; + /// /// Info of the preview section of a /// diff --git a/Flow.Launcher/Helper/WindowsInteropHelper.cs b/Flow.Launcher/Helper/WindowsInteropHelper.cs index caf3f0a7f..3e57948a5 100644 --- a/Flow.Launcher/Helper/WindowsInteropHelper.cs +++ b/Flow.Launcher/Helper/WindowsInteropHelper.cs @@ -148,4 +148,64 @@ public class WindowsInteropHelper return new Point((int)(matrix.M11 * unitX), (int)(matrix.M22 * unitY)); } + + #region Alt Tab + + private static int SetWindowLong(HWND hWnd, WINDOW_LONG_PTR_INDEX nIndex, int dwNewLong) + { + PInvoke.SetLastError(WIN32_ERROR.NO_ERROR); // Clear any existing error + + var result = PInvoke.SetWindowLong(hWnd, nIndex, dwNewLong); + if (result == 0 && Marshal.GetLastPInvokeError() != 0) + { + throw new Win32Exception(Marshal.GetLastPInvokeError()); + } + + return result; + } + + /// + /// Hide windows in the Alt+Tab window list + /// + /// To hide a window + public static void HideFromAltTab(Window window) + { + var exStyle = GetCurrentWindowStyle(window); + + // Add TOOLWINDOW style, remove APPWINDOW style + var newExStyle = ((uint)exStyle | (uint)WINDOW_EX_STYLE.WS_EX_TOOLWINDOW) & ~(uint)WINDOW_EX_STYLE.WS_EX_APPWINDOW; + + SetWindowLong(new(new WindowInteropHelper(window).Handle), WINDOW_LONG_PTR_INDEX.GWL_EXSTYLE, (int)newExStyle); + } + + /// + /// Restore window display in the Alt+Tab window list. + /// + /// To restore the displayed window + public static void ShowInAltTab(Window window) + { + var exStyle = GetCurrentWindowStyle(window); + + // Remove the TOOLWINDOW style and add the APPWINDOW style. + var newExStyle = ((uint)exStyle & ~(uint)WINDOW_EX_STYLE.WS_EX_TOOLWINDOW) | (uint)WINDOW_EX_STYLE.WS_EX_APPWINDOW; + + SetWindowLong(new(new WindowInteropHelper(window).Handle), WINDOW_LONG_PTR_INDEX.GWL_EXSTYLE, (int)newExStyle); + } + + /// + /// To obtain the current overridden style of a window. + /// + /// To obtain the style dialog window + /// current extension style value + private static int GetCurrentWindowStyle(Window window) + { + var style = PInvoke.GetWindowLong(new(new WindowInteropHelper(window).Handle), WINDOW_LONG_PTR_INDEX.GWL_EXSTYLE); + if (style == 0 && Marshal.GetLastPInvokeError() != 0) + { + throw new Win32Exception(Marshal.GetLastPInvokeError()); + } + return style; + } + + #endregion } diff --git a/Flow.Launcher/MainWindow.xaml b/Flow.Launcher/MainWindow.xaml index f5fd729d4..da9e1a5b5 100644 --- a/Flow.Launcher/MainWindow.xaml +++ b/Flow.Launcher/MainWindow.xaml @@ -20,6 +20,7 @@ Closing="OnClosing" Deactivated="OnDeactivated" Icon="Images/app.png" + SourceInitialized="OnSourceInitialized" Initialized="OnInitialized" Left="{Binding Settings.WindowLeft, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Loaded="OnLoaded" diff --git a/Flow.Launcher/MainWindow.xaml.cs b/Flow.Launcher/MainWindow.xaml.cs index 8ca153afc..41dc68fd9 100644 --- a/Flow.Launcher/MainWindow.xaml.cs +++ b/Flow.Launcher/MainWindow.xaml.cs @@ -171,6 +171,11 @@ namespace Flow.Launcher Environment.Exit(0); } + private void OnSourceInitialized(object sender, EventArgs e) + { + WindowsInteropHelper.HideFromAltTab(this); + } + private void OnInitialized(object sender, EventArgs e) { } diff --git a/Flow.Launcher/NativeMethods.txt b/Flow.Launcher/NativeMethods.txt index 2b147c05f..88eeeca6e 100644 --- a/Flow.Launcher/NativeMethods.txt +++ b/Flow.Launcher/NativeMethods.txt @@ -14,4 +14,7 @@ FindWindowEx WINDOW_STYLE WM_ENTERSIZEMOVE -WM_EXITSIZEMOVE \ No newline at end of file +WM_EXITSIZEMOVE + +SetLastError +WINDOW_EX_STYLE \ No newline at end of file diff --git a/Flow.Launcher/ProgressBoxEx.xaml b/Flow.Launcher/ProgressBoxEx.xaml new file mode 100644 index 000000000..3102cfb72 --- /dev/null +++ b/Flow.Launcher/ProgressBoxEx.xaml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +