diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index d0fee9559..8e29be550 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -1,7 +1,6 @@ crowdin DWM workflows -Wpf wpf actionkeyword stackoverflow @@ -20,9 +19,7 @@ Prioritise Segoe Google Customise -UWP uwp -Uwp Bokmal Bokm uninstallation @@ -61,7 +58,6 @@ popup ptr pluginindicator TobiasSekan -Img img resx bak @@ -78,7 +74,6 @@ WCA_ACCENT_POLICY HGlobal dopusrt firefox -Firefox msedge svgc ime @@ -87,7 +82,6 @@ txb btn otf searchplugin -Noresult wpftk mkv flac @@ -108,4 +102,6 @@ Preinstalled errormetadatafile noresult pluginsmanager -alreadyexists \ No newline at end of file +alreadyexists +JsonRPC +JsonRPCV2 diff --git a/.github/actions/spelling/patterns.txt b/.github/actions/spelling/patterns.txt index 903714aef..f29f57ad5 100644 --- a/.github/actions/spelling/patterns.txt +++ b/.github/actions/spelling/patterns.txt @@ -118,3 +118,6 @@ # UWP [Uu][Ww][Pp] + +# version suffix v# +(?:(?<=[A-Z]{2})V|(?<=[a-z]{2}|[A-Z]{2})v)\d+(?:\b|(?=[a-zA-Z_])) diff --git a/.github/workflows/spelling.yml b/.github/workflows/spelling.yml index 97d3cccb3..7aaa9296a 100644 --- a/.github/workflows/spelling.yml +++ b/.github/workflows/spelling.yml @@ -73,7 +73,7 @@ jobs: steps: - name: check-spelling id: spelling - uses: check-spelling/check-spelling@v0.0.22 + uses: check-spelling/check-spelling@prerelease with: suppress_push_for_open_pull_request: 1 checkout: true @@ -91,10 +91,9 @@ jobs: extra_dictionaries: cspell:software-terms/dict/softwareTerms.txt cspell:win32/src/win32.txt - cspell:php/src/php.txt cspell:filetypes/filetypes.txt cspell:csharp/csharp.txt - cspell:dotnet/src/dotnet.txt + cspell:dotnet/dict/dotnet.txt cspell:python/src/common/extra.txt cspell:python/src/python/python-lib.txt cspell:aws/aws.txt @@ -130,7 +129,7 @@ jobs: if: (success() || failure()) && needs.spelling.outputs.followup && contains(github.event_name, 'pull_request') steps: - name: comment - uses: check-spelling/check-spelling@v0.0.22 + uses: check-spelling/check-spelling@prerelease with: checkout: true spell_check_this: check-spelling/spell-check-this@main diff --git a/Flow.Launcher.Core/Flow.Launcher.Core.csproj b/Flow.Launcher.Core/Flow.Launcher.Core.csproj index 42f233dad..312dfdd9e 100644 --- a/Flow.Launcher.Core/Flow.Launcher.Core.csproj +++ b/Flow.Launcher.Core/Flow.Launcher.Core.csproj @@ -54,7 +54,7 @@ - + diff --git a/Flow.Launcher.Core/Plugin/JsonPRCModel.cs b/Flow.Launcher.Core/Plugin/JsonPRCModel.cs index 48606eea4..5b2a9f6cb 100644 --- a/Flow.Launcher.Core/Plugin/JsonPRCModel.cs +++ b/Flow.Launcher.Core/Plugin/JsonPRCModel.cs @@ -25,7 +25,7 @@ namespace Flow.Launcher.Core.Plugin public record JsonRPCResponseModel(int Id, JsonRPCErrorModel Error = default) : JsonRPCBase(Id, Error); public record JsonRPCQueryResponseModel(int Id, [property: JsonPropertyName("result")] List Result, - IReadOnlyDictionary SettingsChanges = null, + IReadOnlyDictionary SettingsChange = null, string DebugMessage = "", JsonRPCErrorModel Error = default) : JsonRPCResponseModel(Id, Error); diff --git a/Flow.Launcher.Core/Plugin/JsonRPCPluginBase.cs b/Flow.Launcher.Core/Plugin/JsonRPCPluginBase.cs index 330120c12..f6e5e5879 100644 --- a/Flow.Launcher.Core/Plugin/JsonRPCPluginBase.cs +++ b/Flow.Launcher.Core/Plugin/JsonRPCPluginBase.cs @@ -94,7 +94,7 @@ namespace Flow.Launcher.Core.Plugin results.AddRange(queryResponseModel.Result); - Settings?.UpdateSettings(queryResponseModel.SettingsChanges); + Settings?.UpdateSettings(queryResponseModel.SettingsChange); return results; } @@ -126,14 +126,15 @@ namespace Flow.Launcher.Core.Plugin private async Task InitSettingAsync() { - if (!File.Exists(SettingConfigurationPath)) - return; + JsonRpcConfigurationModel configuration = null; + if (File.Exists(SettingConfigurationPath)) + { + var deserializer = new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance).Build(); + configuration = + deserializer.Deserialize( + await File.ReadAllTextAsync(SettingConfigurationPath)); + } - var deserializer = new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance) - .Build(); - var configuration = - deserializer.Deserialize( - await File.ReadAllTextAsync(SettingConfigurationPath)); Settings ??= new JsonRPCPluginSettings { diff --git a/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs b/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs index b87623c56..3ffac1343 100644 --- a/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs +++ b/Flow.Launcher.Core/Plugin/JsonRPCPluginSettings.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System.Collections.Concurrent; +using System.Collections.Generic; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; @@ -10,16 +11,16 @@ namespace Flow.Launcher.Core.Plugin { public class JsonRPCPluginSettings { - public required JsonRpcConfigurationModel Configuration { get; init; } + public required JsonRpcConfigurationModel? Configuration { get; init; } public required string SettingPath { get; init; } public Dictionary SettingControls { get; } = new(); public IReadOnlyDictionary Inner => Settings; - protected Dictionary Settings { get; set; } + protected ConcurrentDictionary Settings { get; set; } public required IPublicAPI API { get; init; } - private JsonStorage> _storage; + private JsonStorage> _storage; // maybe move to resource? private static readonly Thickness settingControlMargin = new(0, 9, 18, 9); @@ -33,9 +34,14 @@ namespace Flow.Launcher.Core.Plugin public async Task InitializeAsync() { - _storage = new JsonStorage>(SettingPath); + _storage = new JsonStorage>(SettingPath); Settings = await _storage.LoadAsync(); + if (Settings != null) + { + return; + } + foreach (var (type, attributes) in Configuration.Body) { if (attributes.Name == null) @@ -58,10 +64,7 @@ namespace Flow.Launcher.Core.Plugin foreach (var (key, value) in settings) { - if (Settings.ContainsKey(key)) - { - Settings[key] = value; - } + Settings[key] = value; if (SettingControls.TryGetValue(key, out var control)) { @@ -82,6 +85,7 @@ namespace Flow.Launcher.Core.Plugin } } } + Save(); } public async Task SaveAsync() diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index eec906807..7f4d4ea35 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -11,6 +11,8 @@ using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; using ISavable = Flow.Launcher.Plugin.ISavable; +using Flow.Launcher.Plugin.SharedCommands; +using System.Text.Json; namespace Flow.Launcher.Core.Plugin { @@ -27,9 +29,9 @@ namespace Flow.Launcher.Core.Plugin public static IPublicAPI API { private set; get; } - // todo happlebao, this should not be public, the indicator function should be embeded - public static PluginsSettings Settings; + private static PluginsSettings Settings; private static List _metadatas; + private static List _modifiedPlugins = new List(); /// /// Directories that will hold Flow Launcher plugin directory @@ -343,5 +345,156 @@ namespace Flow.Launcher.Core.Plugin RemoveActionKeyword(id, oldActionKeyword); } } + + private static string GetContainingFolderPathAfterUnzip(string unzippedParentFolderPath) + { + var unzippedFolderCount = Directory.GetDirectories(unzippedParentFolderPath).Length; + var unzippedFilesCount = Directory.GetFiles(unzippedParentFolderPath).Length; + + // adjust path depending on how the plugin is zipped up + // the recommended should be to zip up the folder not the contents + if (unzippedFolderCount == 1 && unzippedFilesCount == 0) + // folder is zipped up, unzipped plugin directory structure: tempPath/unzippedParentPluginFolder/pluginFolderName/ + return Directory.GetDirectories(unzippedParentFolderPath)[0]; + + if (unzippedFilesCount > 1) + // content is zipped up, unzipped plugin directory structure: tempPath/unzippedParentPluginFolder/ + return unzippedParentFolderPath; + + return string.Empty; + } + + private static bool SameOrLesserPluginVersionExists(string metadataPath) + { + var newMetadata = JsonSerializer.Deserialize(File.ReadAllText(metadataPath)); + return AllPlugins.Any(x => x.Metadata.ID == newMetadata.ID + && newMetadata.Version.CompareTo(x.Metadata.Version) <= 0); + } + + #region Public functions + + public static bool PluginModified(string uuid) + { + return _modifiedPlugins.Contains(uuid); + } + + + /// + /// Update a plugin to new version, from a zip file. Will Delete zip after updating. + /// + public static void UpdatePlugin(PluginMetadata existingVersion, UserPlugin newVersion, string zipFilePath) + { + InstallPlugin(newVersion, zipFilePath, checkModified:false); + UninstallPlugin(existingVersion, removeSettings:false, checkModified:false); + _modifiedPlugins.Add(existingVersion.ID); + } + + /// + /// Install a plugin. Will Delete zip after updating. + /// + public static void InstallPlugin(UserPlugin plugin, string zipFilePath) + { + InstallPlugin(plugin, zipFilePath, true); + } + + /// + /// Uninstall a plugin. + /// + public static void UninstallPlugin(PluginMetadata plugin, bool removeSettings = true) + { + UninstallPlugin(plugin, removeSettings, true); + } + + #endregion + + #region Internal functions + + internal static void InstallPlugin(UserPlugin plugin, string zipFilePath, bool checkModified) + { + if (checkModified && PluginModified(plugin.ID)) + { + // Distinguish exception from installing same or less version + throw new ArgumentException($"Plugin {plugin.Name} {plugin.ID} has been modified.", nameof(plugin)); + } + + // Unzip plugin files to temp folder + var tempFolderPluginPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + System.IO.Compression.ZipFile.ExtractToDirectory(zipFilePath, tempFolderPluginPath); + File.Delete(zipFilePath); + + var pluginFolderPath = GetContainingFolderPathAfterUnzip(tempFolderPluginPath); + + var metadataJsonFilePath = string.Empty; + if (File.Exists(Path.Combine(pluginFolderPath, Constant.PluginMetadataFileName))) + metadataJsonFilePath = Path.Combine(pluginFolderPath, Constant.PluginMetadataFileName); + + if (string.IsNullOrEmpty(metadataJsonFilePath) || string.IsNullOrEmpty(pluginFolderPath)) + { + throw new FileNotFoundException($"Unable to find plugin.json from the extracted zip file, or this path {pluginFolderPath} does not exist"); + } + + if (SameOrLesserPluginVersionExists(metadataJsonFilePath)) + { + throw new InvalidOperationException($"A plugin with the same ID and version already exists, or the version is greater than this downloaded plugin {plugin.Name}"); + } + + var folderName = string.IsNullOrEmpty(plugin.Version) ? $"{plugin.Name}-{Guid.NewGuid()}" : $"{plugin.Name}-{plugin.Version}"; + + var defaultPluginIDs = new List + { + "0ECADE17459B49F587BF81DC3A125110", // BrowserBookmark + "CEA0FDFC6D3B4085823D60DC76F28855", // Calculator + "572be03c74c642baae319fc283e561a8", // Explorer + "6A122269676E40EB86EB543B945932B9", // PluginIndicator + "9f8f9b14-2518-4907-b211-35ab6290dee7", // PluginsManager + "b64d0a79-329a-48b0-b53f-d658318a1bf6", // ProcessKiller + "791FC278BA414111B8D1886DFE447410", // Program + "D409510CD0D2481F853690A07E6DC426", // Shell + "CEA08895D2544B019B2E9C5009600DF4", // Sys + "0308FD86DE0A4DEE8D62B9B535370992", // URL + "565B73353DBF4806919830B9202EE3BF", // WebSearch + "5043CETYU6A748679OPA02D27D99677A" // WindowsSettings + }; + + // Treat default plugin differently, it needs to be removable along with each flow release + var installDirectory = !defaultPluginIDs.Any(x => x == plugin.ID) + ? DataLocation.PluginsDirectory + : Constant.PreinstalledDirectory; + + var newPluginPath = Path.Combine(installDirectory, folderName); + + FilesFolders.CopyAll(pluginFolderPath, newPluginPath); + + Directory.Delete(tempFolderPluginPath, true); + + if (checkModified) + { + _modifiedPlugins.Add(plugin.ID); + } + } + + internal static void UninstallPlugin(PluginMetadata plugin, bool removeSettings, bool checkModified) + { + if (checkModified && PluginModified(plugin.ID)) + { + throw new ArgumentException($"Plugin {plugin.Name} has been modified"); + } + + if (removeSettings) + { + Settings.Plugins.Remove(plugin.ID); + AllPlugins.RemoveAll(p => p.Metadata.ID == plugin.ID); + } + + // Marked for deletion. Will be deleted on next start up + using var _ = File.CreateText(Path.Combine(plugin.PluginDirectory, "NeedDelete.txt")); + + if (checkModified) + { + _modifiedPlugins.Add(plugin.ID); + } + } + + #endregion } } diff --git a/Flow.Launcher.Core/Plugin/ProcessStreamPluginV2.cs b/Flow.Launcher.Core/Plugin/ProcessStreamPluginV2.cs index 24d06d975..a476f06e9 100644 --- a/Flow.Launcher.Core/Plugin/ProcessStreamPluginV2.cs +++ b/Flow.Launcher.Core/Plugin/ProcessStreamPluginV2.cs @@ -17,7 +17,7 @@ namespace Flow.Launcher.Core.Plugin protected abstract ProcessStartInfo StartInfo { get; set; } - public Process ClientProcess { get; set; } + protected Process ClientProcess { get; set; } public override async Task InitAsync(PluginInitContext context) { @@ -33,6 +33,8 @@ namespace Flow.Launcher.Core.Plugin SetupPipe(ClientProcess); + ErrorStream = ClientProcess.StandardError; + await base.InitAsync(context); } diff --git a/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj b/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj index 2f5259039..b24f069c1 100644 --- a/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj +++ b/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj @@ -53,6 +53,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index 203c5646a..add6d4e92 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using System.Windows.Media; using System.Windows.Media.Imaging; @@ -15,6 +16,7 @@ namespace Flow.Launcher.Infrastructure.Image public static class ImageLoader { private static readonly ImageCache ImageCache = new(); + private static SemaphoreSlim storageLock { get; } = new SemaphoreSlim(1, 1); private static BinaryStorage> _storage; private static readonly ConcurrentDictionary GuidToKey = new(); private static IImageHashGenerator _hashGenerator; @@ -25,24 +27,18 @@ namespace Flow.Launcher.Infrastructure.Image public const int FullIconSize = 256; - private static readonly string[] ImageExtensions = - { - ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".ico" - }; + private static readonly string[] ImageExtensions = { ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".ico" }; - public static void Initialize() + public static async Task InitializeAsync() { _storage = new BinaryStorage>("Image"); _hashGenerator = new ImageHashGenerator(); - var usage = LoadStorageToConcurrentDictionary(); + var usage = await LoadStorageToConcurrentDictionaryAsync(); ImageCache.Initialize(usage.ToDictionary(x => x.Key, x => x.Value)); - foreach (var icon in new[] - { - Constant.DefaultIcon, Constant.MissingImgIcon - }) + foreach (var icon in new[] { Constant.DefaultIcon, Constant.MissingImgIcon }) { ImageSource img = new BitmapImage(new Uri(icon)); img.Freeze(); @@ -58,29 +54,41 @@ namespace Flow.Launcher.Infrastructure.Image await LoadAsync(path, isFullImage); } }); - Log.Info($"|ImageLoader.Initialize|Number of preload images is <{ImageCache.CacheSize()}>, Images Number: {ImageCache.CacheSize()}, Unique Items {ImageCache.UniqueImagesInCache()}"); + Log.Info( + $"|ImageLoader.Initialize|Number of preload images is <{ImageCache.CacheSize()}>, Images Number: {ImageCache.CacheSize()}, Unique Items {ImageCache.UniqueImagesInCache()}"); }); } - public static void Save() + public static async Task Save() { - lock (_storage) + await storageLock.WaitAsync(); + + try { - _storage.Save(ImageCache.Data + _storage.SaveAsync(ImageCache.Data .ToDictionary( x => x.Key, x => x.Value.usage)); } + finally + { + storageLock.Release(); + } } - private static ConcurrentDictionary<(string, bool), int> LoadStorageToConcurrentDictionary() + private static async Task> LoadStorageToConcurrentDictionaryAsync() { - lock (_storage) + await storageLock.WaitAsync(); + try { - var loaded = _storage.TryLoad(new Dictionary<(string, bool), int>()); + var loaded = await _storage.TryLoadAsync(new Dictionary<(string, bool), int>()); return new ConcurrentDictionary<(string, bool), int>(loaded); } + finally + { + storageLock.Release(); + } } private class ImageResult @@ -129,6 +137,7 @@ namespace Flow.Launcher.Infrastructure.Image ImageCache[path, loadFullImage] = image; return new ImageResult(image, ImageType.ImageFile); } + if (path.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) { var imageSource = new BitmapImage(new Uri(path)); @@ -158,6 +167,7 @@ namespace Flow.Launcher.Infrastructure.Image return imageResult; } + private static async Task LoadRemoteImageAsync(bool loadFullImage, Uri uriResult) { // Download image from url @@ -173,6 +183,7 @@ namespace Flow.Launcher.Infrastructure.Image image.DecodePixelHeight = SmallIconSize; image.DecodePixelWidth = SmallIconSize; } + image.StreamSource = buffer; image.EndInit(); image.StreamSource = null; @@ -188,8 +199,8 @@ namespace Flow.Launcher.Infrastructure.Image if (Directory.Exists(path)) { /* Directories can also have thumbnails instead of shell icons. - * Generating thumbnails for a bunch of folder results while scrolling - * could have a big impact on performance and Flow.Launcher responsibility. + * Generating thumbnails for a bunch of folder results while scrolling + * could have a big impact on performance and Flow.Launcher responsibility. * - Solution: just load the icon */ type = ImageType.Folder; @@ -208,9 +219,9 @@ namespace Flow.Launcher.Infrastructure.Image } else { - /* Although the documentation for GetImage on MSDN indicates that + /* Although the documentation for GetImage on MSDN indicates that * if a thumbnail is available it will return one, this has proved to not - * be the case in many situations while testing. + * be the case in many situations while testing. * - Solution: explicitly pass the ThumbnailOnly flag */ image = GetThumbnail(path, ThumbnailOptions.ThumbnailOnly); @@ -236,7 +247,8 @@ namespace Flow.Launcher.Infrastructure.Image return new ImageResult(image, type); } - private static BitmapSource GetThumbnail(string path, ThumbnailOptions option = ThumbnailOptions.ThumbnailOnly, int size = SmallIconSize) + private static BitmapSource GetThumbnail(string path, ThumbnailOptions option = ThumbnailOptions.ThumbnailOnly, + int size = SmallIconSize) { return WindowsThumbnailProvider.GetThumbnail( path, @@ -261,17 +273,19 @@ namespace Flow.Launcher.Infrastructure.Image var img = imageResult.ImageSource; if (imageResult.ImageType != ImageType.Error && imageResult.ImageType != ImageType.Cache) - { // we need to get image hash + { + // we need to get image hash string hash = EnableImageHash ? _hashGenerator.GetHashFromImage(img) : null; if (hash != null) { - if (GuidToKey.TryGetValue(hash, out string key)) - { // image already exists + { + // image already exists img = ImageCache[key, loadFullImage] ?? img; } else - { // new guid + { + // new guid GuidToKey[hash] = path; } @@ -289,7 +303,7 @@ namespace Flow.Launcher.Infrastructure.Image BitmapImage image = new BitmapImage(); image.BeginInit(); image.CacheOption = BitmapCacheOption.OnLoad; - image.UriSource = new Uri(path); + image.UriSource = new Uri(path); image.CreateOptions = BitmapCreateOptions.IgnoreColorProfile; image.EndInit(); @@ -314,8 +328,10 @@ namespace Flow.Launcher.Infrastructure.Image resizedHeight.EndInit(); return resizedHeight; } + return resizedWidth; } + return image; } } diff --git a/Flow.Launcher.Infrastructure/Storage/BinaryStorage.cs b/Flow.Launcher.Infrastructure/Storage/BinaryStorage.cs index ea2d42773..a679643fd 100644 --- a/Flow.Launcher.Infrastructure/Storage/BinaryStorage.cs +++ b/Flow.Launcher.Infrastructure/Storage/BinaryStorage.cs @@ -4,67 +4,65 @@ using System.Reflection; using System.Runtime.Serialization; using System.Runtime.Serialization.Formatters; using System.Runtime.Serialization.Formatters.Binary; +using System.Threading.Tasks; using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; +using MemoryPack; namespace Flow.Launcher.Infrastructure.Storage { -#pragma warning disable SYSLIB0011 // BinaryFormatter is obsolete. /// /// Stroage object using binary data /// Normally, it has better performance, but not readable /// + /// + /// It utilize MemoryPack, which means the object must be MemoryPackSerializable + /// https://github.com/Cysharp/MemoryPack + /// public class BinaryStorage { + const string DirectoryName = "Cache"; + + const string FileSuffix = ".cache"; + public BinaryStorage(string filename) { - const string directoryName = "Cache"; - var directoryPath = Path.Combine(DataLocation.DataDirectory(), directoryName); + var directoryPath = Path.Combine(DataLocation.DataDirectory(), DirectoryName); Helper.ValidateDirectory(directoryPath); - const string fileSuffix = ".cache"; - FilePath = Path.Combine(directoryPath, $"{filename}{fileSuffix}"); + FilePath = Path.Combine(directoryPath, $"{filename}{FileSuffix}"); } public string FilePath { get; } - public T TryLoad(T defaultData) + public async ValueTask TryLoadAsync(T defaultData) { if (File.Exists(FilePath)) { if (new FileInfo(FilePath).Length == 0) { Log.Error($"|BinaryStorage.TryLoad|Zero length cache file <{FilePath}>"); - Save(defaultData); + await SaveAsync(defaultData); return defaultData; } - using (var stream = new FileStream(FilePath, FileMode.Open)) - { - var d = Deserialize(stream, defaultData); - return d; - } + await using var stream = new FileStream(FilePath, FileMode.Open); + var d = await DeserializeAsync(stream, defaultData); + return d; } else { Log.Info("|BinaryStorage.TryLoad|Cache file not exist, load default data"); - Save(defaultData); + await SaveAsync(defaultData); return defaultData; } } - private T Deserialize(FileStream stream, T defaultData) + private async ValueTask DeserializeAsync(Stream stream, T defaultData) { - //http://stackoverflow.com/questions/2120055/binaryformatter-deserialize-gives-serializationexception - AppDomain.CurrentDomain.AssemblyResolve += CurrentDomain_AssemblyResolve; - BinaryFormatter binaryFormatter = new BinaryFormatter - { - AssemblyFormat = FormatterAssemblyStyle.Simple - }; - try { - var t = ((T)binaryFormatter.Deserialize(stream)).NonNull(); + var t = await MemoryPackSerializer.DeserializeAsync(stream); return t; } catch (System.Exception e) @@ -72,47 +70,12 @@ namespace Flow.Launcher.Infrastructure.Storage Log.Exception($"|BinaryStorage.Deserialize|Deserialize error for file <{FilePath}>", e); return defaultData; } - finally - { - AppDomain.CurrentDomain.AssemblyResolve -= CurrentDomain_AssemblyResolve; - } } - private Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args) + public async ValueTask SaveAsync(T data) { - Assembly ayResult = null; - string sShortAssemblyName = args.Name.Split(',')[0]; - Assembly[] ayAssemblies = AppDomain.CurrentDomain.GetAssemblies(); - foreach (Assembly ayAssembly in ayAssemblies) - { - if (sShortAssemblyName == ayAssembly.FullName.Split(',')[0]) - { - ayResult = ayAssembly; - break; - } - } - return ayResult; - } - - public void Save(T data) - { - using (var stream = new FileStream(FilePath, FileMode.Create)) - { - BinaryFormatter binaryFormatter = new BinaryFormatter - { - AssemblyFormat = FormatterAssemblyStyle.Simple - }; - - try - { - binaryFormatter.Serialize(stream, data); - } - catch (SerializationException e) - { - Log.Exception($"|BinaryStorage.Save|serialize error for file <{FilePath}>", e); - } - } + await using var stream = new FileStream(FilePath, FileMode.Create); + await MemoryPackSerializer.SerializeAsync(stream, data); } } -#pragma warning restore SYSLIB0011 } diff --git a/Flow.Launcher.Plugin/ActionContext.cs b/Flow.Launcher.Plugin/ActionContext.cs index d6ba4894e..e31c8e31d 100644 --- a/Flow.Launcher.Plugin/ActionContext.cs +++ b/Flow.Launcher.Plugin/ActionContext.cs @@ -50,5 +50,12 @@ namespace Flow.Launcher.Plugin (AltPressed ? ModifierKeys.Alt : ModifierKeys.None) | (WinPressed ? ModifierKeys.Windows : ModifierKeys.None); } + + public static readonly SpecialKeyState Default = new () { + CtrlPressed = false, + ShiftPressed = false, + AltPressed = false, + WinPressed = false + }; } } diff --git a/Flow.Launcher.Test/Flow.Launcher.Test.csproj b/Flow.Launcher.Test/Flow.Launcher.Test.csproj index c662fdeff..29414baa6 100644 --- a/Flow.Launcher.Test/Flow.Launcher.Test.csproj +++ b/Flow.Launcher.Test/Flow.Launcher.Test.csproj @@ -54,7 +54,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + \ No newline at end of file diff --git a/Flow.Launcher/App.xaml.cs b/Flow.Launcher/App.xaml.cs index 295dd3e7a..f4a17761f 100644 --- a/Flow.Launcher/App.xaml.cs +++ b/Flow.Launcher/App.xaml.cs @@ -52,12 +52,13 @@ namespace Flow.Launcher { _portable.PreStartCleanUpAfterPortabilityUpdate(); - Log.Info("|App.OnStartup|Begin Flow Launcher startup ----------------------------------------------------"); + Log.Info( + "|App.OnStartup|Begin Flow Launcher startup ----------------------------------------------------"); Log.Info($"|App.OnStartup|Runtime info:{ErrorReporting.RuntimeInfo()}"); RegisterAppDomainExceptions(); RegisterDispatcherUnhandledException(); - ImageLoader.Initialize(); + var imageLoadertask = ImageLoader.InitializeAsync(); _settingsVM = new SettingWindowViewModel(_updater, _portable); _settings = _settingsVM.Settings; @@ -78,6 +79,8 @@ namespace Flow.Launcher Http.Proxy = _settings.Proxy; await PluginManager.InitializePluginsAsync(API); + await imageLoadertask; + var window = new MainWindow(_settings, _mainVM); Log.Info($"|App.OnStartup|Dependencies Info:{ErrorReporting.DependenciesInfo()}"); @@ -103,7 +106,8 @@ namespace Flow.Launcher AutoUpdates(); API.SaveAppAllSettings(); - Log.Info("|App.OnStartup|End Flow Launcher startup ---------------------------------------------------- "); + Log.Info( + "|App.OnStartup|End Flow Launcher startup ---------------------------------------------------- "); }); } @@ -122,7 +126,8 @@ namespace Flow.Launcher // but if it fails (permissions, etc) then don't keep retrying // this also gives the user a visual indication in the Settings widget _settings.StartFlowLauncherOnSystemStartup = false; - Notification.Show(InternationalizationManager.Instance.GetTranslation("setAutoStartFailed"), e.Message); + Notification.Show(InternationalizationManager.Instance.GetTranslation("setAutoStartFailed"), + e.Message); } } } diff --git a/Flow.Launcher/ViewModel/MainViewModel.cs b/Flow.Launcher/ViewModel/MainViewModel.cs index c832c258d..61bf0c4dc 100644 --- a/Flow.Launcher/ViewModel/MainViewModel.cs +++ b/Flow.Launcher/ViewModel/MainViewModel.cs @@ -285,7 +285,8 @@ namespace Flow.Launcher.ViewModel } var hideWindow = await result.ExecuteAsync(new ActionContext { - SpecialKeyState = GlobalHotkey.CheckModifiers() + // not null means pressing modifier key + number, should ignore the modifier key + SpecialKeyState = index is not null ? SpecialKeyState.Default : GlobalHotkey.CheckModifiers() }) .ConfigureAwait(false); diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj index 9701c2ee8..4ce8584fc 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj @@ -1,4 +1,4 @@ - + Library @@ -56,7 +56,7 @@ - + \ No newline at end of file diff --git a/Plugins/Flow.Launcher.Plugin.Explorer/Flow.Launcher.Plugin.Explorer.csproj b/Plugins/Flow.Launcher.Plugin.Explorer/Flow.Launcher.Plugin.Explorer.csproj index 1c0bdaad7..6d1497327 100644 --- a/Plugins/Flow.Launcher.Plugin.Explorer/Flow.Launcher.Plugin.Explorer.csproj +++ b/Plugins/Flow.Launcher.Plugin.Explorer/Flow.Launcher.Plugin.Explorer.csproj @@ -45,7 +45,7 @@ - + diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/ContextMenu.cs b/Plugins/Flow.Launcher.Plugin.PluginsManager/ContextMenu.cs index 580954f3c..17e9fe2bc 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/ContextMenu.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/ContextMenu.cs @@ -13,6 +13,10 @@ namespace Flow.Launcher.Plugin.PluginsManager Context = context; } + private readonly GlyphInfo sourcecodeGlyph = new("/Resources/#Segoe Fluent Icons","\uE943"); + private readonly GlyphInfo issueGlyph = new("/Resources/#Segoe Fluent Icons", "\ued15"); + private readonly GlyphInfo manifestGlyph = new("/Resources/#Segoe Fluent Icons", "\uea37"); + public List LoadContextMenus(Result selectedResult) { if(selectedResult.ContextData is not UserPlugin pluginManifestInfo) @@ -36,6 +40,7 @@ namespace Flow.Launcher.Plugin.PluginsManager Title = Context.API.GetTranslation("plugin_pluginsmanager_plugin_contextmenu_gotosourcecode_title"), SubTitle = Context.API.GetTranslation("plugin_pluginsmanager_plugin_contextmenu_gotosourcecode_subtitle"), IcoPath = "Images\\sourcecode.png", + Glyph = sourcecodeGlyph, Action = _ => { Context.API.OpenUrl(pluginManifestInfo.UrlSourceCode); @@ -47,6 +52,7 @@ namespace Flow.Launcher.Plugin.PluginsManager Title = Context.API.GetTranslation("plugin_pluginsmanager_plugin_contextmenu_newissue_title"), SubTitle = Context.API.GetTranslation("plugin_pluginsmanager_plugin_contextmenu_newissue_subtitle"), IcoPath = "Images\\request.png", + Glyph = issueGlyph, Action = _ => { // standard UrlSourceCode format in PluginsManifest's plugins.json file: https://github.com/jjw24/Flow.Launcher.Plugin.Putty/tree/master @@ -63,6 +69,7 @@ namespace Flow.Launcher.Plugin.PluginsManager Title = Context.API.GetTranslation("plugin_pluginsmanager_plugin_contextmenu_pluginsmanifest_title"), SubTitle = Context.API.GetTranslation("plugin_pluginsmanager_plugin_contextmenu_pluginsmanifest_subtitle"), IcoPath = "Images\\manifestsite.png", + Glyph = manifestGlyph, Action = _ => { Context.API.OpenUrl("https://github.com/Flow-Launcher/Flow.Launcher.PluginsManifest"); diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/Flow.Launcher.Plugin.PluginsManager.csproj b/Plugins/Flow.Launcher.Plugin.PluginsManager/Flow.Launcher.Plugin.PluginsManager.csproj index 51882a20e..92500ae6a 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/Flow.Launcher.Plugin.PluginsManager.csproj +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/Flow.Launcher.Plugin.PluginsManager.csproj @@ -36,8 +36,4 @@ PreserveNewest - - - - \ No newline at end of file diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/en.xaml b/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/en.xaml index 1f74a49a2..42a1ac9b8 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/en.xaml +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/Languages/en.xaml @@ -8,7 +8,9 @@ Successfully downloaded {0} Error: Unable to download the plugin {0} by {1} {2}{3}Would you like to uninstall this plugin? After the uninstallation Flow will automatically restart. + {0} by {1} {2}{2}Would you like to uninstall this plugin? {0} by {1} {2}{3}Would you like to install this plugin? After the installation Flow will automatically restart. + {0} by {1} {2}{2}Would you like to install this plugin? Plugin Install Installing Plugin Download and install {0} @@ -18,19 +20,25 @@ Error: A plugin which has the same or greater version with {0} already exists. Error installing plugin Error occurred while trying to install {0} + Error uninstalling plugin No update available All plugins are up to date {0} by {1} {2}{3}Would you like to update this plugin? After the update Flow will automatically restart. + {0} by {1} {2}{2}Would you like to update this plugin? Plugin Update This plugin has an update, would you like to see it? This plugin is already installed Plugin Manifest Download Failed Please check if you can connect to github.com. This error means you may not be able to install or update plugins. + Plugin {0} successfully updated. Restarting Flow, please wait... Installing from an unknown source You are installing this plugin from an unknown source and it may contain potential risks!{0}{0}Please ensure you understand where this plugin is from and that it is safe.{0}{0}Would you like to continue still?{0}{0}(You can switch off this warning via settings) - - - + + Plugin {0} successfully installed. Please restart Flow. + Plugin {0} successfully uninstalled. Please restart Flow. + Plugin {0} successfully updated. Please restart Flow. + Plugin {0} has already been modified. Please restart Flow before making any further changes. + Plugins Manager Management of installing, uninstalling or updating Flow Launcher plugins @@ -48,4 +56,5 @@ Install from unknown source warning + Automatically restart Flow Launcher after installing/uninstalling/updating plugins \ No newline at end of file diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs b/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs index 683904ea0..00f77f872 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs @@ -10,7 +10,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using System.Windows; @@ -117,9 +116,19 @@ namespace Flow.Launcher.Plugin.PluginsManager return; } - var message = string.Format(Context.API.GetTranslation("plugin_pluginsmanager_install_prompt"), - plugin.Name, plugin.Author, - Environment.NewLine, Environment.NewLine); + string message; + if (Settings.AutoRestartAfterChanging) + { + message = string.Format(Context.API.GetTranslation("plugin_pluginsmanager_install_prompt"), + plugin.Name, plugin.Author, + Environment.NewLine, Environment.NewLine); + } + else + { + message = string.Format(Context.API.GetTranslation("plugin_pluginsmanager_install_prompt_no_restart"), + plugin.Name, plugin.Author, + Environment.NewLine); + } if (MessageBox.Show(message, Context.API.GetTranslation("plugin_pluginsmanager_install_title"), MessageBoxButton.YesNo) == MessageBoxResult.No) @@ -130,36 +139,48 @@ namespace Flow.Launcher.Plugin.PluginsManager ? $"{plugin.Name}-{Guid.NewGuid()}.zip" : $"{plugin.Name}-{plugin.Version}.zip"; - var filePath = Path.Combine(DataLocation.PluginsDirectory, downloadFilename); + var filePath = Path.Combine(Path.GetTempPath(), downloadFilename); try { - await Http.DownloadAsync(plugin.UrlDownload, filePath).ConfigureAwait(false); + if (File.Exists(filePath)) + { + File.Delete(filePath); + } - Context.API.ShowMsg(Context.API.GetTranslation("plugin_pluginsmanager_downloading_plugin"), - string.Format(Context.API.GetTranslation("plugin_pluginsmanager_download_success"), plugin.Name)); + await Http.DownloadAsync(plugin.UrlDownload, filePath).ConfigureAwait(false); Install(plugin, filePath); } - catch (Exception e) + catch (HttpRequestException e) { - if (e is HttpRequestException) - MessageBox.Show(Context.API.GetTranslation("plugin_pluginsmanager_download_error"), - Context.API.GetTranslation("plugin_pluginsmanager_downloading_plugin")); - - Context.API.ShowMsgError(Context.API.GetTranslation("plugin_pluginsmanager_install_error_title"), - string.Format(Context.API.GetTranslation("plugin_pluginsmanager_install_error_subtitle"), - plugin.Name)); - - Log.Exception("PluginsManager", "An error occurred while downloading plugin", e, "InstallOrUpdate"); - + Context.API.ShowMsgError(string.Format(Context.API.GetTranslation("plugin_pluginsmanager_downloading_plugin"), plugin.Name), + Context.API.GetTranslation("plugin_pluginsmanager_download_error")); + Log.Exception("PluginsManager", "An error occurred while downloading plugin", e); return; } - - Context.API.ShowMsg(Context.API.GetTranslation("plugin_pluginsmanager_installing_plugin"), - string.Format(Context.API.GetTranslation("plugin_pluginsmanager_install_success_restart"), plugin.Name)); - - Context.API.RestartApp(); + catch (Exception e) + { + Context.API.ShowMsgError(Context.API.GetTranslation("plugin_pluginsmanager_install_error_title"), + string.Format(Context.API.GetTranslation("plugin_pluginsmanager_install_error_subtitle"), + plugin.Name)); + Log.Exception("PluginsManager", "An error occurred while downloading plugin", e); + return; + } + + if (Settings.AutoRestartAfterChanging) + { + Context.API.ShowMsg(Context.API.GetTranslation("plugin_pluginsmanager_installing_plugin"), + string.Format(Context.API.GetTranslation("plugin_pluginsmanager_install_success_restart"), + plugin.Name)); + Context.API.RestartApp(); + } + else + { + Context.API.ShowMsg(Context.API.GetTranslation("plugin_pluginsmanager_installing_plugin"), + string.Format(Context.API.GetTranslation("plugin_pluginsmanager_install_success_no_restart"), + plugin.Name)); + } } internal async ValueTask> RequestUpdateAsync(string search, CancellationToken token, bool usePrimaryUrlOnly = false) @@ -172,6 +193,7 @@ namespace Flow.Launcher.Plugin.PluginsManager on existingPlugin.Metadata.ID equals pluginFromManifest.ID where existingPlugin.Metadata.Version.CompareTo(pluginFromManifest.Version) < 0 // if current version precedes manifest version + && !PluginManager.PluginModified(existingPlugin.Metadata.ID) select new { @@ -204,36 +226,57 @@ namespace Flow.Launcher.Plugin.PluginsManager IcoPath = x.IcoPath, Action = e => { - string message = string.Format( - Context.API.GetTranslation("plugin_pluginsmanager_update_prompt"), - x.Name, x.Author, - Environment.NewLine, Environment.NewLine); + + string message; + if (Settings.AutoRestartAfterChanging) + { + message = string.Format(Context.API.GetTranslation("plugin_pluginsmanager_update_prompt"), + x.Name, x.Author, + Environment.NewLine, Environment.NewLine); + } + else + { + message = string.Format(Context.API.GetTranslation("plugin_pluginsmanager_update_prompt_no_restart"), + x.Name, x.Author, + Environment.NewLine); + } if (MessageBox.Show(message, Context.API.GetTranslation("plugin_pluginsmanager_update_title"), MessageBoxButton.YesNo) == MessageBoxResult.Yes) { - Uninstall(x.PluginExistingMetadata, false); - - var downloadToFilePath = Path.Combine(DataLocation.PluginsDirectory, + var downloadToFilePath = Path.Combine(Path.GetTempPath(), $"{x.Name}-{x.NewVersion}.zip"); - Task.Run(async delegate + _ = Task.Run(async delegate { + if (File.Exists(downloadToFilePath)) + { + File.Delete(downloadToFilePath); + } + await Http.DownloadAsync(x.PluginNewUserPlugin.UrlDownload, downloadToFilePath) .ConfigureAwait(false); - Context.API.ShowMsg( - Context.API.GetTranslation("plugin_pluginsmanager_downloading_plugin"), - string.Format(Context.API.GetTranslation("plugin_pluginsmanager_download_success"), x.Name)); + PluginManager.UpdatePlugin(x.PluginExistingMetadata, x.PluginNewUserPlugin, downloadToFilePath); - Install(x.PluginNewUserPlugin, downloadToFilePath); - - Context.API.RestartApp(); + if (Settings.AutoRestartAfterChanging) + { + Context.API.ShowMsg(Context.API.GetTranslation("plugin_pluginsmanager_update_title"), + string.Format(Context.API.GetTranslation("plugin_pluginsmanager_update_success_restart"), + x.Name)); + Context.API.RestartApp(); + } + else + { + Context.API.ShowMsg(Context.API.GetTranslation("plugin_pluginsmanager_update_title"), + string.Format(Context.API.GetTranslation("plugin_pluginsmanager_update_success_no_restart"), + x.Name)); + } }).ContinueWith(t => { Log.Exception("PluginsManager", $"Update failed for {x.Name}", - t.Exception.InnerException, "RequestUpdate"); + t.Exception.InnerException); Context.API.ShowMsg( Context.API.GetTranslation("plugin_pluginsmanager_install_error_title"), string.Format( @@ -348,12 +391,13 @@ namespace Flow.Launcher.Plugin.PluginsManager var results = PluginsManifest .UserPlugins + .Where(x => !PluginExists(x.ID) && !PluginManager.PluginModified(x.ID)) .Select(x => new Result { Title = $"{x.Name} by {x.Author}", SubTitle = x.Description, - IcoPath = icoPath, + IcoPath = x.IcoPath, Action = e => { if (e.SpecialKeyState.CtrlPressed) @@ -375,78 +419,29 @@ namespace Flow.Launcher.Plugin.PluginsManager private void Install(UserPlugin plugin, string downloadedFilePath) { if (!File.Exists(downloadedFilePath)) - return; - - var tempFolderPath = Path.Combine(Path.GetTempPath(), "flowlauncher"); - var tempFolderPluginPath = Path.Combine(tempFolderPath, "plugin"); - - if (Directory.Exists(tempFolderPath)) - Directory.Delete(tempFolderPath, true); - - Directory.CreateDirectory(tempFolderPath); - - var zipFilePath = Path.Combine(tempFolderPath, Path.GetFileName(downloadedFilePath)); - - File.Copy(downloadedFilePath, zipFilePath); - - File.Delete(downloadedFilePath); - - Utilities.UnZip(zipFilePath, tempFolderPluginPath, true); - - var pluginFolderPath = Utilities.GetContainingFolderPathAfterUnzip(tempFolderPluginPath); - - var metadataJsonFilePath = string.Empty; - if (File.Exists(Path.Combine(pluginFolderPath, Constant.PluginMetadataFileName))) - metadataJsonFilePath = Path.Combine(pluginFolderPath, Constant.PluginMetadataFileName); - - if (string.IsNullOrEmpty(metadataJsonFilePath) || string.IsNullOrEmpty(pluginFolderPath)) + throw new FileNotFoundException($"Plugin {plugin.ID} zip file not found at {downloadedFilePath}", downloadedFilePath); + try { - MessageBox.Show(Context.API.GetTranslation("plugin_pluginsmanager_install_errormetadatafile"), - Context.API.GetTranslation("plugin_pluginsmanager_install_error_title")); - - throw new FileNotFoundException( - string.Format("Unable to find plugin.json from the extracted zip file, or this path {0} does not exist", pluginFolderPath)); + PluginManager.InstallPlugin(plugin, downloadedFilePath); + File.Delete(downloadedFilePath); } - - if (SameOrLesserPluginVersionExists(metadataJsonFilePath)) + catch (FileNotFoundException e) { - MessageBox.Show(string.Format(Context.API.GetTranslation("plugin_pluginsmanager_install_error_duplicate"), plugin.Name), - Context.API.GetTranslation("plugin_pluginsmanager_install_error_title")); - - throw new InvalidOperationException( - string.Format("A plugin with the same ID and version already exists, " + - "or the version is greater than this downloaded plugin {0}", - plugin.Name)); + Context.API.ShowMsgError(Context.API.GetTranslation("plugin_pluginsmanager_install_error_title"), + Context.API.GetTranslation("plugin_pluginsmanager_install_errormetadatafile")); + Log.Exception("Flow.Launcher.Plugin.PluginsManager", e.Message, e); + } + catch (InvalidOperationException e) + { + Context.API.ShowMsgError(Context.API.GetTranslation("plugin_pluginsmanager_install_error_title"), + string.Format(Context.API.GetTranslation("plugin_pluginsmanager_install_error_duplicate"), plugin.Name)); + Log.Exception("Flow.Launcher.Plugin.PluginsManager", e.Message, e); + } + catch (ArgumentException e) { + Context.API.ShowMsgError(Context.API.GetTranslation("plugin_pluginsmanager_install_error_title"), + string.Format(Context.API.GetTranslation("plugin_pluginsmanager_plugin_modified_error"), plugin.Name)); + Log.Exception("Flow.Launcher.Plugin.PluginsManager", e.Message, e); } - - var folderName = string.IsNullOrEmpty(plugin.Version) ? $"{plugin.Name}-{Guid.NewGuid()}" : $"{plugin.Name}-{plugin.Version}"; - - var defaultPluginIDs = new List - { - "0ECADE17459B49F587BF81DC3A125110", // BrowserBookmark - "CEA0FDFC6D3B4085823D60DC76F28855", // Calculator - "572be03c74c642baae319fc283e561a8", // Explorer - "6A122269676E40EB86EB543B945932B9", // PluginIndicator - "9f8f9b14-2518-4907-b211-35ab6290dee7", // PluginsManager - "b64d0a79-329a-48b0-b53f-d658318a1bf6", // ProcessKiller - "791FC278BA414111B8D1886DFE447410", // Program - "D409510CD0D2481F853690A07E6DC426", // Shell - "CEA08895D2544B019B2E9C5009600DF4", // Sys - "0308FD86DE0A4DEE8D62B9B535370992", // URL - "565B73353DBF4806919830B9202EE3BF", // WebSearch - "5043CETYU6A748679OPA02D27D99677A" // WindowsSettings - }; - - // Treat default plugin differently, it needs to be removable along with each flow release - var installDirectory = !defaultPluginIDs.Any(x => x == plugin.ID) - ? DataLocation.PluginsDirectory - : Constant.PreinstalledDirectory; - - var newPluginPath = Path.Combine(installDirectory, folderName); - - FilesFolders.CopyAll(pluginFolderPath, newPluginPath); - - Directory.Delete(pluginFolderPath, true); } internal List RequestUninstall(string search) @@ -461,10 +456,19 @@ namespace Flow.Launcher.Plugin.PluginsManager IcoPath = x.Metadata.IcoPath, Action = e => { - string message = string.Format( - Context.API.GetTranslation("plugin_pluginsmanager_uninstall_prompt"), - x.Metadata.Name, x.Metadata.Author, - Environment.NewLine, Environment.NewLine); + string message; + if (Settings.AutoRestartAfterChanging) + { + message = string.Format(Context.API.GetTranslation("plugin_pluginsmanager_uninstall_prompt"), + x.Metadata.Name, x.Metadata.Author, + Environment.NewLine, Environment.NewLine); + } + else + { + message = string.Format(Context.API.GetTranslation("plugin_pluginsmanager_uninstall_prompt_no_restart"), + x.Metadata.Name, x.Metadata.Author, + Environment.NewLine); + } if (MessageBox.Show(message, Context.API.GetTranslation("plugin_pluginsmanager_uninstall_title"), @@ -472,7 +476,16 @@ namespace Flow.Launcher.Plugin.PluginsManager { Application.Current.MainWindow.Hide(); Uninstall(x.Metadata); - Context.API.RestartApp(); + if (Settings.AutoRestartAfterChanging) + { + Context.API.RestartApp(); + } + else + { + Context.API.ShowMsg(Context.API.GetTranslation("plugin_pluginsmanager_uninstall_title"), + string.Format(Context.API.GetTranslation("plugin_pluginsmanager_uninstall_success_no_restart"), + x.Metadata.Name)); + } return true; } @@ -484,24 +497,18 @@ namespace Flow.Launcher.Plugin.PluginsManager return Search(results, search); } - private void Uninstall(PluginMetadata plugin, bool removedSetting = true) + private void Uninstall(PluginMetadata plugin) { - if (removedSetting) + try { - PluginManager.Settings.Plugins.Remove(plugin.ID); - PluginManager.AllPlugins.RemoveAll(p => p.Metadata.ID == plugin.ID); + PluginManager.UninstallPlugin(plugin, removeSettings:true); + } + catch (ArgumentException e) + { + Log.Exception("Flow.Launcher.Plugin.PluginsManager", e.Message, e); + Context.API.ShowMsgError(Context.API.GetTranslation("plugin_pluginsmanager_uninstall_error_title"), + Context.API.GetTranslation("plugin_pluginsmanager_plugin_modified_error")); } - - // Marked for deletion. Will be deleted on next start up - using var _ = File.CreateText(Path.Combine(plugin.PluginDirectory, "NeedDelete.txt")); - } - - private bool SameOrLesserPluginVersionExists(string metadataPath) - { - var newMetadata = JsonSerializer.Deserialize(File.ReadAllText(metadataPath)); - return Context.API.GetAllPlugins() - .Any(x => x.Metadata.ID == newMetadata.ID - && newMetadata.Version.CompareTo(x.Metadata.Version) <= 0); } } } diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/Settings.cs b/Plugins/Flow.Launcher.Plugin.PluginsManager/Settings.cs index aa35f02b5..811bec50c 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/Settings.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/Settings.cs @@ -9,5 +9,7 @@ internal const string UpdateCommand = "update"; public bool WarnFromUnknownSource { get; set; } = true; + + public bool AutoRestartAfterChanging { get; set; } = true; } } diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/ViewModels/SettingsViewModel.cs b/Plugins/Flow.Launcher.Plugin.PluginsManager/ViewModels/SettingsViewModel.cs index 672884f80..1c71507fc 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/ViewModels/SettingsViewModel.cs +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/ViewModels/SettingsViewModel.cs @@ -17,5 +17,11 @@ get => Settings.WarnFromUnknownSource; set => Settings.WarnFromUnknownSource = value; } + + public bool AutoRestartAfterChanging + { + get => Settings.AutoRestartAfterChanging; + set => Settings.AutoRestartAfterChanging = value; + } } } diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/Views/PluginsManagerSettings.xaml b/Plugins/Flow.Launcher.Plugin.PluginsManager/Views/PluginsManagerSettings.xaml index a62a032f7..18be0d2ca 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/Views/PluginsManagerSettings.xaml +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/Views/PluginsManagerSettings.xaml @@ -12,10 +12,22 @@ + + + + + diff --git a/Plugins/Flow.Launcher.Plugin.Program/Main.cs b/Plugins/Flow.Launcher.Plugin.Program/Main.cs index ac23534b1..c02573ed8 100644 --- a/Plugins/Flow.Launcher.Plugin.Program/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Program/Main.cs @@ -15,24 +15,22 @@ using Stopwatch = Flow.Launcher.Infrastructure.Stopwatch; namespace Flow.Launcher.Plugin.Program { - public class Main : ISettingProvider, IAsyncPlugin, IPluginI18n, IContextMenu, ISavable, IAsyncReloadable, IDisposable + public class Main : ISettingProvider, IAsyncPlugin, IPluginI18n, IContextMenu, ISavable, IAsyncReloadable, + IDisposable { internal static Win32[] _win32s { get; set; } - internal static UWP.Application[] _uwps { get; set; } + internal static UWPApp[] _uwps { get; set; } internal static Settings _settings { get; set; } internal static PluginInitContext Context { get; private set; } private static BinaryStorage _win32Storage; - private static BinaryStorage _uwpStorage; + private static BinaryStorage _uwpStorage; private static readonly List emptyResults = new(); - private static readonly MemoryCacheOptions cacheOptions = new() - { - SizeLimit = 1560 - }; + private static readonly MemoryCacheOptions cacheOptions = new() { SizeLimit = 1560 }; private static MemoryCache cache = new(cacheOptions); static Main() @@ -41,8 +39,8 @@ namespace Flow.Launcher.Plugin.Program public void Save() { - _win32Storage.Save(_win32s); - _uwpStorage.Save(_uwps); + _win32Storage.SaveAsync(_win32s); + _uwpStorage.SaveAsync(_uwps); } public async Task> QueryAsync(Query query, CancellationToken token) @@ -76,12 +74,12 @@ namespace Flow.Launcher.Plugin.Program _settings = context.API.LoadSettingJsonStorage(); - Stopwatch.Normal("|Flow.Launcher.Plugin.Program.Main|Preload programs cost", () => + await Stopwatch.NormalAsync("|Flow.Launcher.Plugin.Program.Main|Preload programs cost", async () => { _win32Storage = new BinaryStorage("Win32"); - _win32s = _win32Storage.TryLoad(Array.Empty()); - _uwpStorage = new BinaryStorage("UWP"); - _uwps = _uwpStorage.TryLoad(Array.Empty()); + _win32s = await _win32Storage.TryLoadAsync(Array.Empty()); + _uwpStorage = new BinaryStorage("UWP"); + _uwps = await _uwpStorage.TryLoadAsync(Array.Empty()); }); Log.Info($"|Flow.Launcher.Plugin.Program.Main|Number of preload win32 programs <{_win32s.Length}>"); Log.Info($"|Flow.Launcher.Plugin.Program.Main|Number of preload uwps <{_uwps.Length}>"); @@ -104,7 +102,7 @@ namespace Flow.Launcher.Plugin.Program static void WatchProgramUpdate() { Win32.WatchProgramUpdate(_settings); - _ = UWP.WatchPackageChange(); + _ = UWPPackage.WatchPackageChange(); } } @@ -113,16 +111,16 @@ namespace Flow.Launcher.Plugin.Program var win32S = Win32.All(_settings); _win32s = win32S; ResetCache(); - _win32Storage.Save(_win32s); + _win32Storage.SaveAsync(_win32s); _settings.LastIndexTime = DateTime.Now; } public static void IndexUwpPrograms() { - var applications = UWP.All(_settings); + var applications = UWPPackage.All(_settings); _uwps = applications; ResetCache(); - _uwpStorage.Save(_uwps); + _uwpStorage.SaveAsync(_uwps); _settings.LastIndexTime = DateTime.Now; } @@ -228,7 +226,8 @@ namespace Flow.Launcher.Plugin.Program catch (Exception) { var title = Context.API.GetTranslation("flowlauncher_plugin_program_disable_dlgtitle_error"); - var message = string.Format(Context.API.GetTranslation("flowlauncher_plugin_program_run_failed"), info.FileName); + var message = string.Format(Context.API.GetTranslation("flowlauncher_plugin_program_run_failed"), + info.FileName); Context.API.ShowMsg(title, string.Format(message, info.FileName), string.Empty); } } diff --git a/Plugins/Flow.Launcher.Plugin.Program/Programs/UWP.cs b/Plugins/Flow.Launcher.Plugin.Program/Programs/UWP.cs deleted file mode 100644 index d5924ba28..000000000 --- a/Plugins/Flow.Launcher.Plugin.Program/Programs/UWP.cs +++ /dev/null @@ -1,737 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Security.Principal; -using System.Threading.Tasks; -using System.Windows.Media.Imaging; -using Windows.ApplicationModel; -using Windows.Management.Deployment; -using Flow.Launcher.Infrastructure; -using Flow.Launcher.Plugin.Program.Logger; -using Flow.Launcher.Plugin.SharedModels; -using System.Threading.Channels; -using System.Xml; -using Windows.ApplicationModel.Core; -using System.Windows.Input; - -namespace Flow.Launcher.Plugin.Program.Programs -{ - [Serializable] - public class UWP - { - public string Name { get; } - public string FullName { get; } - public string FamilyName { get; } - public string Location { get; set; } - - public Application[] Apps { get; set; } = Array.Empty(); - - - public UWP(Package package) - { - Location = package.InstalledLocation.Path; - Name = package.Id.Name; - FullName = package.Id.FullName; - FamilyName = package.Id.FamilyName; - } - - public void InitAppsInPackage(Package package) - { - var apps = new List(); - // WinRT - var appListEntries = package.GetAppListEntries(); - foreach (var app in appListEntries) - { - try - { - var tmp = new Application(app, this); - apps.Add(tmp); - } - catch (Exception e) - { - ProgramLogger.LogException($"|UWP|InitAppsInPackage|{Location}" + - "|Unexpected exception occurs when trying to construct a Application from package" - + $"{FullName} from location {Location}", e); - } - } - Apps = apps.ToArray(); - - try - { - var xmlDoc = GetManifestXml(); - if (xmlDoc == null) - { - return; - } - - var xmlRoot = xmlDoc.DocumentElement; - var packageVersion = GetPackageVersionFromManifest(xmlRoot); - if (!smallLogoNameFromVersion.TryGetValue(packageVersion, out string logoName) || - !bigLogoNameFromVersion.TryGetValue(packageVersion, out string bigLogoName)) - { - return; - } - - var namespaceManager = new XmlNamespaceManager(xmlDoc.NameTable); - namespaceManager.AddNamespace("d", "http://schemas.microsoft.com/appx/manifest/foundation/windows10"); // still need a name - namespaceManager.AddNamespace("rescap", "http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"); - namespaceManager.AddNamespace("uap10", "http://schemas.microsoft.com/appx/manifest/uap/windows10/10"); - - var allowElevationNode = xmlRoot.SelectSingleNode("//rescap:Capability[@Name='allowElevation']", namespaceManager); - bool packageCanElevate = allowElevationNode != null; - - var appsNode = xmlRoot.SelectSingleNode("d:Applications", namespaceManager); - foreach (var app in Apps) - { - // According to https://learn.microsoft.com/windows/apps/desktop/modernize/grant-identity-to-nonpackaged-apps#create-a-package-manifest-for-the-sparse-package - // and https://learn.microsoft.com/uwp/schemas/appxpackage/uapmanifestschema/element-application#attributes - var id = app.UserModelId.Split('!')[1]; - var appNode = appsNode?.SelectSingleNode($"d:Application[@Id='{id}']", namespaceManager); - if (appNode != null) - { - app.CanRunElevated = packageCanElevate || Application.IfAppCanRunElevated(appNode); - - // local name to fit all versions - var visualElement = appNode.SelectSingleNode($"*[local-name()='VisualElements']", namespaceManager); - var logoUri = visualElement?.Attributes[logoName]?.Value; - app.LogoPath = app.LogoPathFromUri(logoUri, (64, 64)); - // use small logo or may have a big margin - var previewUri = visualElement?.Attributes[logoName]?.Value; - app.PreviewImagePath = app.LogoPathFromUri(previewUri, (256, 256)); - } - } - } - catch (Exception e) - { - ProgramLogger.LogException($"|UWP|InitAppsInPackage|{Location}" + - "|Unexpected exception occurs when trying to construct a Application from package" - + $"{FullName} from location {Location}", e); - } - } - - private XmlDocument GetManifestXml() - { - var manifest = Path.Combine(Location, "AppxManifest.xml"); - try - { - var file = File.ReadAllText(manifest); - var xmlDoc = new XmlDocument(); - xmlDoc.LoadXml(file); - return xmlDoc; - } - catch (FileNotFoundException e) - { - ProgramLogger.LogException("UWP", "GetManifestXml", $"{Location}", "AppxManifest.xml not found.", e); - return null; - } - catch (Exception e) - { - ProgramLogger.LogException("UWP", "GetManifestXml", $"{Location}", "An unexpected error occurred and unable to parse AppxManifest.xml", e); - return null; - } - } - - private PackageVersion GetPackageVersionFromManifest(XmlNode xmlRoot) - { - if (xmlRoot != null) - { - - var namespaces = xmlRoot.Attributes; - foreach (XmlAttribute ns in namespaces) - { - if (versionFromNamespace.TryGetValue(ns.Value, out var packageVersion)) - { - return packageVersion; - } - } - - ProgramLogger.LogException($"|UWP|GetPackageVersionFromManifest|{Location}" + - "|Trying to get the package version of the UWP program, but an unknown UWP app-manifest version in package " - + $"{FullName} from location {Location}", new FormatException()); - return PackageVersion.Unknown; - } - else - { - ProgramLogger.LogException($"|UWP|GetPackageVersionFromManifest|{Location}" + - "|Can't parse AppManifest.xml of package " - + $"{FullName} from location {Location}", new ArgumentNullException(nameof(xmlRoot))); - return PackageVersion.Unknown; - } - } - - private static readonly Dictionary versionFromNamespace = new() - { - { - "http://schemas.microsoft.com/appx/manifest/foundation/windows10", PackageVersion.Windows10 - }, - { - "http://schemas.microsoft.com/appx/2013/manifest", PackageVersion.Windows81 - }, - { - "http://schemas.microsoft.com/appx/2010/manifest", PackageVersion.Windows8 - }, - }; - - private static readonly Dictionary smallLogoNameFromVersion = new() - { - { - PackageVersion.Windows10, "Square44x44Logo" - }, - { - PackageVersion.Windows81, "Square30x30Logo" - }, - { - PackageVersion.Windows8, "SmallLogo" - }, - }; - - private static readonly Dictionary bigLogoNameFromVersion = new() - { - { - PackageVersion.Windows10, "Square150x150Logo" - }, - { - PackageVersion.Windows81, "Square150x150Logo" - }, - { - PackageVersion.Windows8, "Logo" - }, - }; - - public static Application[] All(Settings settings) - { - var support = SupportUWP(); - if (support && settings.EnableUWP) - { - var applications = CurrentUserPackages().AsParallel().SelectMany(p => - { - UWP u; - try - { - u = new UWP(p); - u.InitAppsInPackage(p); - } -#if !DEBUG - catch (Exception e) - { - ProgramLogger.LogException($"|UWP|All|{p.InstalledLocation}|An unexpected error occurred and unable to convert Package to UWP for {p.Id.FullName}", e); - return Array.Empty(); - } -#endif -#if DEBUG //make developer aware and implement handling - catch - { - throw; - } -#endif - return u.Apps; - }).ToArray(); - - var updatedListWithoutDisabledApps = applications - .Where(t1 => !Main._settings.DisabledProgramSources - .Any(x => x.UniqueIdentifier == t1.UniqueIdentifier)); - - return updatedListWithoutDisabledApps.ToArray(); - } - else - { - return Array.Empty(); - } - } - - public static bool SupportUWP() - { - var windows10 = new Version(10, 0); - var support = Environment.OSVersion.Version.Major >= windows10.Major; - return support; - } - - private static IEnumerable CurrentUserPackages() - { - var user = WindowsIdentity.GetCurrent().User; - - if (user != null) - { - var userId = user.Value; - PackageManager packageManager; - try - { - packageManager = new PackageManager(); - } - catch - { - // Bug from https://github.com/microsoft/CsWinRT, using Microsoft.Windows.SDK.NET.Ref 10.0.19041.0. - // Only happens on the first time, so a try catch can fix it. - packageManager = new PackageManager(); - } - var packages = packageManager.FindPackagesForUser(userId); - packages = packages.Where(p => - { - try - { - var f = p.IsFramework; - var d = p.IsDevelopmentMode; - var path = p.InstalledLocation.Path; - return !f && !d && !string.IsNullOrEmpty(path); - } - catch (Exception e) - { - ProgramLogger.LogException("UWP", "CurrentUserPackages", $"{p.Id.FullName}", "An unexpected error occurred and " - + $"unable to verify if package is valid", e); - return false; - } - }); - return packages; - } - else - { - return Array.Empty(); - } - } - - private static Channel PackageChangeChannel = Channel.CreateBounded(1); - - public static async Task WatchPackageChange() - { - if (Environment.OSVersion.Version.Major >= 10) - { - var catalog = PackageCatalog.OpenForCurrentUser(); - catalog.PackageInstalling += (_, args) => - { - if (args.IsComplete) - PackageChangeChannel.Writer.TryWrite(default); - }; - catalog.PackageUninstalling += (_, args) => - { - if (args.IsComplete) - PackageChangeChannel.Writer.TryWrite(default); - }; - catalog.PackageUpdating += (_, args) => - { - if (args.IsComplete) - PackageChangeChannel.Writer.TryWrite(default); - }; - - while (await PackageChangeChannel.Reader.WaitToReadAsync().ConfigureAwait(false)) - { - await Task.Delay(3000).ConfigureAwait(false); - PackageChangeChannel.Reader.TryRead(out _); - await Task.Run(Main.IndexUwpPrograms); - } - - } - } - - public override string ToString() - { - return FamilyName; - } - - public override bool Equals(object obj) - { - if (obj is UWP uwp) - { - return FamilyName.Equals(uwp.FamilyName); - } - else - { - return false; - } - } - - public override int GetHashCode() - { - return FamilyName.GetHashCode(); - } - - [Serializable] - public class Application : IProgram - { - private string _uid = string.Empty; - public string UniqueIdentifier { get => _uid; set => _uid = value == null ? string.Empty : value.ToLowerInvariant(); } - public string DisplayName { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public string UserModelId { get; set; } = string.Empty; - //public string BackgroundColor { get; set; } = string.Empty; // preserve for future use - public string Name => DisplayName; - public string Location { get; set; } = string.Empty; - - public bool Enabled { get; set; } = false; - public bool CanRunElevated { get; set; } = false; - public string LogoPath { get; set; } = string.Empty; - public string PreviewImagePath { get; set; } = string.Empty; - - public Application(AppListEntry appListEntry, UWP package) - { - UserModelId = appListEntry.AppUserModelId; - UniqueIdentifier = appListEntry.AppUserModelId; - DisplayName = appListEntry.DisplayInfo.DisplayName; - Description = appListEntry.DisplayInfo.Description; - Location = package.Location; - Enabled = true; - } - - public Result Result(string query, IPublicAPI api) - { - string title; - MatchResult matchResult; - - // We suppose Name won't be null - if (!Main._settings.EnableDescription || string.IsNullOrWhiteSpace(Description) || Name.Equals(Description)) - { - title = Name; - matchResult = StringMatcher.FuzzySearch(query, Name); - } - else - { - title = $"{Name}: {Description}"; - var nameMatch = StringMatcher.FuzzySearch(query, Name); - var descriptionMatch = StringMatcher.FuzzySearch(query, Description); - if (descriptionMatch.Score > nameMatch.Score) - { - for (int i = 0; i < descriptionMatch.MatchData.Count; i++) - { - descriptionMatch.MatchData[i] += Name.Length + 2; // 2 is ": " - } - matchResult = descriptionMatch; - } - else - { - matchResult = nameMatch; - } - } - - if (!matchResult.IsSearchPrecisionScoreMet()) - return null; - - var result = new Result - { - Title = title, - AutoCompleteText = Name, - SubTitle = Main._settings.HideAppsPath ? string.Empty : Location, - IcoPath = LogoPath, - Preview = new Result.PreviewInfo - { - IsMedia = false, - PreviewImagePath = PreviewImagePath, - Description = Description - }, - Score = matchResult.Score, - TitleHighlightData = matchResult.MatchData, - ContextData = this, - Action = e => - { - // Ctrl + Enter to open containing folder - bool openFolder = e.SpecialKeyState.ToModifierKeys() == ModifierKeys.Control; - if (openFolder) - { - Main.Context.API.OpenDirectory(Location); - return true; - } - - // Ctrl + Shift + Enter to run elevated - bool elevated = e.SpecialKeyState.ToModifierKeys() == (ModifierKeys.Control | ModifierKeys.Shift); - - bool shouldRunElevated = elevated && CanRunElevated; - _ = Task.Run(() => Launch(shouldRunElevated)).ConfigureAwait(false); - if (elevated && !shouldRunElevated) - { - var title = api.GetTranslation("flowlauncher_plugin_program_disable_dlgtitle_error"); - var message = api.GetTranslation("flowlauncher_plugin_program_run_as_administrator_not_supported_message"); - api.ShowMsg(title, message, string.Empty); - } - - return true; - } - }; - - - return result; - } - - public List ContextMenus(IPublicAPI api) - { - var contextMenus = new List - { - new Result - { - Title = api.GetTranslation("flowlauncher_plugin_program_open_containing_folder"), - Action = _ => - { - Main.Context.API.OpenDirectory(Location); - - return true; - }, - IcoPath = "Images/folder.png", - Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\xe838"), - } - }; - - if (CanRunElevated) - { - contextMenus.Add(new Result - { - Title = api.GetTranslation("flowlauncher_plugin_program_run_as_administrator"), - Action = _ => - { - Task.Run(() => Launch(true)).ConfigureAwait(false); - return true; - }, - IcoPath = "Images/cmd.png", - Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\xe7ef") - }); - } - - return contextMenus; - } - - private void Launch(bool elevated = false) - { - string command = "shell:AppsFolder\\" + UserModelId; - command = Environment.ExpandEnvironmentVariables(command.Trim()); - - var info = new ProcessStartInfo(command) - { - UseShellExecute = true, - Verb = elevated ? "runas" : "" - }; - - Main.StartProcess(Process.Start, info); - } - - internal static bool IfAppCanRunElevated(XmlNode appNode) - { - // According to https://learn.microsoft.com/windows/apps/desktop/modernize/grant-identity-to-nonpackaged-apps#create-a-package-manifest-for-the-sparse-package - // and https://learn.microsoft.com/uwp/schemas/appxpackage/uapmanifestschema/element-application#attributes - - return appNode?.Attributes["EntryPoint"]?.Value == "Windows.FullTrustApplication" || - appNode?.Attributes["uap10:TrustLevel"]?.Value == "mediumIL"; - } - - internal string LogoPathFromUri(string uri, (int, int) desiredSize) - { - // all https://msdn.microsoft.com/windows/uwp/controls-and-patterns/tiles-and-notifications-app-assets - // windows 10 https://msdn.microsoft.com/en-us/library/windows/apps/dn934817.aspx - // windows 8.1 https://msdn.microsoft.com/en-us/library/windows/apps/hh965372.aspx#target_size - // windows 8 https://msdn.microsoft.com/en-us/library/windows/apps/br211475.aspx - - if (string.IsNullOrWhiteSpace(uri)) - { - ProgramLogger.LogException($"|UWP|LogoPathFromUri|{Location}" + - $"|{UserModelId} 's logo uri is null or empty: {Location}", new ArgumentException("uri")); - return string.Empty; - } - - string path = Path.Combine(Location, uri); - - var pxCount = desiredSize.Item1 * desiredSize.Item2; - var logoPath = TryToFindLogo(uri, path, pxCount); - if (logoPath == string.Empty) - { - var tmp = Path.Combine(Location, "Assets", uri); - if (!path.Equals(tmp, StringComparison.OrdinalIgnoreCase)) - { - // TODO: Don't know why, just keep it at the moment - // Maybe on older version of Windows 10? - // for C:\Windows\MiracastView etc - return TryToFindLogo(uri, tmp, pxCount); - } - } - return logoPath; - - string TryToFindLogo(string uri, string path, int px) - { - var extension = Path.GetExtension(path); - if (extension != null) - { - //if (File.Exists(path)) - //{ - // return path; // shortcut, avoid enumerating files - //} - - var logoNamePrefix = Path.GetFileNameWithoutExtension(uri); // e.g Square44x44 - var logoDir = Path.GetDirectoryName(path); // e.g ..\..\Assets - if (String.IsNullOrEmpty(logoNamePrefix) || !Directory.Exists(logoDir)) - { - // Known issue: Edge always triggers it since logo is not at uri - ProgramLogger.LogException($"|UWP|LogoPathFromUri|{Location}" + - $"|{UserModelId} can't find logo uri for {uri} in package location (logo name or directory not found): {Location}", new FileNotFoundException()); - return string.Empty; - } - - var logos = Directory.EnumerateFiles(logoDir, $"{logoNamePrefix}*{extension}"); - - // Currently we don't care which one to choose - // Just ignore all qualifiers - // select like logo.[xxx_yyy].png - // https://learn.microsoft.com/en-us/windows/uwp/app-resources/tailor-resources-lang-scale-contrast - - // todo select from file name like pt run - var selected = logos.FirstOrDefault(); - var closest = selected; - int min = int.MaxValue; - foreach (var logo in logos) - { - - var imageStream = File.OpenRead(logo); - var decoder = BitmapDecoder.Create(imageStream, BitmapCreateOptions.IgnoreColorProfile, BitmapCacheOption.None); - var height = decoder.Frames[0].PixelHeight; - var width = decoder.Frames[0].PixelWidth; - int pixelCountDiff = Math.Abs(height * width - px); - if (pixelCountDiff < min) - { - // try to find the closest to desired size - closest = logo; - if (pixelCountDiff == 0) - break; // found - min = pixelCountDiff; - } - } - - selected = closest; - if (!string.IsNullOrEmpty(selected)) - { - return selected; - } - else - { - ProgramLogger.LogException($"|UWP|LogoPathFromUri|{Location}" + - $"|{UserModelId} can't find logo uri for {uri} in package location (can't find specified logo): {Location}", new FileNotFoundException()); - return string.Empty; - } - } - else - { - ProgramLogger.LogException($"|UWP|LogoPathFromUri|{Location}" + - $"|Unable to find extension from {uri} for {UserModelId} " + - $"in package location {Location}", new FileNotFoundException()); - return string.Empty; - } - } - } - - - #region logo legacy - // preserve for potential future use - - //public ImageSource Logo() - //{ - // var logo = ImageFromPath(LogoPath); - // var plated = PlatedImage(logo); // TODO: maybe get plated directly from app package? - - // // todo magic! temp fix for cross thread object - // plated.Freeze(); - // return plated; - //} - //private BitmapImage ImageFromPath(string path) - //{ - // if (File.Exists(path)) - // { - // var image = new BitmapImage(); - // image.BeginInit(); - // image.UriSource = new Uri(path); - // image.CacheOption = BitmapCacheOption.OnLoad; - // image.EndInit(); - // image.Freeze(); - // return image; - // } - // else - // { - // ProgramLogger.LogException($"|UWP|ImageFromPath|{(string.IsNullOrEmpty(path) ? "Not Available" : path)}" + - // $"|Unable to get logo for {UserModelId} from {path} and" + - // $" located in {Location}", new FileNotFoundException()); - // return new BitmapImage(new Uri(Constant.MissingImgIcon)); - // } - //} - - //private ImageSource PlatedImage(BitmapImage image) - //{ - // if (!string.IsNullOrEmpty(BackgroundColor) && BackgroundColor != "transparent") - // { - // var width = image.Width; - // var height = image.Height; - // var x = 0; - // var y = 0; - - // var group = new DrawingGroup(); - - // var converted = ColorConverter.ConvertFromString(BackgroundColor); - // if (converted != null) - // { - // var color = (Color)converted; - // var brush = new SolidColorBrush(color); - // var pen = new Pen(brush, 1); - // var backgroundArea = new Rect(0, 0, width, width); - // var rectangle = new RectangleGeometry(backgroundArea); - // var rectDrawing = new GeometryDrawing(brush, pen, rectangle); - // group.Children.Add(rectDrawing); - - // var imageArea = new Rect(x, y, image.Width, image.Height); - // var imageDrawing = new ImageDrawing(image, imageArea); - // group.Children.Add(imageDrawing); - - // // http://stackoverflow.com/questions/6676072/get-system-drawing-bitmap-of-a-wpf-area-using-visualbrush - // var visual = new DrawingVisual(); - // var context = visual.RenderOpen(); - // context.DrawDrawing(group); - // context.Close(); - // const int dpiScale100 = 96; - // var bitmap = new RenderTargetBitmap( - // Convert.ToInt32(width), Convert.ToInt32(height), - // dpiScale100, dpiScale100, - // PixelFormats.Pbgra32 - // ); - // bitmap.Render(visual); - // return bitmap; - // } - // else - // { - // ProgramLogger.LogException($"|UWP|PlatedImage|{Location}" + - // $"|Unable to convert background string {BackgroundColor} " + - // $"to color for {Location}", new InvalidOperationException()); - - // return new BitmapImage(new Uri(Constant.MissingImgIcon)); - // } - // } - // else - // { - // // todo use windows theme as background - // return image; - // } - //} - - #endregion - public override string ToString() - { - return $"{DisplayName}: {Description}"; - } - - public override bool Equals(object obj) - { - if (obj is Application other) - { - return UniqueIdentifier == other.UniqueIdentifier; - } - else - { - return false; - } - } - - public override int GetHashCode() - { - return UniqueIdentifier.GetHashCode(); - } - } - - public enum PackageVersion - { - Windows10, - Windows81, - Windows8, - Unknown - } - } -} diff --git a/Plugins/Flow.Launcher.Plugin.Program/Programs/UWPPackage.cs b/Plugins/Flow.Launcher.Plugin.Program/Programs/UWPPackage.cs new file mode 100644 index 000000000..654897cc5 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.Program/Programs/UWPPackage.cs @@ -0,0 +1,752 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Security.Principal; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using Windows.ApplicationModel; +using Windows.Management.Deployment; +using Flow.Launcher.Infrastructure; +using Flow.Launcher.Plugin.Program.Logger; +using Flow.Launcher.Plugin.SharedModels; +using System.Threading.Channels; +using System.Xml; +using Windows.ApplicationModel.Core; +using System.Windows.Input; +using MemoryPack; + +namespace Flow.Launcher.Plugin.Program.Programs +{ + [MemoryPackable] + public partial class UWPPackage + { + public string Name { get; } + public string FullName { get; } + public string FamilyName { get; } + public string Location { get; set; } + + public UWPApp[] Apps { get; set; } = Array.Empty(); + + + /// + /// For serialization + /// + [MemoryPackConstructor] + private UWPPackage() + { + } + + public UWPPackage(Package package) + { + Location = package.InstalledLocation.Path; + Name = package.Id.Name; + FullName = package.Id.FullName; + FamilyName = package.Id.FamilyName; + } + + public void InitAppsInPackage(Package package) + { + var apps = new List(); + // WinRT + var appListEntries = package.GetAppListEntries(); + foreach (var app in appListEntries) + { + try + { + var tmp = new UWPApp(app, this); + apps.Add(tmp); + } + catch (Exception e) + { + ProgramLogger.LogException($"|UWP|InitAppsInPackage|{Location}" + + "|Unexpected exception occurs when trying to construct a Application from package" + + $"{FullName} from location {Location}", e); + } + } + + Apps = apps.ToArray(); + + try + { + var xmlDoc = GetManifestXml(); + if (xmlDoc == null) + { + return; + } + + var xmlRoot = xmlDoc.DocumentElement; + var packageVersion = GetPackageVersionFromManifest(xmlRoot); + if (!smallLogoNameFromVersion.TryGetValue(packageVersion, out string logoName) || + !bigLogoNameFromVersion.TryGetValue(packageVersion, out string bigLogoName)) + { + return; + } + + var namespaceManager = new XmlNamespaceManager(xmlDoc.NameTable); + namespaceManager.AddNamespace("d", + "http://schemas.microsoft.com/appx/manifest/foundation/windows10"); // still need a name + namespaceManager.AddNamespace("rescap", + "http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"); + namespaceManager.AddNamespace("uap10", "http://schemas.microsoft.com/appx/manifest/uap/windows10/10"); + + var allowElevationNode = + xmlRoot.SelectSingleNode("//rescap:Capability[@Name='allowElevation']", namespaceManager); + bool packageCanElevate = allowElevationNode != null; + + var appsNode = xmlRoot.SelectSingleNode("d:Applications", namespaceManager); + foreach (var app in Apps) + { + // According to https://learn.microsoft.com/windows/apps/desktop/modernize/grant-identity-to-nonpackaged-apps#create-a-package-manifest-for-the-sparse-package + // and https://learn.microsoft.com/uwp/schemas/appxpackage/uapmanifestschema/element-application#attributes + var id = app.UserModelId.Split('!')[1]; + var appNode = appsNode?.SelectSingleNode($"d:Application[@Id='{id}']", namespaceManager); + if (appNode != null) + { + app.CanRunElevated = packageCanElevate || UWPApp.IfAppCanRunElevated(appNode); + + // local name to fit all versions + var visualElement = + appNode.SelectSingleNode($"*[local-name()='VisualElements']", namespaceManager); + var logoUri = visualElement?.Attributes[logoName]?.Value; + app.LogoPath = app.LogoPathFromUri(logoUri, (64, 64)); + // use small logo or may have a big margin + var previewUri = visualElement?.Attributes[logoName]?.Value; + app.PreviewImagePath = app.LogoPathFromUri(previewUri, (256, 256)); + } + } + } + catch (Exception e) + { + ProgramLogger.LogException($"|UWP|InitAppsInPackage|{Location}" + + "|Unexpected exception occurs when trying to construct a Application from package" + + $"{FullName} from location {Location}", e); + } + } + + private XmlDocument GetManifestXml() + { + var manifest = Path.Combine(Location, "AppxManifest.xml"); + try + { + var file = File.ReadAllText(manifest); + var xmlDoc = new XmlDocument(); + xmlDoc.LoadXml(file); + return xmlDoc; + } + catch (FileNotFoundException e) + { + ProgramLogger.LogException("UWP", "GetManifestXml", $"{Location}", "AppxManifest.xml not found.", e); + return null; + } + catch (Exception e) + { + ProgramLogger.LogException("UWP", "GetManifestXml", $"{Location}", + "An unexpected error occurred and unable to parse AppxManifest.xml", e); + return null; + } + } + + private PackageVersion GetPackageVersionFromManifest(XmlNode xmlRoot) + { + if (xmlRoot != null) + { + var namespaces = xmlRoot.Attributes; + foreach (XmlAttribute ns in namespaces) + { + if (versionFromNamespace.TryGetValue(ns.Value, out var packageVersion)) + { + return packageVersion; + } + } + + ProgramLogger.LogException($"|UWP|GetPackageVersionFromManifest|{Location}" + + "|Trying to get the package version of the UWP program, but an unknown UWP app-manifest version in package " + + $"{FullName} from location {Location}", new FormatException()); + return PackageVersion.Unknown; + } + else + { + ProgramLogger.LogException($"|UWP|GetPackageVersionFromManifest|{Location}" + + "|Can't parse AppManifest.xml of package " + + $"{FullName} from location {Location}", + new ArgumentNullException(nameof(xmlRoot))); + return PackageVersion.Unknown; + } + } + + private static readonly Dictionary versionFromNamespace = new() + { + { "http://schemas.microsoft.com/appx/manifest/foundation/windows10", PackageVersion.Windows10 }, + { "http://schemas.microsoft.com/appx/2013/manifest", PackageVersion.Windows81 }, + { "http://schemas.microsoft.com/appx/2010/manifest", PackageVersion.Windows8 }, + }; + + private static readonly Dictionary smallLogoNameFromVersion = new() + { + { PackageVersion.Windows10, "Square44x44Logo" }, + { PackageVersion.Windows81, "Square30x30Logo" }, + { PackageVersion.Windows8, "SmallLogo" }, + }; + + private static readonly Dictionary bigLogoNameFromVersion = new() + { + { PackageVersion.Windows10, "Square150x150Logo" }, + { PackageVersion.Windows81, "Square150x150Logo" }, + { PackageVersion.Windows8, "Logo" }, + }; + + public static UWPApp[] All(Settings settings) + { + var support = SupportUWP(); + if (support && settings.EnableUWP) + { + var applications = CurrentUserPackages().AsParallel().SelectMany(p => + { + UWPPackage u; + try + { + u = new UWPPackage(p); + u.InitAppsInPackage(p); + } +#if !DEBUG + catch (Exception e) + { + ProgramLogger.LogException($"|UWP|All|{p.InstalledLocation}|An unexpected error occurred and unable to convert Package to UWP for {p.Id.FullName}", e); + return Array.Empty(); + } +#endif +#if DEBUG //make developer aware and implement handling + catch + { + throw; + } +#endif + return u.Apps; + }).ToArray(); + + var updatedListWithoutDisabledApps = applications + .Where(t1 => !Main._settings.DisabledProgramSources + .Any(x => x.UniqueIdentifier == t1.UniqueIdentifier)); + + return updatedListWithoutDisabledApps.ToArray(); + } + else + { + return Array.Empty(); + } + } + + public static bool SupportUWP() + { + var windows10 = new Version(10, 0); + var support = Environment.OSVersion.Version.Major >= windows10.Major; + return support; + } + + private static IEnumerable CurrentUserPackages() + { + var user = WindowsIdentity.GetCurrent().User; + + if (user != null) + { + var userId = user.Value; + PackageManager packageManager; + try + { + packageManager = new PackageManager(); + } + catch + { + // Bug from https://github.com/microsoft/CsWinRT, using Microsoft.Windows.SDK.NET.Ref 10.0.19041.0. + // Only happens on the first time, so a try catch can fix it. + packageManager = new PackageManager(); + } + + var packages = packageManager.FindPackagesForUser(userId); + packages = packages.Where(p => + { + try + { + var f = p.IsFramework; + var d = p.IsDevelopmentMode; + var path = p.InstalledLocation.Path; + return !f && !d && !string.IsNullOrEmpty(path); + } + catch (Exception e) + { + ProgramLogger.LogException("UWP", "CurrentUserPackages", $"{p.Id.FullName}", + "An unexpected error occurred and " + + $"unable to verify if package is valid", e); + return false; + } + }); + return packages; + } + else + { + return Array.Empty(); + } + } + + private static Channel PackageChangeChannel = Channel.CreateBounded(1); + + public static async Task WatchPackageChange() + { + if (Environment.OSVersion.Version.Major >= 10) + { + var catalog = PackageCatalog.OpenForCurrentUser(); + catalog.PackageInstalling += (_, args) => + { + if (args.IsComplete) + PackageChangeChannel.Writer.TryWrite(default); + }; + catalog.PackageUninstalling += (_, args) => + { + if (args.IsComplete) + PackageChangeChannel.Writer.TryWrite(default); + }; + catalog.PackageUpdating += (_, args) => + { + if (args.IsComplete) + PackageChangeChannel.Writer.TryWrite(default); + }; + + while (await PackageChangeChannel.Reader.WaitToReadAsync().ConfigureAwait(false)) + { + await Task.Delay(3000).ConfigureAwait(false); + PackageChangeChannel.Reader.TryRead(out _); + await Task.Run(Main.IndexUwpPrograms); + } + } + } + + public override string ToString() + { + return FamilyName; + } + + public override bool Equals(object obj) + { + if (obj is UWPPackage uwp) + { + return FamilyName.Equals(uwp.FamilyName); + } + else + { + return false; + } + } + + public override int GetHashCode() + { + return FamilyName.GetHashCode(); + } + + + public enum PackageVersion + { + Windows10, + Windows81, + Windows8, + Unknown + } + } + + [MemoryPackable] + public partial class UWPApp : IProgram + { + private string _uid = string.Empty; + + public string UniqueIdentifier + { + get => _uid; + set => _uid = value == null ? string.Empty : value.ToLowerInvariant(); + } + + public string DisplayName { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + + public string UserModelId { get; set; } = string.Empty; + + //public string BackgroundColor { get; set; } = string.Empty; // preserve for future use + public string Name => DisplayName; + public string Location { get; set; } = string.Empty; + + public bool Enabled { get; set; } = false; + public bool CanRunElevated { get; set; } = false; + public string LogoPath { get; set; } = string.Empty; + public string PreviewImagePath { get; set; } = string.Empty; + + [MemoryPackConstructor] + private UWPApp() + { + } + + public UWPApp(AppListEntry appListEntry, UWPPackage package) + { + UserModelId = appListEntry.AppUserModelId; + UniqueIdentifier = appListEntry.AppUserModelId; + DisplayName = appListEntry.DisplayInfo.DisplayName; + Description = appListEntry.DisplayInfo.Description; + Location = package.Location; + Enabled = true; + } + + public Result Result(string query, IPublicAPI api) + { + string title; + MatchResult matchResult; + + // We suppose Name won't be null + if (!Main._settings.EnableDescription || string.IsNullOrWhiteSpace(Description) || Name.Equals(Description)) + { + title = Name; + matchResult = StringMatcher.FuzzySearch(query, Name); + } + else + { + title = $"{Name}: {Description}"; + var nameMatch = StringMatcher.FuzzySearch(query, Name); + var descriptionMatch = StringMatcher.FuzzySearch(query, Description); + if (descriptionMatch.Score > nameMatch.Score) + { + for (int i = 0; i < descriptionMatch.MatchData.Count; i++) + { + descriptionMatch.MatchData[i] += Name.Length + 2; // 2 is ": " + } + + matchResult = descriptionMatch; + } + else + { + matchResult = nameMatch; + } + } + + if (!matchResult.IsSearchPrecisionScoreMet()) + return null; + + var result = new Result + { + Title = title, + AutoCompleteText = Name, + SubTitle = Main._settings.HideAppsPath ? string.Empty : Location, + IcoPath = LogoPath, + Preview = new Result.PreviewInfo + { + IsMedia = false, PreviewImagePath = PreviewImagePath, Description = Description + }, + Score = matchResult.Score, + TitleHighlightData = matchResult.MatchData, + ContextData = this, + Action = e => + { + // Ctrl + Enter to open containing folder + bool openFolder = e.SpecialKeyState.ToModifierKeys() == ModifierKeys.Control; + if (openFolder) + { + Main.Context.API.OpenDirectory(Location); + return true; + } + + // Ctrl + Shift + Enter to run elevated + bool elevated = e.SpecialKeyState.ToModifierKeys() == (ModifierKeys.Control | ModifierKeys.Shift); + + bool shouldRunElevated = elevated && CanRunElevated; + _ = Task.Run(() => Launch(shouldRunElevated)).ConfigureAwait(false); + if (elevated && !shouldRunElevated) + { + var title = api.GetTranslation("flowlauncher_plugin_program_disable_dlgtitle_error"); + var message = + api.GetTranslation( + "flowlauncher_plugin_program_run_as_administrator_not_supported_message"); + api.ShowMsg(title, message, string.Empty); + } + + return true; + } + }; + + + return result; + } + + public List ContextMenus(IPublicAPI api) + { + var contextMenus = new List + { + new Result + { + Title = api.GetTranslation("flowlauncher_plugin_program_open_containing_folder"), + Action = _ => + { + Main.Context.API.OpenDirectory(Location); + + return true; + }, + IcoPath = "Images/folder.png", + Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\xe838"), + } + }; + + if (CanRunElevated) + { + contextMenus.Add(new Result + { + Title = api.GetTranslation("flowlauncher_plugin_program_run_as_administrator"), + Action = _ => + { + Task.Run(() => Launch(true)).ConfigureAwait(false); + return true; + }, + IcoPath = "Images/cmd.png", + Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\xe7ef") + }); + } + + return contextMenus; + } + + private void Launch(bool elevated = false) + { + string command = "shell:AppsFolder\\" + UserModelId; + command = Environment.ExpandEnvironmentVariables(command.Trim()); + + var info = new ProcessStartInfo(command) { UseShellExecute = true, Verb = elevated ? "runas" : "" }; + + Main.StartProcess(Process.Start, info); + } + + internal static bool IfAppCanRunElevated(XmlNode appNode) + { + // According to https://learn.microsoft.com/windows/apps/desktop/modernize/grant-identity-to-nonpackaged-apps#create-a-package-manifest-for-the-sparse-package + // and https://learn.microsoft.com/uwp/schemas/appxpackage/uapmanifestschema/element-application#attributes + + return appNode?.Attributes["EntryPoint"]?.Value == "Windows.FullTrustApplication" || + appNode?.Attributes["uap10:TrustLevel"]?.Value == "mediumIL"; + } + + internal string LogoPathFromUri(string uri, (int, int) desiredSize) + { + // all https://msdn.microsoft.com/windows/uwp/controls-and-patterns/tiles-and-notifications-app-assets + // windows 10 https://msdn.microsoft.com/en-us/library/windows/apps/dn934817.aspx + // windows 8.1 https://msdn.microsoft.com/en-us/library/windows/apps/hh965372.aspx#target_size + // windows 8 https://msdn.microsoft.com/en-us/library/windows/apps/br211475.aspx + + if (string.IsNullOrWhiteSpace(uri)) + { + ProgramLogger.LogException($"|UWP|LogoPathFromUri|{Location}" + + $"|{UserModelId} 's logo uri is null or empty: {Location}", + new ArgumentException("uri")); + return string.Empty; + } + + string path = Path.Combine(Location, uri); + + var pxCount = desiredSize.Item1 * desiredSize.Item2; + var logoPath = TryToFindLogo(uri, path, pxCount); + if (logoPath == string.Empty) + { + var tmp = Path.Combine(Location, "Assets", uri); + if (!path.Equals(tmp, StringComparison.OrdinalIgnoreCase)) + { + // TODO: Don't know why, just keep it at the moment + // Maybe on older version of Windows 10? + // for C:\Windows\MiracastView etc + return TryToFindLogo(uri, tmp, pxCount); + } + } + + return logoPath; + + string TryToFindLogo(string uri, string path, int px) + { + var extension = Path.GetExtension(path); + if (extension != null) + { + //if (File.Exists(path)) + //{ + // return path; // shortcut, avoid enumerating files + //} + + var logoNamePrefix = Path.GetFileNameWithoutExtension(uri); // e.g Square44x44 + var logoDir = Path.GetDirectoryName(path); // e.g ..\..\Assets + if (String.IsNullOrEmpty(logoNamePrefix) || !Directory.Exists(logoDir)) + { + // Known issue: Edge always triggers it since logo is not at uri + ProgramLogger.LogException($"|UWP|LogoPathFromUri|{Location}" + + $"|{UserModelId} can't find logo uri for {uri} in package location (logo name or directory not found): {Location}", + new FileNotFoundException()); + return string.Empty; + } + + var logos = Directory.EnumerateFiles(logoDir, $"{logoNamePrefix}*{extension}"); + + // Currently we don't care which one to choose + // Just ignore all qualifiers + // select like logo.[xxx_yyy].png + // https://learn.microsoft.com/en-us/windows/uwp/app-resources/tailor-resources-lang-scale-contrast + + // todo select from file name like pt run + var selected = logos.FirstOrDefault(); + var closest = selected; + int min = int.MaxValue; + foreach (var logo in logos) + { + var imageStream = File.OpenRead(logo); + var decoder = BitmapDecoder.Create(imageStream, BitmapCreateOptions.IgnoreColorProfile, + BitmapCacheOption.None); + var height = decoder.Frames[0].PixelHeight; + var width = decoder.Frames[0].PixelWidth; + int pixelCountDiff = Math.Abs(height * width - px); + if (pixelCountDiff < min) + { + // try to find the closest to desired size + closest = logo; + if (pixelCountDiff == 0) + break; // found + min = pixelCountDiff; + } + } + + selected = closest; + if (!string.IsNullOrEmpty(selected)) + { + return selected; + } + else + { + ProgramLogger.LogException($"|UWP|LogoPathFromUri|{Location}" + + $"|{UserModelId} can't find logo uri for {uri} in package location (can't find specified logo): {Location}", + new FileNotFoundException()); + return string.Empty; + } + } + else + { + ProgramLogger.LogException($"|UWP|LogoPathFromUri|{Location}" + + $"|Unable to find extension from {uri} for {UserModelId} " + + $"in package location {Location}", new FileNotFoundException()); + return string.Empty; + } + } + } + + + #region logo legacy + + // preserve for potential future use + + //public ImageSource Logo() + //{ + // var logo = ImageFromPath(LogoPath); + // var plated = PlatedImage(logo); // TODO: maybe get plated directly from app package? + + // // todo magic! temp fix for cross thread object + // plated.Freeze(); + // return plated; + //} + //private BitmapImage ImageFromPath(string path) + //{ + // if (File.Exists(path)) + // { + // var image = new BitmapImage(); + // image.BeginInit(); + // image.UriSource = new Uri(path); + // image.CacheOption = BitmapCacheOption.OnLoad; + // image.EndInit(); + // image.Freeze(); + // return image; + // } + // else + // { + // ProgramLogger.LogException($"|UWP|ImageFromPath|{(string.IsNullOrEmpty(path) ? "Not Available" : path)}" + + // $"|Unable to get logo for {UserModelId} from {path} and" + + // $" located in {Location}", new FileNotFoundException()); + // return new BitmapImage(new Uri(Constant.MissingImgIcon)); + // } + //} + + //private ImageSource PlatedImage(BitmapImage image) + //{ + // if (!string.IsNullOrEmpty(BackgroundColor) && BackgroundColor != "transparent") + // { + // var width = image.Width; + // var height = image.Height; + // var x = 0; + // var y = 0; + + // var group = new DrawingGroup(); + + // var converted = ColorConverter.ConvertFromString(BackgroundColor); + // if (converted != null) + // { + // var color = (Color)converted; + // var brush = new SolidColorBrush(color); + // var pen = new Pen(brush, 1); + // var backgroundArea = new Rect(0, 0, width, width); + // var rectangle = new RectangleGeometry(backgroundArea); + // var rectDrawing = new GeometryDrawing(brush, pen, rectangle); + // group.Children.Add(rectDrawing); + + // var imageArea = new Rect(x, y, image.Width, image.Height); + // var imageDrawing = new ImageDrawing(image, imageArea); + // group.Children.Add(imageDrawing); + + // // http://stackoverflow.com/questions/6676072/get-system-drawing-bitmap-of-a-wpf-area-using-visualbrush + // var visual = new DrawingVisual(); + // var context = visual.RenderOpen(); + // context.DrawDrawing(group); + // context.Close(); + // const int dpiScale100 = 96; + // var bitmap = new RenderTargetBitmap( + // Convert.ToInt32(width), Convert.ToInt32(height), + // dpiScale100, dpiScale100, + // PixelFormats.Pbgra32 + // ); + // bitmap.Render(visual); + // return bitmap; + // } + // else + // { + // ProgramLogger.LogException($"|UWP|PlatedImage|{Location}" + + // $"|Unable to convert background string {BackgroundColor} " + + // $"to color for {Location}", new InvalidOperationException()); + + // return new BitmapImage(new Uri(Constant.MissingImgIcon)); + // } + // } + // else + // { + // // todo use windows theme as background + // return image; + // } + //} + + #endregion + + public override string ToString() + { + return $"{DisplayName}: {Description}"; + } + + public override bool Equals(object obj) + { + if (obj is UWPApp other) + { + return UniqueIdentifier == other.UniqueIdentifier; + } + else + { + return false; + } + } + + public override int GetHashCode() + { + return UniqueIdentifier.GetHashCode(); + } + } +} diff --git a/Plugins/Flow.Launcher.Plugin.Program/Programs/Win32.cs b/Plugins/Flow.Launcher.Plugin.Program/Programs/Win32.cs index 20f489c30..7d08d3670 100644 --- a/Plugins/Flow.Launcher.Plugin.Program/Programs/Win32.cs +++ b/Plugins/Flow.Launcher.Plugin.Program/Programs/Win32.cs @@ -16,14 +16,21 @@ using System.Threading.Channels; using Flow.Launcher.Plugin.Program.Views.Models; using IniParser; using System.Windows.Input; +using MemoryPack; namespace Flow.Launcher.Plugin.Program.Programs { - [Serializable] - public class Win32 : IProgram, IEquatable + [MemoryPackable] + public partial class Win32 : IProgram, IEquatable { public string Name { get; set; } - public string UniqueIdentifier { get => _uid; set => _uid = value == null ? string.Empty : value.ToLowerInvariant(); } // For path comparison + + public string UniqueIdentifier + { + get => _uid; + set => _uid = value == null ? string.Empty : value.ToLowerInvariant(); + } // For path comparison + public string IcoPath { get; set; } /// @@ -96,7 +103,8 @@ namespace Flow.Launcher.Plugin.Program.Programs bool useLocalizedName = !string.IsNullOrEmpty(LocalizedName) && !Name.Equals(LocalizedName); string resultName = useLocalizedName ? LocalizedName : Name; - if (!Main._settings.EnableDescription || string.IsNullOrWhiteSpace(Description) || resultName.Equals(Description)) + if (!Main._settings.EnableDescription || string.IsNullOrWhiteSpace(Description) || + resultName.Equals(Description)) { title = resultName; matchResult = StringMatcher.FuzzySearch(query, resultName); @@ -113,6 +121,7 @@ namespace Flow.Launcher.Plugin.Program.Programs { descriptionMatch.MatchData[i] += resultName.Length + 2; // 2 is ": " } + matchResult = descriptionMatch; } else @@ -129,10 +138,12 @@ namespace Flow.Launcher.Plugin.Program.Programs { candidates.Add(ExecutableName); } + if (useLocalizedName) { candidates.Add(Name); } + matchResult = Match(query, candidates); if (matchResult == null) { @@ -209,9 +220,7 @@ namespace Flow.Launcher.Plugin.Program.Programs { var info = new ProcessStartInfo { - FileName = FullPath, - WorkingDirectory = ParentDirectory, - UseShellExecute = true + FileName = FullPath, WorkingDirectory = ParentDirectory, UseShellExecute = true }; Task.Run(() => Main.StartProcess(ShellCommand.RunAsDifferentUser, info)); @@ -424,7 +433,8 @@ namespace Flow.Launcher.Plugin.Program.Programs } } - private static IEnumerable EnumerateProgramsInDir(string directory, string[] suffixes, bool recursive = true) + private static IEnumerable EnumerateProgramsInDir(string directory, string[] suffixes, + bool recursive = true) { if (!Directory.Exists(directory)) return Enumerable.Empty(); @@ -448,7 +458,8 @@ namespace Flow.Launcher.Plugin.Program.Programs } } - private static IEnumerable UnregisteredPrograms(List directories, string[] suffixes, string[] protocols) + private static IEnumerable UnregisteredPrograms(List directories, string[] suffixes, + string[] protocols) { // Disabled custom sources are not in DisabledProgramSources var paths = directories.AsParallel() @@ -466,14 +477,15 @@ namespace Flow.Launcher.Plugin.Program.Programs .Distinct(); var startupPaths = GetStartupPaths(); - + var programs = ExceptDisabledSource(allPrograms) .Where(x => !startupPaths.Any(startup => FilesFolders.PathContains(startup, x))) .Select(x => GetProgramFromPath(x, protocols)); return programs; } - private static IEnumerable PATHPrograms(string[] suffixes, string[] protocols, List commonParents) + private static IEnumerable PATHPrograms(string[] suffixes, string[] protocols, + List commonParents) { var pathEnv = Environment.GetEnvironmentVariable("Path"); if (String.IsNullOrEmpty(pathEnv)) @@ -515,7 +527,8 @@ namespace Flow.Launcher.Plugin.Program.Programs toFilter = toFilter.Distinct().Where(p => suffixes.Contains(Extension(p))); var programs = ExceptDisabledSource(toFilter) - .Select(x => GetProgramFromPath(x, protocols)).Where(x => x.Valid).ToList(); // ToList due to disposing issue + .Select(x => GetProgramFromPath(x, protocols)).Where(x => x.Valid) + .ToList(); // ToList due to disposing issue return programs; } @@ -616,7 +629,10 @@ namespace Flow.Launcher.Plugin.Program.Programs .SelectMany(g => { // is shortcut and in start menu - var startMenu = g.Where(g => g.LnkResolvedPath != null && startMenuPaths.Any(x => FilesFolders.PathContains(x, g.FullPath))).ToList(); + var startMenu = g.Where(g => + g.LnkResolvedPath != null && + startMenuPaths.Any(x => FilesFolders.PathContains(x, g.FullPath))) + .ToList(); if (startMenu.Any()) return startMenu.Take(1); @@ -756,6 +772,7 @@ namespace Flow.Launcher.Plugin.Program.Programs while (reader.TryRead(out _)) { } + await Task.Run(Main.IndexWin32Programs); } } @@ -766,6 +783,7 @@ namespace Flow.Launcher.Plugin.Program.Programs { throw new ArgumentException("Path Not Exist"); } + var watcher = new FileSystemWatcher(directory); watcher.Created += static (_, _) => indexQueue.Writer.TryWrite(default); @@ -804,8 +822,10 @@ namespace Flow.Launcher.Plugin.Program.Programs parents.Remove(source); } } + result.AddRange(parents.Select(x => x.Location)); } + return result.DistinctBy(x => x.ToLowerInvariant()).ToList(); } } diff --git a/Plugins/Flow.Launcher.Plugin.Program/Views/ProgramSetting.xaml.cs b/Plugins/Flow.Launcher.Plugin.Program/Views/ProgramSetting.xaml.cs index 156f33ebc..9879993fe 100644 --- a/Plugins/Flow.Launcher.Plugin.Program/Views/ProgramSetting.xaml.cs +++ b/Plugins/Flow.Launcher.Plugin.Program/Views/ProgramSetting.xaml.cs @@ -87,9 +87,9 @@ namespace Flow.Launcher.Plugin.Program.Views } } - public bool ShowUWPCheckbox => UWP.SupportUWP(); + public bool ShowUWPCheckbox => UWPPackage.SupportUWP(); - public ProgramSetting(PluginInitContext context, Settings settings, Win32[] win32s, UWP.Application[] uwps) + public ProgramSetting(PluginInitContext context, Settings settings, Win32[] win32s, UWPApp[] uwps) { this.context = context; _settings = settings; @@ -149,9 +149,9 @@ namespace Flow.Launcher.Plugin.Program.Views private void DeleteProgramSources(List itemsToDelete) { itemsToDelete.ForEach(t1 => _settings.ProgramSources - .Remove(_settings.ProgramSources - .Where(x => x.UniqueIdentifier == t1.UniqueIdentifier) - .FirstOrDefault())); + .Remove(_settings.ProgramSources + .Where(x => x.UniqueIdentifier == t1.UniqueIdentifier) + .FirstOrDefault())); itemsToDelete.ForEach(x => ProgramSettingDisplayList.Remove(x)); ReIndexing(); @@ -182,16 +182,20 @@ namespace Flow.Launcher.Plugin.Program.Views { if (selectedProgramSource.Enabled) { - ProgramSettingDisplay.SetProgramSourcesStatus(new List { selectedProgramSource }, true); // sync status in win32, uwp and disabled + ProgramSettingDisplay.SetProgramSourcesStatus(new List { selectedProgramSource }, + true); // sync status in win32, uwp and disabled ProgramSettingDisplay.RemoveDisabledFromSettings(); } else { - ProgramSettingDisplay.SetProgramSourcesStatus(new List { selectedProgramSource }, false); + ProgramSettingDisplay.SetProgramSourcesStatus(new List { selectedProgramSource }, + false); ProgramSettingDisplay.StoreDisabledInSettings(); } + ReIndexing(); } + programSourceView.SelectedIndex = selectedIndex; } } @@ -233,7 +237,8 @@ namespace Flow.Launcher.Plugin.Program.Views foreach (string directory in directories) { if (Directory.Exists(directory) - && !ProgramSettingDisplayList.Any(x => x.UniqueIdentifier.Equals(directory, System.StringComparison.OrdinalIgnoreCase))) + && !ProgramSettingDisplayList.Any(x => + x.UniqueIdentifier.Equals(directory, System.StringComparison.OrdinalIgnoreCase))) { var source = new ProgramSource(directory); @@ -262,8 +267,8 @@ namespace Flow.Launcher.Plugin.Program.Views private void btnProgramSourceStatus_OnClick(object sender, RoutedEventArgs e) { var selectedItems = programSourceView - .SelectedItems.Cast() - .ToList(); + .SelectedItems.Cast() + .ToList(); if (selectedItems.Count == 0) { @@ -274,7 +279,8 @@ namespace Flow.Launcher.Plugin.Program.Views if (IsAllItemsUserAdded(selectedItems)) { - var msg = string.Format(context.API.GetTranslation("flowlauncher_plugin_program_delete_program_source")); + var msg = string.Format( + context.API.GetTranslation("flowlauncher_plugin_program_delete_program_source")); if (MessageBox.Show(msg, string.Empty, MessageBoxButton.YesNo) == MessageBoxResult.No) { @@ -364,8 +370,8 @@ namespace Flow.Launcher.Plugin.Program.Views private void programSourceView_SelectionChanged(object sender, SelectionChangedEventArgs e) { var selectedItems = programSourceView - .SelectedItems.Cast() - .ToList(); + .SelectedItems.Cast() + .ToList(); if (IsAllItemsUserAdded(selectedItems)) { @@ -400,7 +406,8 @@ namespace Flow.Launcher.Plugin.Program.Views ListView listView = sender as ListView; GridView gView = listView.View as GridView; - var workingWidth = listView.ActualWidth - SystemParameters.VerticalScrollBarWidth; // take into account vertical scrollbar + var workingWidth = + listView.ActualWidth - SystemParameters.VerticalScrollBarWidth; // take into account vertical scrollbar var col1 = 0.25; var col2 = 0.15; var col3 = 0.60;