From de6db9d3c01dee23743ccd833a6e4e91b56930b3 Mon Sep 17 00:00:00 2001 From: dcog989 Date: Thu, 25 Sep 2025 04:08:04 +0100 Subject: [PATCH 01/33] Complete rewrite of plugin, initial commit - Service-oriented architecture - Asynchronous Operations - Cleaner file watching - MVVM / ViewModel - Browser engine auto-detect - New favicon fetch if missing, background thread, efficient fetching - Firefox now has periodic check on `places.sqlite` rather than constant watch --- .../ChromeBookmarkLoader.cs | 24 - .../ChromiumBookmarkLoader.cs | 205 ----- .../Commands/BookmarkLoader.cs | 57 -- .../CustomChromiumBookmarkLoader.cs | 18 - .../CustomFirefoxBookmarkLoader.cs | 26 - .../EdgeBookmarkLoader.cs | 22 - .../FirefoxBookmarkLoader.cs | 341 -------- ...low.Launcher.Plugin.BrowserBookmark.csproj | 32 +- .../Flow.Launcher.Plugin.BrowserBookmark.sln | 24 + .../Helper/FaviconHelper.cs | 125 --- .../IBookmarkLoader.cs | 9 - .../Languages/en.xaml | 40 +- .../Main.cs | 358 ++++---- .../Models/BaseModel.cs | 16 + .../Models/Bookmark.cs | 15 +- .../Models/CustomBrowser.cs | 13 +- .../Models/Settings.cs | 51 +- .../Services/BookmarkLoaderService.cs | 156 ++++ .../Services/BookmarkWatcherService.cs | 85 ++ .../Services/BrowserDetector.cs | 39 + .../Services/ChromiumBookmarkLoader.cs | 116 +++ .../Services/FaviconService.cs | 426 +++++++++ .../Services/FirefoxBookmarkLoader.cs | 106 +++ .../Services/FirefoxProfileFinder.cs | 83 ++ .../Services/IBookmarkLoader.cs | 11 + .../Services/LocalFaviconExtractor.cs | 136 +++ .../CustomBrowserSettingViewModel.cs | 101 +++ .../Views/CustomBrowserSetting.xaml | 67 +- .../Views/CustomBrowserSetting.xaml.cs | 59 +- .../Views/SettingsControl.xaml | 48 +- .../Views/SettingsControl.xaml.cs | 71 +- .../plugin.json | 23 +- Scripts/BuilderToolbox.ps1 | 818 ++++++++++++++++++ 33 files changed, 2494 insertions(+), 1227 deletions(-) delete mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/ChromeBookmarkLoader.cs delete mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/ChromiumBookmarkLoader.cs delete mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Commands/BookmarkLoader.cs delete mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/CustomChromiumBookmarkLoader.cs delete mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/CustomFirefoxBookmarkLoader.cs delete mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/EdgeBookmarkLoader.cs delete mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/FirefoxBookmarkLoader.cs create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.sln delete mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Helper/FaviconHelper.cs delete mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/IBookmarkLoader.cs create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/BaseModel.cs create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/BookmarkLoaderService.cs create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/BookmarkWatcherService.cs create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/BrowserDetector.cs create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/ChromiumBookmarkLoader.cs create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/FaviconService.cs create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/FirefoxBookmarkLoader.cs create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/FirefoxProfileFinder.cs create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/IBookmarkLoader.cs create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/LocalFaviconExtractor.cs create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/ViewModels/CustomBrowserSettingViewModel.cs create mode 100644 Scripts/BuilderToolbox.ps1 diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/ChromeBookmarkLoader.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/ChromeBookmarkLoader.cs deleted file mode 100644 index 65757b80253..00000000000 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/ChromeBookmarkLoader.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Flow.Launcher.Plugin.BrowserBookmark.Models; -using System; -using System.Collections.Generic; -using System.IO; - -namespace Flow.Launcher.Plugin.BrowserBookmark; - -public class ChromeBookmarkLoader : ChromiumBookmarkLoader -{ - public override List GetBookmarks() - { - return LoadChromeBookmarks(); - } - - private List LoadChromeBookmarks() - { - var bookmarks = new List(); - var platformPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - bookmarks.AddRange(LoadBookmarks(Path.Combine(platformPath, @"Google\Chrome\User Data"), "Google Chrome")); - bookmarks.AddRange(LoadBookmarks(Path.Combine(platformPath, @"Google\Chrome SxS\User Data"), "Google Chrome Canary")); - bookmarks.AddRange(LoadBookmarks(Path.Combine(platformPath, @"Chromium\User Data"), "Chromium")); - return bookmarks; - } -} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/ChromiumBookmarkLoader.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/ChromiumBookmarkLoader.cs deleted file mode 100644 index b1166146670..00000000000 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/ChromiumBookmarkLoader.cs +++ /dev/null @@ -1,205 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; -using System.Text.Json; -using System.Threading.Tasks; -using Flow.Launcher.Plugin.BrowserBookmark.Helper; -using Flow.Launcher.Plugin.BrowserBookmark.Models; -using Microsoft.Data.Sqlite; - -namespace Flow.Launcher.Plugin.BrowserBookmark; - -public abstract class ChromiumBookmarkLoader : IBookmarkLoader -{ - private static readonly string ClassName = nameof(ChromiumBookmarkLoader); - - private readonly string _faviconCacheDir; - - protected ChromiumBookmarkLoader() - { - _faviconCacheDir = Main._faviconCacheDir; - } - - public abstract List GetBookmarks(); - - protected List LoadBookmarks(string browserDataPath, string name) - { - var bookmarks = new List(); - if (!Directory.Exists(browserDataPath)) return bookmarks; - var paths = Directory.GetDirectories(browserDataPath); - - foreach (var profile in paths) - { - var bookmarkPath = Path.Combine(profile, "Bookmarks"); - if (!File.Exists(bookmarkPath)) - continue; - - // Register bookmark file monitoring (direct call to Main.RegisterBookmarkFile) - try - { - if (File.Exists(bookmarkPath)) - { - Main.RegisterBookmarkFile(bookmarkPath); - } - } - catch (Exception ex) - { - Main.Context.API.LogException(ClassName, $"Failed to register bookmark file monitoring: {bookmarkPath}", ex); - continue; - } - - var source = name + (Path.GetFileName(profile) == "Default" ? "" : $" ({Path.GetFileName(profile)})"); - var profileBookmarks = LoadBookmarksFromFile(bookmarkPath, source); - - // Load favicons after loading bookmarks - if (Main._settings.EnableFavicons) - { - var faviconDbPath = Path.Combine(profile, "Favicons"); - if (File.Exists(faviconDbPath)) - { - Main.Context.API.StopwatchLogInfo(ClassName, $"Load {profileBookmarks.Count} favicons cost", () => - { - LoadFaviconsFromDb(faviconDbPath, profileBookmarks); - }); - } - } - - bookmarks.AddRange(profileBookmarks); - } - - return bookmarks; - } - - protected static List LoadBookmarksFromFile(string path, string source) - { - var bookmarks = new List(); - - if (!File.Exists(path)) - return bookmarks; - - using var jsonDocument = JsonDocument.Parse(File.ReadAllText(path)); - if (!jsonDocument.RootElement.TryGetProperty("roots", out var rootElement)) - return bookmarks; - EnumerateRoot(rootElement, bookmarks, source); - return bookmarks; - } - - private static void EnumerateRoot(JsonElement rootElement, ICollection bookmarks, string source) - { - foreach (var folder in rootElement.EnumerateObject()) - { - if (folder.Value.ValueKind != JsonValueKind.Object) - continue; - - // Fix for Opera. It stores bookmarks slightly different than chrome. - if (folder.Name == "custom_root") - EnumerateRoot(folder.Value, bookmarks, source); - else - EnumerateFolderBookmark(folder.Value, bookmarks, source); - } - } - - private static void EnumerateFolderBookmark(JsonElement folderElement, ICollection bookmarks, - string source) - { - if (!folderElement.TryGetProperty("children", out var childrenElement)) - return; - foreach (var subElement in childrenElement.EnumerateArray()) - { - if (subElement.TryGetProperty("type", out var type)) - { - switch (type.GetString()) - { - case "folder": - case "workspace": // Edge Workspace - EnumerateFolderBookmark(subElement, bookmarks, source); - break; - default: - bookmarks.Add(new Bookmark( - subElement.GetProperty("name").GetString(), - subElement.GetProperty("url").GetString(), - source)); - break; - } - } - else - { - Main.Context.API.LogError(ClassName, $"type property not found for {subElement.GetString()}"); - } - } - } - - private void LoadFaviconsFromDb(string dbPath, List bookmarks) - { - FaviconHelper.LoadFaviconsFromDb(_faviconCacheDir, dbPath, (tempDbPath) => - { - // Since some bookmarks may have same favicon id, we need to record them to avoid duplicates - var savedPaths = new ConcurrentDictionary(); - - // Get favicons based on bookmarks concurrently - Parallel.ForEach(bookmarks, bookmark => - { - // Use read-only connection to avoid locking issues - // Do not use pooling so that we do not need to clear pool: https://github.com/dotnet/efcore/issues/26580 - var connection = new SqliteConnection($"Data Source={tempDbPath};Mode=ReadOnly;Pooling=false"); - connection.Open(); - - try - { - var url = bookmark.Url; - if (string.IsNullOrEmpty(url)) return; - - // Extract domain from URL - if (!Uri.TryCreate(url, UriKind.Absolute, out Uri uri)) - return; - - var domain = uri.Host; - - using var cmd = connection.CreateCommand(); - cmd.CommandText = @" - SELECT f.id, b.image_data - FROM favicons f - JOIN favicon_bitmaps b ON f.id = b.icon_id - JOIN icon_mapping m ON f.id = m.icon_id - WHERE m.page_url LIKE @url - ORDER BY b.width DESC - LIMIT 1"; - - cmd.Parameters.AddWithValue("@url", $"%{domain}%"); - - using var reader = cmd.ExecuteReader(); - if (!reader.Read() || reader.IsDBNull(1)) - return; - - var iconId = reader.GetInt64(0).ToString(); - var imageData = (byte[])reader["image_data"]; - - if (imageData is not { Length: > 0 }) - return; - - var faviconPath = Path.Combine(_faviconCacheDir, $"chromium_{domain}_{iconId}.png"); - - // Filter out duplicate favicons - if (savedPaths.TryAdd(faviconPath, true)) - { - FaviconHelper.SaveBitmapData(imageData, faviconPath); - } - - bookmark.FaviconPath = faviconPath; - } - catch (Exception ex) - { - Main.Context.API.LogException(ClassName, $"Failed to extract bookmark favicon: {bookmark.Url}", ex); - } - finally - { - // Cache connection and clear pool after all operations to avoid issue: - // ObjectDisposedException: Safe handle has been closed. - connection.Close(); - connection.Dispose(); - } - }); - }); - } -} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Commands/BookmarkLoader.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Commands/BookmarkLoader.cs deleted file mode 100644 index b76adae93c3..00000000000 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Commands/BookmarkLoader.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Flow.Launcher.Plugin.BrowserBookmark.Models; -using Flow.Launcher.Plugin.SharedModels; - -namespace Flow.Launcher.Plugin.BrowserBookmark.Commands; - -internal static class BookmarkLoader -{ - internal static MatchResult MatchProgram(Bookmark bookmark, string queryString) - { - var match = Main.Context.API.FuzzySearch(queryString, bookmark.Name); - if (match.IsSearchPrecisionScoreMet()) - return match; - - return Main.Context.API.FuzzySearch(queryString, bookmark.Url); - } - - internal static List LoadAllBookmarks(Settings setting) - { - var allBookmarks = new List(); - - if (setting.LoadChromeBookmark) - { - // Add Chrome bookmarks - var chromeBookmarks = new ChromeBookmarkLoader(); - allBookmarks.AddRange(chromeBookmarks.GetBookmarks()); - } - - if (setting.LoadFirefoxBookmark) - { - // Add Firefox bookmarks - var mozBookmarks = new FirefoxBookmarkLoader(); - allBookmarks.AddRange(mozBookmarks.GetBookmarks()); - } - - if (setting.LoadEdgeBookmark) - { - // Add Edge (Chromium) bookmarks - var edgeBookmarks = new EdgeBookmarkLoader(); - allBookmarks.AddRange(edgeBookmarks.GetBookmarks()); - } - - foreach (var browser in setting.CustomChromiumBrowsers) - { - IBookmarkLoader loader = browser.BrowserType switch - { - BrowserType.Chromium => new CustomChromiumBookmarkLoader(browser), - BrowserType.Firefox => new CustomFirefoxBookmarkLoader(browser), - _ => new CustomChromiumBookmarkLoader(browser), - }; - allBookmarks.AddRange(loader.GetBookmarks()); - } - - return allBookmarks.Distinct().ToList(); - } -} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/CustomChromiumBookmarkLoader.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/CustomChromiumBookmarkLoader.cs deleted file mode 100644 index 005c83992bf..00000000000 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/CustomChromiumBookmarkLoader.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Flow.Launcher.Plugin.BrowserBookmark.Models; -using System.Collections.Generic; - -namespace Flow.Launcher.Plugin.BrowserBookmark; - -public class CustomChromiumBookmarkLoader : ChromiumBookmarkLoader -{ - public CustomChromiumBookmarkLoader(CustomBrowser browser) - { - BrowserName = browser.Name; - BrowserDataPath = browser.DataDirectoryPath; - } - public string BrowserDataPath { get; init; } - public string BookmarkFilePath { get; init; } - public string BrowserName { get; init; } - - public override List GetBookmarks() => BrowserDataPath != null ? LoadBookmarks(BrowserDataPath, BrowserName) : LoadBookmarksFromFile(BookmarkFilePath, BrowserName); -} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/CustomFirefoxBookmarkLoader.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/CustomFirefoxBookmarkLoader.cs deleted file mode 100644 index d0bb7b0cc71..00000000000 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/CustomFirefoxBookmarkLoader.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using Flow.Launcher.Plugin.BrowserBookmark.Models; - -namespace Flow.Launcher.Plugin.BrowserBookmark; - -public class CustomFirefoxBookmarkLoader : FirefoxBookmarkLoaderBase -{ - public CustomFirefoxBookmarkLoader(CustomBrowser browser) - { - BrowserName = browser.Name; - BrowserDataPath = browser.DataDirectoryPath; - } - - /// - /// Path to places.sqlite - /// - public string BrowserDataPath { get; init; } - - public string BrowserName { get; init; } - - public override List GetBookmarks() - { - return GetBookmarksFromPath(Path.Combine(BrowserDataPath, "places.sqlite")); - } -} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/EdgeBookmarkLoader.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/EdgeBookmarkLoader.cs deleted file mode 100644 index 40123b022e1..00000000000 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/EdgeBookmarkLoader.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Flow.Launcher.Plugin.BrowserBookmark.Models; -using System; -using System.Collections.Generic; -using System.IO; - -namespace Flow.Launcher.Plugin.BrowserBookmark; - -public class EdgeBookmarkLoader : ChromiumBookmarkLoader -{ - private List LoadEdgeBookmarks() - { - var bookmarks = new List(); - var platformPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - bookmarks.AddRange(LoadBookmarks(Path.Combine(platformPath, @"Microsoft\Edge\User Data"), "Microsoft Edge")); - bookmarks.AddRange(LoadBookmarks(Path.Combine(platformPath, @"Microsoft\Edge Dev\User Data"), "Microsoft Edge Dev")); - bookmarks.AddRange(LoadBookmarks(Path.Combine(platformPath, @"Microsoft\Edge SxS\User Data"), "Microsoft Edge Canary")); - - return bookmarks; - } - - public override List GetBookmarks() => LoadEdgeBookmarks(); -} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/FirefoxBookmarkLoader.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/FirefoxBookmarkLoader.cs deleted file mode 100644 index be83f61584f..00000000000 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/FirefoxBookmarkLoader.cs +++ /dev/null @@ -1,341 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Threading.Tasks; -using Flow.Launcher.Plugin.BrowserBookmark.Helper; -using Flow.Launcher.Plugin.BrowserBookmark.Models; -using Microsoft.Data.Sqlite; - -namespace Flow.Launcher.Plugin.BrowserBookmark; - -public abstract class FirefoxBookmarkLoaderBase : IBookmarkLoader -{ - private static readonly string ClassName = nameof(FirefoxBookmarkLoaderBase); - - private readonly string _faviconCacheDir; - - protected FirefoxBookmarkLoaderBase() - { - _faviconCacheDir = Main._faviconCacheDir; - } - - public abstract List GetBookmarks(); - - // Updated query - removed favicon_id column - private const string QueryAllBookmarks = """ - SELECT moz_places.url, moz_bookmarks.title - FROM moz_places - INNER JOIN moz_bookmarks ON ( - moz_bookmarks.fk NOT NULL AND moz_bookmarks.title NOT NULL AND moz_bookmarks.fk = moz_places.id - ) - ORDER BY moz_places.visit_count DESC - """; - - protected List GetBookmarksFromPath(string placesPath) - { - // Variable to store bookmark list - var bookmarks = new List(); - - // Return empty list if places.sqlite file doesn't exist - if (string.IsNullOrEmpty(placesPath) || !File.Exists(placesPath)) - return bookmarks; - - // Try to register file monitoring - try - { - Main.RegisterBookmarkFile(placesPath); - } - catch (Exception ex) - { - Main.Context.API.LogException(ClassName, $"Failed to register Firefox bookmark file monitoring: {placesPath}", ex); - return bookmarks; - } - - var tempDbPath = Path.Combine(_faviconCacheDir, $"tempplaces_{Guid.NewGuid()}.sqlite"); - - try - { - // Use a copy to avoid lock issues with the original file - File.Copy(placesPath, tempDbPath, true); - - // Create the connection string and init the connection - using var dbConnection = new SqliteConnection($"Data Source={tempDbPath};Mode=ReadOnly"); - - // Open connection to the database file and execute the query - dbConnection.Open(); - var reader = new SqliteCommand(QueryAllBookmarks, dbConnection).ExecuteReader(); - - // Get results in List format - bookmarks = reader - .Select( - x => new Bookmark( - x["title"] is DBNull ? string.Empty : x["title"].ToString(), - x["url"].ToString(), - "Firefox" - ) - ) - .ToList(); - - // Load favicons after loading bookmarks - if (Main._settings.EnableFavicons) - { - var faviconDbPath = Path.Combine(Path.GetDirectoryName(placesPath), "favicons.sqlite"); - if (File.Exists(faviconDbPath)) - { - Main.Context.API.StopwatchLogInfo(ClassName, $"Load {bookmarks.Count} favicons cost", () => - { - LoadFaviconsFromDb(faviconDbPath, bookmarks); - }); - } - } - - // Close the connection so that we can delete the temporary file - // https://github.com/dotnet/efcore/issues/26580 - SqliteConnection.ClearPool(dbConnection); - dbConnection.Close(); - } - catch (Exception ex) - { - Main.Context.API.LogException(ClassName, $"Failed to load Firefox bookmarks: {placesPath}", ex); - } - - // Delete temporary file - try - { - if (File.Exists(tempDbPath)) - { - File.Delete(tempDbPath); - } - } - catch (Exception ex) - { - Main.Context.API.LogException(ClassName, $"Failed to delete temporary favicon DB: {tempDbPath}", ex); - } - - return bookmarks; - } - - private void LoadFaviconsFromDb(string dbPath, List bookmarks) - { - FaviconHelper.LoadFaviconsFromDb(_faviconCacheDir, dbPath, (tempDbPath) => - { - // Since some bookmarks may have same favicon id, we need to record them to avoid duplicates - var savedPaths = new ConcurrentDictionary(); - - // Get favicons based on bookmarks concurrently - Parallel.ForEach(bookmarks, bookmark => - { - // Use read-only connection to avoid locking issues - // Do not use pooling so that we do not need to clear pool: https://github.com/dotnet/efcore/issues/26580 - var connection = new SqliteConnection($"Data Source={tempDbPath};Mode=ReadOnly;Pooling=false"); - connection.Open(); - - try - { - if (!Uri.TryCreate(bookmark.Url, UriKind.Absolute, out Uri uri)) - return; - - var domain = uri.Host; - - // Query for latest Firefox version favicon structure - using var cmd = connection.CreateCommand(); - cmd.CommandText = @" - SELECT i.id, i.data - FROM moz_icons i - JOIN moz_icons_to_pages ip ON i.id = ip.icon_id - JOIN moz_pages_w_icons p ON ip.page_id = p.id - WHERE p.page_url LIKE @domain - ORDER BY i.width DESC - LIMIT 1"; - - cmd.Parameters.AddWithValue("@domain", $"%{domain}%"); - - using var reader = cmd.ExecuteReader(); - if (!reader.Read() || reader.IsDBNull(1)) - return; - - var iconId = reader.GetInt64(0).ToString(); - var imageData = (byte[])reader["data"]; - - if (imageData is not { Length: > 0 }) - return; - - // Check if the image data is compressed (GZip) - if (imageData.Length > 2 && imageData[0] == 0x1f && imageData[1] == 0x8b) - { - using var inputStream = new MemoryStream(imageData); - using var gZipStream = new GZipStream(inputStream, CompressionMode.Decompress); - using var outputStream = new MemoryStream(); - gZipStream.CopyTo(outputStream); - imageData = outputStream.ToArray(); - } - - // Convert the image data to WebP format - var webpData = FaviconHelper.TryConvertToWebp(imageData); - if (webpData != null) - { - var faviconPath = Path.Combine(_faviconCacheDir, $"firefox_{domain}_{iconId}.webp"); - - if (savedPaths.TryAdd(faviconPath, true)) - { - FaviconHelper.SaveBitmapData(webpData, faviconPath); - } - - bookmark.FaviconPath = faviconPath; - } - } - catch (Exception ex) - { - Main.Context.API.LogException(ClassName, $"Failed to extract Firefox favicon: {bookmark.Url}", ex); - } - finally - { - // Cache connection and clear pool after all operations to avoid issue: - // ObjectDisposedException: Safe handle has been closed. - connection.Close(); - connection.Dispose(); - } - }); - }); - } -} - -public class FirefoxBookmarkLoader : FirefoxBookmarkLoaderBase -{ - /// - /// Searches the places.sqlite db and returns all bookmarks - /// - public override List GetBookmarks() - { - var bookmarks = new List(); - bookmarks.AddRange(GetBookmarksFromPath(PlacesPath)); - bookmarks.AddRange(GetBookmarksFromPath(MsixPlacesPath)); - return bookmarks; - } - - /// - /// Path to places.sqlite of Msi installer - /// E.g. C:\Users\{UserName}\AppData\Roaming\Mozilla\Firefox - /// - /// - private static string PlacesPath - { - get - { - var profileFolderPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), @"Mozilla\Firefox"); - return GetProfileIniPath(profileFolderPath); - } - } - - /// - /// Path to places.sqlite of MSIX installer - /// E.g. C:\Users\{UserName}\AppData\Local\Packages\Mozilla.Firefox_n80bbvh6b1yt2\LocalCache\Roaming\Mozilla\Firefox - /// - /// - public static string MsixPlacesPath - { - get - { - var platformPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - var packagesPath = Path.Combine(platformPath, "Packages"); - try - { - // Search for folder with Mozilla.Firefox prefix - var firefoxPackageFolder = Directory.EnumerateDirectories(packagesPath, "Mozilla.Firefox*", - SearchOption.TopDirectoryOnly).FirstOrDefault(); - - // Msix FireFox not installed - if (firefoxPackageFolder == null) return string.Empty; - - var profileFolderPath = Path.Combine(firefoxPackageFolder, @"LocalCache\Roaming\Mozilla\Firefox"); - return GetProfileIniPath(profileFolderPath); - } - catch - { - return string.Empty; - } - } - } - - private static string GetProfileIniPath(string profileFolderPath) - { - var profileIni = Path.Combine(profileFolderPath, @"profiles.ini"); - if (!File.Exists(profileIni)) - return string.Empty; - - // get firefox default profile directory from profiles.ini - using var sReader = new StreamReader(profileIni); - var ini = sReader.ReadToEnd(); - - var lines = ini.Split("\r\n").ToList(); - - var defaultProfileFolderNameRaw = lines.FirstOrDefault(x => x.Contains("Default=") && x != "Default=1") ?? string.Empty; - - if (string.IsNullOrEmpty(defaultProfileFolderNameRaw)) - return string.Empty; - - var defaultProfileFolderName = defaultProfileFolderNameRaw.Split('=').Last(); - - var indexOfDefaultProfileAttributePath = lines.IndexOf("Path=" + defaultProfileFolderName); - - /* - Current profiles.ini structure example as of Firefox version 69.0.1 - - [Install736426B0AF4A39CB] - Default=Profiles/7789f565.default-release <== this is the default profile this plugin will get the bookmarks from. When opened Firefox will load the default profile - Locked=1 - - [Profile2] - Name=dummyprofile - IsRelative=0 - Path=C:\t6h2yuq8.dummyprofile <== Note this is a custom location path for the profile user can set, we need to cater for this in code. - - [Profile1] - Name=default - IsRelative=1 - Path=Profiles/cydum7q4.default - Default=1 - - [Profile0] - Name=default-release - IsRelative=1 - Path=Profiles/7789f565.default-release - - [General] - StartWithLastProfile=1 - Version=2 - */ - // Seen in the example above, the IsRelative attribute is always above the Path attribute - - var relativePath = Path.Combine(defaultProfileFolderName, "places.sqlite"); - var absolutePath = Path.Combine(profileFolderPath, relativePath); - - // If the index is out of range, it means that the default profile is in a custom location or the file is malformed - // If the profile is in a custom location, we need to check - if (indexOfDefaultProfileAttributePath - 1 < 0 || - indexOfDefaultProfileAttributePath - 1 >= lines.Count) - { - return Directory.Exists(absolutePath) ? absolutePath : relativePath; - } - - var relativeAttribute = lines[indexOfDefaultProfileAttributePath - 1]; - - // See above, the profile is located in a custom location, path is not relative, so IsRelative=0 - return (relativeAttribute == "0" || relativeAttribute == "IsRelative=0") - ? relativePath : absolutePath; - } -} - -public static class Extensions -{ - public static IEnumerable Select(this SqliteDataReader reader, Func projection) - { - while (reader.Read()) - { - yield return projection(reader); - } - } -} 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 8a9162e62e2..d7d417dcd53 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj @@ -57,7 +57,7 @@ $(OutputPath)runtimes\osx-arm64; $(OutputPath)runtimes\osx-x64; $(OutputPath)runtimes\win-arm; - $(OutputPath)runtimes\win-arm64;"/> + $(OutputPath)runtimes\win-arm64;" /> @@ -80,34 +80,40 @@ $(PublishDir)runtimes\osx-arm64; $(PublishDir)runtimes\osx-x64; $(PublishDir)runtimes\win-arm; - $(PublishDir)runtimes\win-arm64;"/> + $(PublishDir)runtimes\win-arm64;" /> - + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + PreserveNewest - - - - - + - - PreserveNewest - PreserveNewest + + + + - + - + \ No newline at end of file diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.sln b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.sln new file mode 100644 index 00000000000..29c49490aab --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flow.Launcher.Plugin.BrowserBookmark", "Flow.Launcher.Plugin.BrowserBookmark.csproj", "{BE047398-4D54-9D16-720E-AB0002E872C4}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {BE047398-4D54-9D16-720E-AB0002E872C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BE047398-4D54-9D16-720E-AB0002E872C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BE047398-4D54-9D16-720E-AB0002E872C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BE047398-4D54-9D16-720E-AB0002E872C4}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {2FA6CF2E-9DDE-42DD-B371-5DADB587392E} + EndGlobalSection +EndGlobal diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Helper/FaviconHelper.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Helper/FaviconHelper.cs deleted file mode 100644 index 82b0890337c..00000000000 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Helper/FaviconHelper.cs +++ /dev/null @@ -1,125 +0,0 @@ -using System; -using System.IO; -using SkiaSharp; -using Svg.Skia; - -namespace Flow.Launcher.Plugin.BrowserBookmark.Helper; - -public static class FaviconHelper -{ - private static readonly string ClassName = nameof(FaviconHelper); - - public static void LoadFaviconsFromDb(string faviconCacheDir, string dbPath, Action loadAction) - { - // Use a copy to avoid lock issues with the original file - var tempDbPath = Path.Combine(faviconCacheDir, $"tempfavicons_{Guid.NewGuid()}.db"); - - try - { - File.Copy(dbPath, tempDbPath, true); - } - catch (Exception ex) - { - try - { - if (File.Exists(tempDbPath)) - { - File.Delete(tempDbPath); - } - } - catch (Exception ex1) - { - Main.Context.API.LogException(ClassName, $"Failed to delete temporary favicon DB: {tempDbPath}", ex1); - } - Main.Context.API.LogException(ClassName, $"Failed to copy favicon DB: {dbPath}", ex); - return; - } - - try - { - loadAction(tempDbPath); - } - catch (Exception ex) - { - Main.Context.API.LogException(ClassName, $"Failed to connect to SQLite: {tempDbPath}", ex); - } - - // Delete temporary file - try - { - File.Delete(tempDbPath); - } - catch (Exception ex) - { - Main.Context.API.LogException(ClassName, $"Failed to delete temporary favicon DB: {tempDbPath}", ex); - } - } - - public static void SaveBitmapData(byte[] imageData, string outputPath) - { - try - { - File.WriteAllBytes(outputPath, imageData); - } - catch (Exception ex) - { - Main.Context.API.LogException(ClassName, $"Failed to save image: {outputPath}", ex); - } - } - - public static byte[] TryConvertToWebp(byte[] data) - { - if (data == null || data.Length == 0) - return null; - - SKBitmap bitmap = null; - - try - { - using (var ms = new MemoryStream(data)) - { - var svg = new SKSvg(); - if (svg.Load(ms) != null && svg.Picture != null) - { - bitmap = new SKBitmap((int)svg.Picture.CullRect.Width, (int)svg.Picture.CullRect.Height); - using (var canvas = new SKCanvas(bitmap)) - { - canvas.Clear(SKColors.Transparent); - canvas.DrawPicture(svg.Picture); - canvas.Flush(); - } - } - } - } - catch { /* Not an SVG */ } - - if (bitmap == null) - { - try - { - bitmap = SKBitmap.Decode(data); - } - catch { /* Not a decodable bitmap */ } - } - - if (bitmap != null) - { - try - { - using var image = SKImage.FromBitmap(bitmap); - if (image is null) - return null; - - using var webp = image.Encode(SKEncodedImageFormat.Webp, 65); - if (webp != null) - return webp.ToArray(); - } - finally - { - bitmap.Dispose(); - } - } - - return null; - } -} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/IBookmarkLoader.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/IBookmarkLoader.cs deleted file mode 100644 index 8a972735275..00000000000 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/IBookmarkLoader.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Flow.Launcher.Plugin.BrowserBookmark.Models; -using System.Collections.Generic; - -namespace Flow.Launcher.Plugin.BrowserBookmark; - -public interface IBookmarkLoader -{ - public List GetBookmarks(); -} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/en.xaml b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/en.xaml index 56471417309..d0ea98553ec 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/en.xaml +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/en.xaml @@ -5,31 +5,35 @@ Browser Bookmarks - Search your browser bookmarks + Search your browser bookmarks, retrieve favicons from the web. - Failed to set url in clipboard + Failed to copy URL to clipboard + Copy URL + Copy the bookmark's URL to clipboard - Bookmark Data - Open bookmarks in: - New window - New tab - Set browser from path: - Choose - Copy url - Copy the bookmark's url to clipboard - Load Browser From: - Browser Name - Data Directory Path + Load bookmarks from: + Others... Add Edit Delete + Enable favicons + Fetch missing favicons from the web + + + Custom Browser Setting + Browser Name + Data / Profile Path Browse - Others - Browser Engine - If you are not using Chrome, Firefox or Edge, or you are using their portable version, you need to add bookmarks data directory and select correct browser engine to make this plugin work. - For example: Brave's engine is Chromium; and its default bookmarks data location is: "%LOCALAPPDATA%\BraveSoftware\Brave-Browser\UserData". For Firefox engine, the bookmarks directory is the userdata folder contains the places.sqlite file. - Load favicons (can be time consuming during startup) + Enter your custom browser information here. + Chromium-based browsers: enter the path to your 'Bookmarks' file. + Firefox-based browsers: enter the path to your 'places.sqlite' file. + + + Invalid or unsupported profile directory. + Detected: Chromium-based browser + Detected: Firefox-based browser + Please select a data directory. \ No newline at end of file diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs index 07ce510fb3e..307844222c7 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs @@ -1,215 +1,218 @@ -using System; +#nullable enable +using Flow.Launcher.Plugin.BrowserBookmark.Models; +using System; using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Channels; -using System.Threading.Tasks; -using System.Threading; using System.Windows.Controls; -using Flow.Launcher.Plugin.BrowserBookmark.Commands; -using Flow.Launcher.Plugin.BrowserBookmark.Models; -using Flow.Launcher.Plugin.BrowserBookmark.Views; +using System.Threading; +using System.Threading.Tasks; +using Flow.Launcher.Plugin.BrowserBookmark.Services; +using System.ComponentModel; +using System.Linq; using Flow.Launcher.Plugin.SharedCommands; +using System.Collections.Specialized; +using Flow.Launcher.Plugin.SharedModels; +using System.IO; +using System.Collections.Concurrent; namespace Flow.Launcher.Plugin.BrowserBookmark; -public class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContextMenu, IDisposable +public class Main : ISettingProvider, IPlugin, IAsyncReloadable, IPluginI18n, IContextMenu, IDisposable { - private static readonly string ClassName = nameof(Main); - - internal static string _faviconCacheDir; - - internal static PluginInitContext Context { get; set; } - - internal static Settings _settings; + internal static PluginInitContext Context { get; set; } = null!; + private static Settings _settings = null!; + + private BookmarkLoaderService _bookmarkLoader = null!; + private FaviconService _faviconService = null!; + private BookmarkWatcherService _bookmarkWatcher = null!; - private static List _cachedBookmarks = new(); + private List _bookmarks = new(); + private readonly CancellationTokenSource _cancellationTokenSource = new(); + private PeriodicTimer? _firefoxBookmarkTimer; + private static readonly TimeSpan FirefoxPollingInterval = TimeSpan.FromHours(3); - private static bool _initialized = false; - public void Init(PluginInitContext context) { Context = context; - _settings = context.API.LoadSettingJsonStorage(); + _settings.PropertyChanged += OnSettingsPropertyChanged; + _settings.CustomBrowsers.CollectionChanged += OnCustomBrowsersChanged; - _faviconCacheDir = Path.Combine( - context.CurrentPluginMetadata.PluginCacheDirectoryPath, - "FaviconCache"); + var tempPath = SetupTempDirectory(); + _bookmarkLoader = new BookmarkLoaderService(Context, _settings, tempPath); + _faviconService = new FaviconService(Context, _settings, tempPath); + _bookmarkWatcher = new BookmarkWatcherService(); + _bookmarkWatcher.OnBookmarkFileChanged += OnBookmarkFileChanged; + + // Fire and forget the initial load to make Flow's UI responsive immediately. + _ = ReloadDataAsync(); + StartFirefoxBookmarkTimer(); + } + + private string SetupTempDirectory() + { + var tempPath = Path.Combine(Context.CurrentPluginMetadata.PluginCacheDirectoryPath, "Temp"); try { - if (Directory.Exists(_faviconCacheDir)) + if (Directory.Exists(tempPath)) { - var files = Directory.GetFiles(_faviconCacheDir); - foreach (var file in files) - { - var extension = Path.GetExtension(file); - if (extension is ".db-shm" or ".db-wal" or ".sqlite-shm" or ".sqlite-wal") - { - File.Delete(file); - } - } + Directory.Delete(tempPath, true); } + Directory.CreateDirectory(tempPath); } catch (Exception e) { - Context.API.LogException(ClassName, "Failed to clean up orphaned cache files.", e); + Context.API.LogException(nameof(Main), "Failed to set up temporary directory.", e); } - - LoadBookmarksIfEnabled(); + return tempPath; } - private static void LoadBookmarksIfEnabled() + public List Query(Query query) { - if (Context.CurrentPluginMetadata.Disabled) + var search = query.Search.Trim(); + var bookmarks = _bookmarks; // use a local copy + + if (!string.IsNullOrEmpty(search)) { - // Don't load or monitor files if disabled - return; + return bookmarks + .Select(b => + { + var match = Context.API.FuzzySearch(search, b.Name); + if(!match.IsSearchPrecisionScoreMet()) + match = Context.API.FuzzySearch(search, b.Url); + return (b, match); + }) + .Where(t => t.match.IsSearchPrecisionScoreMet()) + .OrderByDescending(t => t.match.Score) + .Select(t => CreateResult(t.b, t.match.Score)) + .ToList(); } - // Validate the cache directory before loading all bookmarks because Flow needs this directory to storage favicons - FilesFolders.ValidateDirectory(_faviconCacheDir); - - _cachedBookmarks = BookmarkLoader.LoadAllBookmarks(_settings); - _ = MonitorRefreshQueueAsync(); - _initialized = true; + return bookmarks.Select(b => CreateResult(b, 0)).ToList(); } - - public List Query(Query query) + + private Result CreateResult(Bookmark bookmark, int score) => new() { - // For when the plugin being previously disabled and is now re-enabled - if (!_initialized) + Title = bookmark.Name, + SubTitle = bookmark.Url, + IcoPath = !string.IsNullOrEmpty(bookmark.FaviconPath) + ? bookmark.FaviconPath + : @"Images\bookmark.png", + Score = score, + Action = _ => { - LoadBookmarksIfEnabled(); - } - - string param = query.Search.TrimStart(); + Context.API.OpenUrl(bookmark.Url); + return true; + }, + ContextData = bookmark.Url + }; - // Should top results be returned? (true if no search parameters have been passed) - var topResults = string.IsNullOrEmpty(param); - - if (!topResults) - { - // Since we mixed chrome and firefox bookmarks, we should order them again - return _cachedBookmarks - .Select( - c => new Result - { - Title = c.Name, - SubTitle = c.Url, - IcoPath = !string.IsNullOrEmpty(c.FaviconPath) && File.Exists(c.FaviconPath) - ? c.FaviconPath - : @"Images\bookmark.png", - Score = BookmarkLoader.MatchProgram(c, param).Score, - Action = _ => - { - Context.API.OpenUrl(c.Url); + public async Task ReloadDataAsync() + { + var bookmarks = await _bookmarkLoader.LoadBookmarksAsync(_cancellationTokenSource.Token); + + // Atomically swap the list. This prevents the Query method from seeing a partially loaded list. + Volatile.Write(ref _bookmarks, bookmarks); - return true; - }, - ContextData = new BookmarkAttributes { Url = c.Url } - } - ) - .Where(r => r.Score > 0) - .ToList(); - } - else + _bookmarkWatcher.UpdateWatchers(_bookmarkLoader.DiscoveredBookmarkFiles); + + // Fire and forget favicon processing to not block the UI + _ = _faviconService.ProcessBookmarkFavicons(_bookmarks, _cancellationTokenSource.Token); + } + + private void OnSettingsPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(Settings.LoadFirefoxBookmark)) { - return _cachedBookmarks - .Select( - c => new Result - { - Title = c.Name, - SubTitle = c.Url, - IcoPath = !string.IsNullOrEmpty(c.FaviconPath) && File.Exists(c.FaviconPath) - ? c.FaviconPath - : @"Images\bookmark.png", - Score = 5, - Action = _ => - { - Context.API.OpenUrl(c.Url); - return true; - }, - ContextData = new BookmarkAttributes { Url = c.Url } - } - ) - .ToList(); + StartFirefoxBookmarkTimer(); } + _ = ReloadDataAsync(); } - private static readonly Channel _refreshQueue = Channel.CreateBounded(1); - - private static readonly SemaphoreSlim _fileMonitorSemaphore = new(1, 1); + private void OnCustomBrowsersChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + StartFirefoxBookmarkTimer(); + _ = ReloadDataAsync(); + } + + private void OnBookmarkFileChanged() + { + _ = ReloadDataAsync(); + } - private static async Task MonitorRefreshQueueAsync() + private void StartFirefoxBookmarkTimer() { - if (_fileMonitorSemaphore.CurrentCount < 1) - { + _firefoxBookmarkTimer?.Dispose(); + + if (!_settings.LoadFirefoxBookmark && !_settings.CustomBrowsers.Any(x => x.BrowserType == BrowserType.Firefox)) return; - } - await _fileMonitorSemaphore.WaitAsync(); - var reader = _refreshQueue.Reader; - while (await reader.WaitToReadAsync()) + + _firefoxBookmarkTimer = new PeriodicTimer(FirefoxPollingInterval); + + _ = Task.Run(async () => { - if (reader.TryRead(out _)) + while (await _firefoxBookmarkTimer.WaitForNextTickAsync(_cancellationTokenSource.Token)) { - ReloadAllBookmarks(false); + await ReloadFirefoxBookmarksAsync(); } - } - _fileMonitorSemaphore.Release(); + }, _cancellationTokenSource.Token); } - private static readonly List Watchers = new(); - - internal static void RegisterBookmarkFile(string path) + private async Task ReloadFirefoxBookmarksAsync() { - var directory = Path.GetDirectoryName(path); - if (!Directory.Exists(directory) || !File.Exists(path)) - { - return; - } - if (Watchers.Any(x => x.Path.Equals(directory, StringComparison.OrdinalIgnoreCase))) + Context.API.LogInfo(nameof(Main), "Starting periodic reload of Firefox bookmarks."); + + var firefoxLoaders = _bookmarkLoader.GetFirefoxBookmarkLoaders().ToList(); + if (!firefoxLoaders.Any()) { + Context.API.LogInfo(nameof(Main), "No Firefox bookmark loaders enabled, skipping reload."); return; } - var watcher = new FileSystemWatcher(directory!) - { - Filter = Path.GetFileName(path), - NotifyFilter = NotifyFilters.FileName | - NotifyFilters.LastWrite | - NotifyFilters.Size - }; - - watcher.Changed += static (_, _) => + var firefoxBookmarks = new ConcurrentBag(); + var tasks = firefoxLoaders.Select(async loader => { - _refreshQueue.Writer.TryWrite(default); - }; + try + { + await foreach (var bookmark in loader.GetBookmarksAsync(_cancellationTokenSource.Token)) + { + firefoxBookmarks.Add(bookmark); + } + } + catch (OperationCanceledException) { } // Task was cancelled, swallow exception + catch (Exception e) + { + Context.API.LogException(nameof(Main), $"Failed to load bookmarks from {loader.Name}.", e); + } + }); - watcher.Renamed += static (_, _) => + await Task.WhenAll(tasks); + + if (firefoxBookmarks.IsEmpty) { - _refreshQueue.Writer.TryWrite(default); - }; + Context.API.LogInfo(nameof(Main), "No Firefox bookmarks found during periodic reload."); + return; + } - watcher.EnableRaisingEvents = true; + var currentBookmarks = Volatile.Read(ref _bookmarks); + + var firefoxLoaderNames = firefoxLoaders.Select(l => l.Name).ToHashSet(); + var otherBookmarks = currentBookmarks.Where(b => !firefoxLoaderNames.Any(name => b.Source.StartsWith(name, StringComparison.OrdinalIgnoreCase))); - Watchers.Add(watcher); - } + var newBookmarkList = otherBookmarks.Concat(firefoxBookmarks).Distinct().ToList(); - public void ReloadData() - { - ReloadAllBookmarks(); + Volatile.Write(ref _bookmarks, newBookmarkList); + + Context.API.LogInfo(nameof(Main), $"Periodic reload complete. Loaded {firefoxBookmarks.Count} Firefox bookmarks."); + + _ = _faviconService.ProcessBookmarkFavicons(firefoxBookmarks.ToList(), _cancellationTokenSource.Token); } - public static void ReloadAllBookmarks(bool disposeFileWatchers = true) + public Control CreateSettingPanel() { - _cachedBookmarks.Clear(); - if (disposeFileWatchers) - DisposeFileWatchers(); - LoadBookmarksIfEnabled(); + return new Views.SettingsControl(_settings); } - + public string GetTranslatedPluginTitle() { return Localize.flowlauncher_plugin_browserbookmark_plugin_name(); @@ -219,57 +222,46 @@ public string GetTranslatedPluginDescription() { return Localize.flowlauncher_plugin_browserbookmark_plugin_description(); } - - public Control CreateSettingPanel() - { - return new SettingsControl(_settings); - } - + public List LoadContextMenus(Result selectedResult) { - return new List() + if (selectedResult.ContextData is not string url) + return new List(); + + return new List { new() { Title = Localize.flowlauncher_plugin_browserbookmark_copyurl_title(), SubTitle = Localize.flowlauncher_plugin_browserbookmark_copyurl_subtitle(), + Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\ue8c8"), + IcoPath = @"Images\copylink.png", Action = _ => { - try + try { - Context.API.CopyToClipboard(((BookmarkAttributes)selectedResult.ContextData).Url); - + Context.API.CopyToClipboard(url); return true; - } - catch (Exception e) + } + catch(Exception ex) { - Context.API.LogException(ClassName, "Failed to set url in clipboard", e); + Context.API.LogException(nameof(Main), "Failed to copy URL to clipboard", ex); Context.API.ShowMsgError(Localize.flowlauncher_plugin_browserbookmark_copy_failed()); return false; } - }, - IcoPath = @"Images\copylink.png", - Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\ue8c8") + } } }; } - - internal class BookmarkAttributes - { - internal string Url { get; set; } - } - + public void Dispose() { - DisposeFileWatchers(); - } - - private static void DisposeFileWatchers() - { - foreach (var watcher in Watchers) - { - watcher.Dispose(); - } - Watchers.Clear(); + _settings.PropertyChanged -= OnSettingsPropertyChanged; + _settings.CustomBrowsers.CollectionChanged -= OnCustomBrowsersChanged; + _firefoxBookmarkTimer?.Dispose(); + _cancellationTokenSource.Cancel(); + _cancellationTokenSource.Dispose(); + _faviconService.Dispose(); + _bookmarkWatcher.Dispose(); } } diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/BaseModel.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/BaseModel.cs new file mode 100644 index 00000000000..2bfa422fe5c --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/BaseModel.cs @@ -0,0 +1,16 @@ +#nullable enable +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Models; + +// A base class that implements INotifyPropertyChanged for view models. +public abstract class BaseModel : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/Bookmark.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/Bookmark.cs index caab16b65e8..b8b4aaff575 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/Bookmark.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/Bookmark.cs @@ -1,22 +1,19 @@ -using System.Collections.Generic; +#nullable enable +using System; namespace Flow.Launcher.Plugin.BrowserBookmark.Models; -// Source may be important in the future -public record Bookmark(string Name, string Url, string Source = "") +public record Bookmark(string Name, string Url, string Source, string ProfilePath) { public override int GetHashCode() { - var hashName = Name?.GetHashCode() ?? 0; - var hashUrl = Url?.GetHashCode() ?? 0; - return hashName ^ hashUrl; + return HashCode.Combine(Name, Url); } - public virtual bool Equals(Bookmark other) + public virtual bool Equals(Bookmark? other) { - return other != null && Name == other.Name && Url == other.Url; + return other is not null && Name == other.Name && Url == other.Url; } - public List CustomBrowsers { get; set; } = new(); public string FaviconPath { get; set; } = string.Empty; } diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/CustomBrowser.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/CustomBrowser.cs index af1e3fee496..22b6d4ef68e 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/CustomBrowser.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/CustomBrowser.cs @@ -1,5 +1,6 @@ -using System.Collections.Generic; using Flow.Launcher.Localization.Attributes; +using System.Collections.Generic; +using System.Linq; namespace Flow.Launcher.Plugin.BrowserBookmark.Models; @@ -7,7 +8,7 @@ public class CustomBrowser : BaseModel { private string _name; private string _dataDirectoryPath; - private BrowserType _browserType = BrowserType.Chromium; + private BrowserType _browserType = BrowserType.Unknown; public string Name { @@ -35,8 +36,6 @@ public string DataDirectoryPath } } - public List AllBrowserTypes { get; } = BrowserTypeLocalized.GetValues(); - public BrowserType BrowserType { get => _browserType; @@ -51,9 +50,15 @@ public BrowserType BrowserType } } +// Helper record for displaying enum values in the settings ComboBox. +public record BrowserTypeDisplay(string Display, BrowserType Value); + [EnumLocalize] public enum BrowserType { + [EnumLocalizeValue("Unknown")] + Unknown, + [EnumLocalizeValue("Chromium")] Chromium, diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/Settings.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/Settings.cs index a0041e0d6a0..3aa8a72135c 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/Settings.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/Settings.cs @@ -1,18 +1,51 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; namespace Flow.Launcher.Plugin.BrowserBookmark.Models; public class Settings : BaseModel { - public bool OpenInNewBrowserWindow { get; set; } = true; + private bool _loadChromeBookmark = true; + private bool _loadFirefoxBookmark = true; + private bool _loadEdgeBookmark = true; + private bool _loadChromiumBookmark = true; + private bool _enableFavicons = true; + private bool _fetchMissingFavicons = false; - public string BrowserPath { get; set; } + public bool LoadChromeBookmark + { + get => _loadChromeBookmark; + set { _loadChromeBookmark = value; OnPropertyChanged(); } + } + + public bool LoadFirefoxBookmark + { + get => _loadFirefoxBookmark; + set { _loadFirefoxBookmark = value; OnPropertyChanged(); } + } + + public bool LoadEdgeBookmark + { + get => _loadEdgeBookmark; + set { _loadEdgeBookmark = value; OnPropertyChanged(); } + } - public bool EnableFavicons { get; set; } = false; + public bool LoadChromiumBookmark + { + get => _loadChromiumBookmark; + set { _loadChromiumBookmark = value; OnPropertyChanged(); } + } - public bool LoadChromeBookmark { get; set; } = true; - public bool LoadFirefoxBookmark { get; set; } = true; - public bool LoadEdgeBookmark { get; set; } = true; - - public ObservableCollection CustomChromiumBrowsers { get; set; } = new(); + public bool EnableFavicons + { + get => _enableFavicons; + set { _enableFavicons = value; OnPropertyChanged(); } + } + + public bool FetchMissingFavicons + { + get => _fetchMissingFavicons; + set { _fetchMissingFavicons = value; OnPropertyChanged(); } + } + + public ObservableCollection CustomBrowsers { get; set; } = new(); } diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/BookmarkLoaderService.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/BookmarkLoaderService.cs new file mode 100644 index 00000000000..ca9d3ea0224 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/BookmarkLoaderService.cs @@ -0,0 +1,156 @@ +#nullable enable +using Flow.Launcher.Plugin.BrowserBookmark.Models; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Services; + +public class BookmarkLoaderService +{ + private readonly PluginInitContext _context; + private readonly Settings _settings; + private readonly string _tempPath; + + // This will hold the actual paths to the bookmark files for the watcher service + public List DiscoveredBookmarkFiles { get; } = new(); + + public BookmarkLoaderService(PluginInitContext context, Settings settings, string tempPath) + { + _context = context; + _settings = settings; + _tempPath = tempPath; + } + + public async Task> LoadBookmarksAsync(CancellationToken cancellationToken) + { + DiscoveredBookmarkFiles.Clear(); + var bookmarks = new ConcurrentBag(); + var loaders = GetBookmarkLoaders(); + + var tasks = loaders.Select(async loader => + { + try + { + await foreach (var bookmark in loader.GetBookmarksAsync(cancellationToken).WithCancellation(cancellationToken)) + { + bookmarks.Add(bookmark); + } + } + catch (OperationCanceledException) + { + // Task was cancelled, swallow exception + } + catch (Exception e) + { + _context.API.LogException(nameof(BookmarkLoaderService), $"Failed to load bookmarks from {loader.Name}.", e); + } + }); + + await Task.WhenAll(tasks); + + return bookmarks.Distinct().ToList(); + } + + public IEnumerable GetBookmarkLoaders() + { + return GetChromiumBookmarkLoaders().Concat(GetFirefoxBookmarkLoaders()); + } + + public IEnumerable GetChromiumBookmarkLoaders() + { + var logAction = (string tag, string msg, Exception? ex) => _context.API.LogException(tag, msg, ex); + + if (_settings.LoadChromeBookmark) + { + var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Google\Chrome\User Data"); + if (Directory.Exists(path)) + yield return new ChromiumBookmarkLoader("Google Chrome", path, logAction, DiscoveredBookmarkFiles); + } + + if (_settings.LoadEdgeBookmark) + { + var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Microsoft\Edge\User Data"); + if (Directory.Exists(path)) + yield return new ChromiumBookmarkLoader("Microsoft Edge", path, logAction, DiscoveredBookmarkFiles); + } + + if (_settings.LoadChromiumBookmark) + { + var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Chromium\User Data"); + if (Directory.Exists(path)) + yield return new ChromiumBookmarkLoader("Chromium", path, logAction, DiscoveredBookmarkFiles); + } + + foreach (var browser in _settings.CustomBrowsers.Where(b => b.BrowserType == BrowserType.Chromium)) + { + if (string.IsNullOrEmpty(browser.Name) || string.IsNullOrEmpty(browser.DataDirectoryPath) || !Directory.Exists(browser.DataDirectoryPath)) + continue; + + yield return new ChromiumBookmarkLoader(browser.Name, browser.DataDirectoryPath, logAction, DiscoveredBookmarkFiles); + } + } + + public IEnumerable GetFirefoxBookmarkLoaders() + { + var logAction = (string tag, string msg, Exception? ex) => _context.API.LogException(tag, msg, ex); + + if (_settings.LoadFirefoxBookmark) + { + string? placesPath = null; + try + { + placesPath = FirefoxProfileFinder.GetFirefoxPlacesPath(); + } + catch (Exception ex) + { + _context.API.LogException(nameof(BookmarkLoaderService), "Failed to find Firefox profile", ex); + } + if (!string.IsNullOrEmpty(placesPath)) + { + yield return new FirefoxBookmarkLoader("Firefox", placesPath, _tempPath, logAction); + } + + string? msixPlacesPath = null; + try + { + msixPlacesPath = FirefoxProfileFinder.GetFirefoxMsixPlacesPath(); + } + catch (Exception ex) + { + _context.API.LogException(nameof(BookmarkLoaderService), "Failed to find Firefox MSIX package", ex); + } + if (!string.IsNullOrEmpty(msixPlacesPath)) + { + yield return new FirefoxBookmarkLoader("Firefox (Store)", msixPlacesPath, _tempPath, logAction); + } + } + + foreach (var browser in _settings.CustomBrowsers.Where(b => b.BrowserType == BrowserType.Firefox)) + { + if (string.IsNullOrEmpty(browser.Name) || string.IsNullOrEmpty(browser.DataDirectoryPath) || !Directory.Exists(browser.DataDirectoryPath)) + continue; + + yield return CreateCustomFirefoxLoader(browser.Name, browser.DataDirectoryPath); + } + } + + private IBookmarkLoader CreateCustomFirefoxLoader(string name, string dataDirectoryPath) + { + var logAction = (string tag, string msg, Exception? ex) => _context.API.LogException(tag, msg, ex); + // Custom Firefox paths might point to the root profile dir (e.g. ...\Mozilla\Firefox) + var placesPath = FirefoxProfileFinder.GetPlacesPathFromProfileDir(dataDirectoryPath); + if (string.IsNullOrEmpty(placesPath)) + { + // Or they might point directly to a profile folder (e.g. ...\Profiles\xyz.default-release) + placesPath = Path.Combine(dataDirectoryPath, "places.sqlite"); + } + + // Do not add Firefox places.sqlite to the watcher as it's updated constantly for history. + return new FirefoxBookmarkLoader(name, placesPath, _tempPath, logAction); + } +} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/BookmarkWatcherService.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/BookmarkWatcherService.cs new file mode 100644 index 00000000000..c3fe8049612 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/BookmarkWatcherService.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Services; + +public class BookmarkWatcherService : IDisposable +{ + private readonly List _watchers = new(); + public event Action OnBookmarkFileChanged; + + // Timer to debounce file change events + private Timer _debounceTimer; + private readonly object _lock = new(); + + public BookmarkWatcherService() + { + _debounceTimer = new Timer(_ => OnBookmarkFileChanged?.Invoke(), null, Timeout.Infinite, Timeout.Infinite); + } + + public void UpdateWatchers(IEnumerable filePaths) + { + // Dispose old watchers + foreach (var watcher in _watchers) + { + watcher.EnableRaisingEvents = false; + watcher.Dispose(); + } + _watchers.Clear(); + + // Create a new, specific watcher for each individual bookmark file. + foreach (var filePath in filePaths) + { + var directory = Path.GetDirectoryName(filePath); + var fileName = Path.GetFileName(filePath); + + if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(fileName) || !Directory.Exists(directory)) + continue; + + var watcher = new FileSystemWatcher(directory) + { + Filter = fileName, + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size + }; + watcher.Changed += OnFileChanged; + watcher.Created += OnFileChanged; + watcher.Deleted += OnFileChanged; + watcher.Renamed += OnFileRenamed; + watcher.EnableRaisingEvents = true; + _watchers.Add(watcher); + } + } + + private void OnFileChanged(object sender, FileSystemEventArgs e) + { + TriggerDebouncedReload(); + } + + private void OnFileRenamed(object sender, RenamedEventArgs e) + { + TriggerDebouncedReload(); + } + + private void TriggerDebouncedReload() + { + // Reset the timer to fire after 2 seconds. + // This prevents multiple reloads if a browser writes to the file several times in quick succession. + lock (_lock) + { + _debounceTimer.Change(2000, Timeout.Infinite); + } + } + + public void Dispose() + { + _debounceTimer?.Dispose(); + foreach (var watcher in _watchers) + { + watcher.EnableRaisingEvents = false; + watcher.Dispose(); + } + } +} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/BrowserDetector.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/BrowserDetector.cs new file mode 100644 index 00000000000..f3e24916fbf --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/BrowserDetector.cs @@ -0,0 +1,39 @@ +#nullable enable +using Flow.Launcher.Plugin.BrowserBookmark.Models; +using System.IO; +using System.Linq; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Services; + +public static class BrowserDetector +{ + public static BrowserType DetectBrowserType(string dataDirectoryPath) + { + if (string.IsNullOrEmpty(dataDirectoryPath) || !Directory.Exists(dataDirectoryPath)) + return BrowserType.Unknown; + + // Check for Chromium-based browsers by looking for the 'Bookmarks' file. + // This includes checking common profile subdirectories. + var profileDirectories = Directory.EnumerateDirectories(dataDirectoryPath, "Profile *").ToList(); + var defaultProfile = Path.Combine(dataDirectoryPath, "Default"); + if (Directory.Exists(defaultProfile)) + profileDirectories.Add(defaultProfile); + + // Also check the root directory itself, as some browsers use it directly. + profileDirectories.Add(dataDirectoryPath); + + if (profileDirectories.Any(p => File.Exists(Path.Combine(p, "Bookmarks")))) + { + return BrowserType.Chromium; + } + + // Check for Firefox-based browsers by looking for 'places.sqlite'. + // This leverages the existing FirefoxProfileFinder logic. + if (File.Exists(Path.Combine(dataDirectoryPath, "places.sqlite")) || !string.IsNullOrEmpty(FirefoxProfileFinder.GetPlacesPathFromProfileDir(dataDirectoryPath))) + { + return BrowserType.Firefox; + } + + return BrowserType.Unknown; + } +} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/ChromiumBookmarkLoader.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/ChromiumBookmarkLoader.cs new file mode 100644 index 00000000000..eafd5fb6ea0 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/ChromiumBookmarkLoader.cs @@ -0,0 +1,116 @@ +#nullable enable +using Flow.Launcher.Plugin.BrowserBookmark.Models; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Services; + +public class ChromiumBookmarkLoader : IBookmarkLoader +{ + private readonly string _browserName; + private readonly string _browserDataPath; + private readonly Action _logException; + private readonly List _discoveredFiles; + + public string Name => _browserName; + + public ChromiumBookmarkLoader(string browserName, string browserDataPath, Action logException, List discoveredFiles) + { + _browserName = browserName; + _browserDataPath = browserDataPath; + _logException = logException; + _discoveredFiles = discoveredFiles; + } + + public async IAsyncEnumerable GetBookmarksAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (!Directory.Exists(_browserDataPath)) + yield break; + + var profileDirectories = Directory.EnumerateDirectories(_browserDataPath, "Profile *").ToList(); + var defaultProfile = Path.Combine(_browserDataPath, "Default"); + if(Directory.Exists(defaultProfile)) + profileDirectories.Add(defaultProfile); + + foreach (var profilePath in profileDirectories) + { + cancellationToken.ThrowIfCancellationRequested(); + var bookmarkPath = Path.Combine(profilePath, "Bookmarks"); + if (!File.Exists(bookmarkPath)) + continue; + + _discoveredFiles.Add(bookmarkPath); + var source = _browserName + (Path.GetFileName(profilePath) == "Default" ? "" : $" ({Path.GetFileName(profilePath)})"); + + var bookmarks = new List(); + try + { + await using var stream = File.OpenRead(bookmarkPath); + using var jsonDocument = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + + if (jsonDocument.RootElement.TryGetProperty("roots", out var rootElement)) + { + bookmarks.AddRange(EnumerateBookmarks(rootElement, source, profilePath)); + } + } + catch(JsonException ex) + { + _logException(nameof(ChromiumBookmarkLoader), $"Failed to parse bookmarks file for {_browserName}: {bookmarkPath}", ex); + } + + foreach (var bookmark in bookmarks) + { + yield return bookmark; + } + } + } + + private IEnumerable EnumerateBookmarks(JsonElement rootElement, string source, string profilePath) + { + var bookmarks = new List(); + foreach (var folder in rootElement.EnumerateObject()) + { + if (folder.Value.ValueKind == JsonValueKind.Object) + EnumerateFolderBookmark(folder.Value, bookmarks, source, profilePath); + } + return bookmarks; + } + + private void EnumerateFolderBookmark(JsonElement folderElement, ICollection bookmarks, string source, string profilePath) + { + if (!folderElement.TryGetProperty("children", out var childrenElement)) + return; + + foreach (var subElement in childrenElement.EnumerateArray()) + { + if (subElement.TryGetProperty("type", out var type)) + { + switch (type.GetString()) + { + case "folder": + case "workspace": // Edge Workspace + EnumerateFolderBookmark(subElement, bookmarks, source, profilePath); + break; + case "url": + if (subElement.TryGetProperty("name", out var name) && + subElement.TryGetProperty("url", out var url) && + !string.IsNullOrEmpty(name.GetString()) && + !string.IsNullOrEmpty(url.GetString())) + { + bookmarks.Add(new Bookmark(name.GetString()!, url.GetString()!, source, profilePath)); + } + break; + } + } + else + { + _logException(nameof(ChromiumBookmarkLoader), $"type property not found for {subElement.GetString()}", null); + } + } + } +} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/FaviconService.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/FaviconService.cs new file mode 100644 index 00000000000..2e1e4470137 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/FaviconService.cs @@ -0,0 +1,426 @@ +#nullable enable +using Flow.Launcher.Plugin.BrowserBookmark.Models; +using SkiaSharp; +using Svg.Skia; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Services; + +public partial class FaviconService : IDisposable +{ + private readonly PluginInitContext _context; + private readonly Settings _settings; + private readonly string _faviconCacheDir; + private readonly HttpClient _httpClient; + private readonly LocalFaviconExtractor _localExtractor; + + private readonly ConcurrentDictionary> _ongoingFetches = new(); + + [GeneratedRegex("]+>", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] + private static partial Regex LinkTagRegex(); + [GeneratedRegex("rel\\s*=\\s*(?:['\"](?[^'\"]*)['\"]|(?[^>\\s]+))", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] + private static partial Regex RelAttributeRegex(); + [GeneratedRegex("href\\s*=\\s*(?:['\"](?[^'\"]*)['\"]|(?[^>\\s]+))", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] + private static partial Regex HrefAttributeRegex(); + [GeneratedRegex("sizes\\s*=\\s*(?:['\"](?[^'\"]*)['\"]|(?[^>\\s]+))", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] + private static partial Regex SizesAttributeRegex(); + [GeneratedRegex("]+href\\s*=\\s*(?:['\"](?[^'\"]*)['\"]|(?[^>\\s]+))", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] + private static partial Regex BaseHrefRegex(); + + private record struct FaviconCandidate(string Url, int Score); + private record struct FetchResult(string? TempPath, int Size); + + public FaviconService(PluginInitContext context, Settings settings, string tempPath) + { + _context = context; + _settings = settings; + + _faviconCacheDir = Path.Combine(context.CurrentPluginMetadata.PluginCacheDirectoryPath, "FaviconCache"); + Directory.CreateDirectory(_faviconCacheDir); + + _localExtractor = new LocalFaviconExtractor(context, tempPath); + + var handler = new HttpClientHandler { AllowAutoRedirect = true }; + _httpClient = new HttpClient(handler); + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36"); + _httpClient.DefaultRequestHeaders.Accept.ParseAdd("text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"); + _httpClient.Timeout = TimeSpan.FromSeconds(5); + } + + public async Task ProcessBookmarkFavicons(IReadOnlyList bookmarks, CancellationToken cancellationToken) + { + if (!_settings.EnableFavicons) return; + + var options = new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = cancellationToken }; + + await Parallel.ForEachAsync(bookmarks, options, async (bookmark, token) => + { + var cachePath = GetCachePath(bookmark.Url, _faviconCacheDir); + if (File.Exists(cachePath)) + { + bookmark.FaviconPath = cachePath; + return; + } + + // 1. Try local browser database + var localData = await _localExtractor.GetFaviconDataAsync(bookmark, token); + if (localData != null) + { + var (pngData, _) = await ToPng(new MemoryStream(localData), token); + if (pngData != null) + { + await File.WriteAllBytesAsync(cachePath, pngData, token); + bookmark.FaviconPath = cachePath; + return; + } + } + + // 2. Fallback to web if enabled + if (_settings.FetchMissingFavicons && Uri.TryCreate(bookmark.Url, UriKind.Absolute, out var uri)) + { + var webFaviconPath = await GetFaviconFromWebAsync(uri, token); + if (!string.IsNullOrEmpty(webFaviconPath)) + { + bookmark.FaviconPath = webFaviconPath; + } + } + }); + } + + private Task GetFaviconFromWebAsync(Uri url, CancellationToken token) + { + if (url is null || (url.Scheme != "http" && url.Scheme != "https")) + { + return Task.FromResult(null); + } + var authority = url.GetLeftPart(UriPartial.Authority); + return _ongoingFetches.GetOrAdd(authority, key => FetchAndCacheFaviconAsync(new Uri(key), token)); + } + + private static string GetCachePath(string url, string cacheDir) + { + var hash = SHA1.HashData(Encoding.UTF8.GetBytes(url)); + var sb = new StringBuilder(hash.Length * 2); + foreach (byte b in hash) + { + sb.Append(b.ToString("x2")); + } + return Path.Combine(cacheDir, sb.ToString() + ".png"); + } + + private async Task FetchAndCacheFaviconAsync(Uri url, CancellationToken token) + { + var urlString = url.GetLeftPart(UriPartial.Authority); + var cachePath = GetCachePath(urlString, _faviconCacheDir); + if (File.Exists(cachePath)) return cachePath; + + using var overallCts = CancellationTokenSource.CreateLinkedTokenSource(token); + overallCts.CancelAfter(TimeSpan.FromSeconds(10)); + var linkedToken = overallCts.Token; + + FetchResult icoResult = default; + FetchResult htmlResult = default; + + try + { + var icoTask = FetchAndProcessUrlAsync(new Uri(url, "/favicon.ico"), linkedToken); + var htmlTask = FetchAndProcessHtmlAsync(url, linkedToken); + + await Task.WhenAll(icoTask, htmlTask); + + icoResult = icoTask.Result; + htmlResult = htmlTask.Result; + + var bestResult = SelectBestFavicon(icoResult, htmlResult); + + if (bestResult.TempPath != null) + { + File.Move(bestResult.TempPath, cachePath, true); + _context.API.LogDebug(nameof(FaviconService), $"Favicon for {urlString} cached successfully."); + return cachePath; + } + + _context.API.LogDebug(nameof(FaviconService), $"No suitable favicon found for {urlString} after all tasks."); + } + catch (OperationCanceledException) { /* Swallow cancellation */ } + catch (Exception ex) + { + _context.API.LogException(nameof(FaviconService), $"Error in favicon fetch for {urlString}", ex); + } + finally + { + if (icoResult.TempPath != null && File.Exists(icoResult.TempPath)) File.Delete(icoResult.TempPath); + if (htmlResult.TempPath != null && File.Exists(htmlResult.TempPath)) File.Delete(htmlResult.TempPath); + _ongoingFetches.TryRemove(urlString, out _); + } + + return null; + } + + private FetchResult SelectBestFavicon(FetchResult icoResult, FetchResult htmlResult) + { + if (htmlResult.Size >= 32) return htmlResult; + if (icoResult.Size >= 32) return icoResult; + if (htmlResult.Size > icoResult.Size) return htmlResult; + // If sizes are equal, prefer ico as it's the standard. If htmlResult was better, it would likely have a larger size. + if (icoResult.Size >= 0) return icoResult; + if (htmlResult.Size >= 0) return htmlResult; + return default; + } + + private async Task FetchAndProcessHtmlAsync(Uri pageUri, CancellationToken token) + { + var bestCandidate = await GetBestCandidateFromHtmlAsync(pageUri, token); + if (bestCandidate != null && Uri.TryCreate(bestCandidate.Value.Url, UriKind.Absolute, out var candidateUri)) + { + return await FetchAndProcessUrlAsync(candidateUri, token); + } + return default; + } + + private async Task FetchAndProcessUrlAsync(Uri faviconUri, CancellationToken token) + { + var tempPath = Path.GetTempFileName(); + try + { + _context.API.LogDebug(nameof(FaviconService), $"Attempting to fetch favicon: {faviconUri}"); + using var request = new HttpRequestMessage(HttpMethod.Get, faviconUri); + var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, token); + + if (!response.IsSuccessStatusCode) + { + _context.API.LogDebug(nameof(FaviconService), $"Fetch failed for {faviconUri} with status code {response.StatusCode}"); + File.Delete(tempPath); + return default; + } + + await using var contentStream = await response.Content.ReadAsStreamAsync(token); + var (pngData, size) = await ToPng(contentStream, token); + + if (pngData is { Length: > 0 }) + { + await File.WriteAllBytesAsync(tempPath, pngData, token); + _context.API.LogDebug(nameof(FaviconService), $"Successfully processed favicon for {faviconUri} with original size {size}x{size}"); + return new FetchResult(tempPath, size); + } + + _context.API.LogDebug(nameof(FaviconService), $"Failed to process or invalid image for {faviconUri}."); + } + catch (OperationCanceledException) { _context.API.LogDebug(nameof(FaviconService), $"Favicon fetch cancelled for {faviconUri}."); } + catch (Exception ex) when (ex is HttpRequestException or NotSupportedException) + { + _context.API.LogDebug(nameof(FaviconService), $"Favicon fetch/process failed for {faviconUri}: {ex.Message}"); + } + + File.Delete(tempPath); + return default; + } + + private async Task GetBestCandidateFromHtmlAsync(Uri pageUri, CancellationToken token) + { + try + { + var response = await _httpClient.GetAsync(pageUri, HttpCompletionOption.ResponseHeadersRead, token); + if (!response.IsSuccessStatusCode) return null; + + var baseUri = response.RequestMessage?.RequestUri ?? pageUri; + + await using var stream = await response.Content.ReadAsStreamAsync(token); + using var reader = new StreamReader(stream, Encoding.UTF8, true); + + var contentBuilder = new StringBuilder(); + var buffer = new char[4096]; + int charsRead; + var totalCharsRead = 0; + const int maxCharsToRead = 500 * 1024; + + while (!token.IsCancellationRequested && (charsRead = await reader.ReadAsync(buffer, 0, buffer.Length)) > 0 && totalCharsRead < maxCharsToRead) + { + contentBuilder.Append(buffer, 0, charsRead); + totalCharsRead += charsRead; + + if (contentBuilder.ToString().Contains("", StringComparison.OrdinalIgnoreCase)) + { + break; + } + } + + return ParseLinkTags(contentBuilder.ToString(), baseUri) + .OrderByDescending(c => c.Score) + .FirstOrDefault(); + } + catch (OperationCanceledException) { return null; } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + _context.API.LogDebug(nameof(FaviconService), $"Failed to fetch or parse HTML head for {pageUri}: {ex.Message}"); + } + return null; + } + + private static List ParseLinkTags(string htmlContent, Uri originalBaseUri) + { + var candidates = new List(); + var effectiveBaseUri = originalBaseUri; + + var baseMatch = BaseHrefRegex().Match(htmlContent); + if (baseMatch.Success) + { + var baseHref = baseMatch.Groups["v"].Value; + if (Uri.TryCreate(originalBaseUri, baseHref, out var newBaseUri)) + { + effectiveBaseUri = newBaseUri; + } + } + + foreach (Match linkMatch in LinkTagRegex().Matches(htmlContent)) + { + var linkTag = linkMatch.Value; + var relMatch = RelAttributeRegex().Match(linkTag); + if (!relMatch.Success || !relMatch.Groups["v"].Value.Contains("icon", StringComparison.OrdinalIgnoreCase)) continue; + + var hrefMatch = HrefAttributeRegex().Match(linkTag); + if (!hrefMatch.Success) continue; + + var href = hrefMatch.Groups["v"].Value; + if (string.IsNullOrWhiteSpace(href)) continue; + + if (href.StartsWith("//")) + { + href = effectiveBaseUri.Scheme + ":" + href; + } + + if (!Uri.TryCreate(effectiveBaseUri, href, out var fullUrl)) continue; + + candidates.Add(new FaviconCandidate(fullUrl.ToString(), CalculateFaviconScore(linkTag, fullUrl.ToString()))); + } + + return candidates; + } + + private static int CalculateFaviconScore(string linkTag, string fullUrl) + { + var extension = Path.GetExtension(fullUrl).ToUpperInvariant(); + if (extension == ".SVG") return 10000; + + var sizesMatch = SizesAttributeRegex().Match(linkTag); + if (sizesMatch.Success) + { + var sizesValue = sizesMatch.Groups["v"].Value.ToUpperInvariant(); + if (sizesValue == "ANY") return 1000; + + var firstSizePart = sizesValue.Split(' ')[0]; + if (int.TryParse(firstSizePart.Split('X')[0], out var size)) + { + return size; + } + } + + if (extension == ".ICO") return 32; // Default score for .ico is 32 as it's likely to contain that size + + return 16; // Default score for other bitmaps + } + +private async Task<(byte[]? PngData, int Size)> ToPng(Stream stream, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + + await using var ms = new MemoryStream(); + await stream.CopyToAsync(ms, token); + + ms.Position = 0; + + try + { + using var svg = new SKSvg(); + if (svg.Load(ms) is not null && svg.Picture is not null) + { + using var bitmap = new SKBitmap(32, 32); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.Transparent); + var scaleMatrix = SKMatrix.CreateScale(32 / svg.Picture.CullRect.Width, 32 / svg.Picture.CullRect.Height); + canvas.DrawPicture(svg.Picture, in scaleMatrix); + + using var image = SKImage.FromBitmap(bitmap); + using var data = image.Encode(SKEncodedImageFormat.Png, 80); + return (data.ToArray(), 32); + } + } + catch { /* Not an SVG */ } + + ms.Position = 0; + try + { + var decoder = new IconBitmapDecoder(ms, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad); + if (decoder.Frames.Any()) + { + var largestFrame = decoder.Frames.OrderByDescending(f => f.Width * f.Height).First(); + + await using var pngStream = new MemoryStream(); + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(largestFrame)); + encoder.Save(pngStream); + + pngStream.Position = 0; + using var original = SKBitmap.Decode(pngStream); + if (original != null) + { + var originalWidth = original.Width; + var info = new SKImageInfo(32, 32, original.ColorType, original.AlphaType); + using var resized = original.Resize(info, new SKSamplingOptions(SKCubicResampler.Mitchell)); + if (resized != null) + { + using var image = SKImage.FromBitmap(resized); + using var data = image.Encode(SKEncodedImageFormat.Png, 80); + return (data.ToArray(), originalWidth); + } + } + } + } + catch (Exception ex) + { + _context.API.LogDebug(nameof(FaviconService), $"Could not decode stream as ICO: {ex.Message}"); + } + + ms.Position = 0; + try + { + using var original = SKBitmap.Decode(ms); + if (original != null) + { + var originalWidth = original.Width; + var info = new SKImageInfo(32, 32, original.ColorType, original.AlphaType); + using var resized = original.Resize(info, new SKSamplingOptions(SKCubicResampler.Mitchell)); + if (resized != null) + { + using var image = SKImage.FromBitmap(resized); + using var data = image.Encode(SKEncodedImageFormat.Png, 80); + return (data.ToArray(), originalWidth); + } + } + } + catch (Exception ex) + { + _context.API.LogException(nameof(FaviconService), "Failed to decode or convert bitmap", ex); + return (null, 0); + } + + return (null, 0); + } + + public void Dispose() + { + _httpClient.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/FirefoxBookmarkLoader.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/FirefoxBookmarkLoader.cs new file mode 100644 index 00000000000..bd946a240cc --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/FirefoxBookmarkLoader.cs @@ -0,0 +1,106 @@ +#nullable enable +using Flow.Launcher.Plugin.BrowserBookmark.Models; +using Microsoft.Data.Sqlite; +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Services; + +public class FirefoxBookmarkLoader : IBookmarkLoader +{ + private readonly string _browserName; + private readonly string _placesPath; + private readonly Action _logException; + private readonly string _tempPath; + + public string Name => _browserName; + + private const string QueryAllBookmarks = """ + SELECT moz_places.url, moz_bookmarks.title + FROM moz_places + INNER JOIN moz_bookmarks ON ( + moz_bookmarks.fk NOT NULL AND moz_bookmarks.title NOT NULL AND moz_bookmarks.fk = moz_places.id + ) + ORDER BY moz_places.visit_count DESC + """; + + public FirefoxBookmarkLoader(string browserName, string placesPath, string tempPath, Action logException) + { + _browserName = browserName; + _placesPath = placesPath; + _logException = logException; + _tempPath = tempPath; + } + + public async IAsyncEnumerable GetBookmarksAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(_placesPath) || !File.Exists(_placesPath)) + yield break; + + var bookmarks = new List(); + string? tempDbPath = null; + + try + { + // First, try to read directly from the source to avoid a slow file copy + await ReadBookmarksFromDb(_placesPath, bookmarks, cancellationToken); + } + catch (SqliteException ex) when (ex.SqliteErrorCode is 5 or 6) // 5 is SQLITE_BUSY, 6 is SQLITE_LOCKED + { + // Fallback to copying the file if the database is locked (e.g., Firefox is open) + try + { + tempDbPath = Path.Combine(_tempPath, $"ff_places_{Guid.NewGuid()}.sqlite"); + File.Copy(_placesPath, tempDbPath, true); + await ReadBookmarksFromDb(tempDbPath, bookmarks, cancellationToken); + } + catch (Exception copyEx) + { + _logException(nameof(FirefoxBookmarkLoader), $"Failed to load {_browserName} bookmarks from fallback copy: {_placesPath}", copyEx); + } + } + catch (Exception e) + { + _logException(nameof(FirefoxBookmarkLoader), $"Failed to load {_browserName} bookmarks: {_placesPath}", e); + } + finally + { + if (tempDbPath != null && File.Exists(tempDbPath)) + { + try { File.Delete(tempDbPath); } + catch(Exception e) { _logException(nameof(FirefoxBookmarkLoader), $"Failed to delete temp db file {tempDbPath}", e); } + } + } + + foreach (var bookmark in bookmarks) + { + yield return bookmark; + } + } + + private async Task ReadBookmarksFromDb(string dbPath, ICollection bookmarks, CancellationToken cancellationToken) + { + var profilePath = Path.GetDirectoryName(dbPath) ?? string.Empty; + var connectionString = $"Data Source={dbPath};Mode=ReadOnly;Pooling=false;"; + + await using var dbConnection = new SqliteConnection(connectionString); + await dbConnection.OpenAsync(cancellationToken); + await using var command = new SqliteCommand(QueryAllBookmarks, dbConnection); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + + while (await reader.ReadAsync(cancellationToken)) + { + var title = reader["title"]?.ToString() ?? string.Empty; + var url = reader["url"]?.ToString(); + + if (!string.IsNullOrEmpty(url)) + { + bookmarks.Add(new Bookmark(title, url, _browserName, profilePath)); + } + } + } +} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/FirefoxProfileFinder.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/FirefoxProfileFinder.cs new file mode 100644 index 00000000000..26e323207bf --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/FirefoxProfileFinder.cs @@ -0,0 +1,83 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Services; + +public static class FirefoxProfileFinder +{ + public static string? GetFirefoxPlacesPath() + { + // Standard MSI installer path + var standardPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), @"Mozilla\Firefox"); + var placesPath = GetPlacesPathFromProfileDir(standardPath); + + return !string.IsNullOrEmpty(placesPath) ? placesPath : null; + } + + public static string? GetFirefoxMsixPlacesPath() + { + var packagesPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Packages"); + if (!Directory.Exists(packagesPath)) + return null; + + try + { + var firefoxPackageFolder = Directory.EnumerateDirectories(packagesPath, "Mozilla.Firefox*", SearchOption.TopDirectoryOnly).FirstOrDefault(); + if (firefoxPackageFolder == null) + return null; + + var profileFolderPath = Path.Combine(firefoxPackageFolder, @"LocalCache\Roaming\Mozilla\Firefox"); + return GetPlacesPathFromProfileDir(profileFolderPath); + } + catch + { + // Logged in the calling service + return null; + } + } + + public static string? GetPlacesPathFromProfileDir(string profileFolderPath) + { + var profileIni = Path.Combine(profileFolderPath, @"profiles.ini"); + if (!File.Exists(profileIni)) + return null; + + try + { + var iniContent = File.ReadAllText(profileIni); + // Try to find the default-release profile first, which is the most common case. + var profileSectionMatch = Regex.Match(iniContent, @"\[Profile[^\]]+\]\s*Name=default-release[\s\S]+?Path=([^\r\n]+)[\s\S]+?Default=1", RegexOptions.IgnoreCase); + + // Fallback to any default profile. + if (!profileSectionMatch.Success) + { + profileSectionMatch = Regex.Match(iniContent, @"\[Profile[^\]]+\][\s\S]+?Path=([^\r\n]+)[\s\S]+?Default=1", RegexOptions.IgnoreCase); + } + + // Fallback to the first available profile if no default is marked. + if (!profileSectionMatch.Success) + { + profileSectionMatch = Regex.Match(iniContent, @"\[Profile[^\]]+\][\s\S]+?Path=([^\r\n]+)"); + } + + if (!profileSectionMatch.Success) + return null; + + var path = profileSectionMatch.Groups[1].Value; + var isRelative = !path.Contains(':'); + + var profilePath = isRelative ? Path.Combine(profileFolderPath, path.Replace('/', Path.DirectorySeparatorChar)) : path; + var placesDb = Path.Combine(profilePath, "places.sqlite"); + + return File.Exists(placesDb) ? placesDb : null; + } + catch + { + // Logged in the calling service + return null; + } + } +} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/IBookmarkLoader.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/IBookmarkLoader.cs new file mode 100644 index 00000000000..cf7f8aec3bc --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/IBookmarkLoader.cs @@ -0,0 +1,11 @@ +using Flow.Launcher.Plugin.BrowserBookmark.Models; +using System.Collections.Generic; +using System.Threading; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Services; + +public interface IBookmarkLoader +{ + string Name { get; } + IAsyncEnumerable GetBookmarksAsync(CancellationToken cancellationToken = default); +} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/LocalFaviconExtractor.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/LocalFaviconExtractor.cs new file mode 100644 index 00000000000..cd8aba9bdf3 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/LocalFaviconExtractor.cs @@ -0,0 +1,136 @@ +#nullable enable +using Flow.Launcher.Plugin.BrowserBookmark.Models; +using Microsoft.Data.Sqlite; +using System; +using System.IO; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Services; + +public class LocalFaviconExtractor +{ + private readonly PluginInitContext _context; + private readonly string _tempPath; + + public LocalFaviconExtractor(PluginInitContext context, string tempPath) + { + _context = context; + _tempPath = tempPath; + } + + public async Task GetFaviconDataAsync(Bookmark bookmark, CancellationToken token) + { + return bookmark.Source switch + { + var s when s.Contains("Firefox") => await GetFirefoxFaviconAsync(bookmark, token), + _ => await GetChromiumFaviconAsync(bookmark, token) // Default to Chromium + }; + } + + private Task GetChromiumFaviconAsync(Bookmark bookmark, CancellationToken token) + { + const string query = @" + SELECT b.image_data FROM favicon_bitmaps b + JOIN icon_mapping m ON b.icon_id = m.icon_id + WHERE m.page_url = @url + ORDER BY b.width DESC LIMIT 1"; + + return GetFaviconFromDbAsync(bookmark, "Favicons", query, null, token); + } + + private Task GetFirefoxFaviconAsync(Bookmark bookmark, CancellationToken token) + { + const string query = @" + SELECT i.data FROM moz_icons i + JOIN moz_icons_to_pages ip ON i.id = ip.icon_id + JOIN moz_pages_w_icons p ON ip.page_id = p.id + WHERE p.page_url = @url + ORDER BY i.width DESC LIMIT 1"; + + return GetFaviconFromDbAsync(bookmark, "favicons.sqlite", query, PostProcessFirefoxFavicon, token); + } + + private async Task GetFaviconFromDbAsync(Bookmark bookmark, string dbFileName, string query, + Func>? postProcessor, CancellationToken token) + { + var dbPath = Path.Combine(bookmark.ProfilePath, dbFileName); + if (!File.Exists(dbPath)) + return null; + + var tempDbPath = Path.Combine(_tempPath, $"{Path.GetFileNameWithoutExtension(dbFileName)}_{Guid.NewGuid()}{Path.GetExtension(dbFileName)}"); + + try + { + File.Copy(dbPath, tempDbPath, true); + + var connectionString = $"Data Source={tempDbPath};Mode=ReadOnly;Pooling=false;"; + await using var connection = new SqliteConnection(connectionString); + await connection.OpenAsync(token); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = query; + cmd.Parameters.AddWithValue("@url", bookmark.Url); + + if (await cmd.ExecuteScalarAsync(token) is not byte[] data || data.Length == 0) + return null; + + _context.API.LogDebug(nameof(LocalFaviconExtractor), $"Extracted {data.Length} bytes for {bookmark.Url} from {dbFileName}."); + + return postProcessor != null ? await postProcessor(data, token) : data; + } + catch (Exception ex) + { + _context.API.LogException(nameof(LocalFaviconExtractor), $"Failed to extract favicon for {bookmark.Url} from {bookmark.Source}'s {dbFileName}", ex); + return null; + } + finally + { + CleanupTempFiles(tempDbPath); + } + } + + private async Task PostProcessFirefoxFavicon(byte[] imageData, CancellationToken token) + { + // Handle old GZipped favicons + if (imageData.Length > 2 && imageData[0] == 0x1f && imageData[1] == 0x8b) + { + await using var inputStream = new MemoryStream(imageData); + await using var gZipStream = new GZipStream(inputStream, CompressionMode.Decompress); + await using var outputStream = new MemoryStream(); + await gZipStream.CopyToAsync(outputStream, token); + return outputStream.ToArray(); + } + + return imageData; + } + + private void CleanupTempFiles(string mainTempDbPath) + { + // This method ensures that the main temp file and any of its associated files + // (e.g., -wal, -shm) are deleted. + try + { + var directory = Path.GetDirectoryName(mainTempDbPath); + var baseName = Path.GetFileName(mainTempDbPath); + if (directory == null || !Directory.Exists(directory)) return; + + foreach (var file in Directory.GetFiles(directory, baseName + "*")) + { + try + { + File.Delete(file); + } + catch (Exception ex) + { + // Log failure to delete a specific chunk, but don't stop the process + _context.API.LogException(nameof(LocalFaviconExtractor), $"Failed to delete temporary file chunk: {file}", ex); + } + } + } + catch (Exception ex) + { + _context.API.LogException(nameof(LocalFaviconExtractor), $"Failed to clean up temporary files for base: {mainTempDbPath}", ex); + } + } +} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/ViewModels/CustomBrowserSettingViewModel.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/ViewModels/CustomBrowserSettingViewModel.cs new file mode 100644 index 00000000000..e1a2c5a979b --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/ViewModels/CustomBrowserSettingViewModel.cs @@ -0,0 +1,101 @@ +#nullable enable +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Flow.Launcher.Plugin.BrowserBookmark.Models; +using Flow.Launcher.Plugin.BrowserBookmark.Services; +using System; +using System.ComponentModel; +using System.IO; +using System.Windows.Forms; +using Flow.Launcher.Plugin.SharedCommands; + +namespace Flow.Launcher.Plugin.BrowserBookmark.ViewModels; + +public partial class CustomBrowserSettingViewModel : ObservableObject +{ + private readonly CustomBrowser _originalBrowser; + private readonly Action _closeAction; + + [ObservableProperty] + private CustomBrowser _editableBrowser; + + public string DetectedEngineText + { + get + { + if (string.IsNullOrEmpty(EditableBrowser.DataDirectoryPath)) + { + return Localize.flowlauncher_plugin_browserbookmark_engine_detection_select_directory(); + } + + return EditableBrowser.BrowserType switch + { + BrowserType.Unknown => Localize.flowlauncher_plugin_browserbookmark_engine_detection_invalid(), + BrowserType.Chromium => Localize.flowlauncher_plugin_browserbookmark_engine_detection_chromium(), + BrowserType.Firefox => Localize.flowlauncher_plugin_browserbookmark_engine_detection_firefox(), + _ => string.Empty + }; + } + } + + public bool IsValidPath => EditableBrowser.BrowserType != BrowserType.Unknown; + + public CustomBrowserSettingViewModel(CustomBrowser browser, Action closeAction) + { + _originalBrowser = browser; + _closeAction = closeAction; + EditableBrowser = new CustomBrowser + { + Name = browser.Name, + DataDirectoryPath = browser.DataDirectoryPath + }; + EditableBrowser.PropertyChanged += EditableBrowser_PropertyChanged; + DetectEngineType(); + } + + private void EditableBrowser_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(CustomBrowser.DataDirectoryPath)) + { + DetectEngineType(); + } + } + + private void DetectEngineType() + { + EditableBrowser.BrowserType = BrowserDetector.DetectBrowserType(EditableBrowser.DataDirectoryPath); + OnPropertyChanged(nameof(DetectedEngineText)); + OnPropertyChanged(nameof(IsValidPath)); + SaveCommand.NotifyCanExecuteChanged(); + } + + [RelayCommand(CanExecute = nameof(IsValidPath))] + private void Save() + { + _originalBrowser.Name = EditableBrowser.Name; + _originalBrowser.DataDirectoryPath = EditableBrowser.DataDirectoryPath; + _originalBrowser.BrowserType = EditableBrowser.BrowserType; + _closeAction(true); + } + + [RelayCommand] + private void Cancel() + { + _closeAction(false); + } + + [RelayCommand] + private void BrowseDataDirectory() + { + var dialog = new FolderBrowserDialog(); + if (!string.IsNullOrEmpty(EditableBrowser.DataDirectoryPath) && Directory.Exists(EditableBrowser.DataDirectoryPath)) + { + dialog.SelectedPath = EditableBrowser.DataDirectoryPath; + } + + if (dialog.ShowDialog() == DialogResult.OK) + { + EditableBrowser.DataDirectoryPath = dialog.SelectedPath; + } + } +} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/CustomBrowserSetting.xaml b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/CustomBrowserSetting.xaml index f67d359bffa..99a3d4d5af6 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/CustomBrowserSetting.xaml +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/CustomBrowserSetting.xaml @@ -1,12 +1,14 @@ - - + - - - @@ -38,7 +38,7 @@