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 8392a0dbe63..875ffd7cf46 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj @@ -1,4 +1,4 @@ - + Library 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..38fcb51d599 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs @@ -1,213 +1,245 @@ -using System; +#nullable enable +using System; using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; using System.IO; using System.Linq; -using System.Threading.Channels; -using System.Threading.Tasks; using System.Threading; +using System.Threading.Tasks; using System.Windows.Controls; -using Flow.Launcher.Plugin.BrowserBookmark.Commands; using Flow.Launcher.Plugin.BrowserBookmark.Models; -using Flow.Launcher.Plugin.BrowserBookmark.Views; -using Flow.Launcher.Plugin.SharedCommands; +using Flow.Launcher.Plugin.BrowserBookmark.Services; 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 PluginInitContext Context { get; set; } = null!; + private static Settings _settings = null!; - internal static Settings _settings; + private BookmarkLoaderService _bookmarkLoader = null!; + private FaviconService _faviconService = null!; + private BookmarkWatcherService _bookmarkWatcher = null!; - private static List _cachedBookmarks = new(); + private List _bookmarks = []; + private readonly CancellationTokenSource _cancellationTokenSource = new(); + private PeriodicTimer? _firefoxBookmarkTimer; + private static readonly TimeSpan FirefoxPollingInterval = TimeSpan.FromHours(3); + private readonly SemaphoreSlim _reloadGate = new(1, 1); - private static bool _initialized = false; - public void Init(PluginInitContext context) { Context = context; - _settings = context.API.LoadSettingJsonStorage(); + _settings.PropertyChanged += OnSettingsPropertyChanged; + _settings.CustomBrowsers.CollectionChanged += OnCustomBrowsersChanged; + + var tempPath = SetupTempDirectory(); + + _bookmarkLoader = new BookmarkLoaderService(_settings, tempPath); + _faviconService = new FaviconService(_settings, tempPath); + _bookmarkWatcher = new BookmarkWatcherService(); + _bookmarkWatcher.OnBookmarkFileChanged += OnBookmarkFileChanged; - _faviconCacheDir = Path.Combine( - context.CurrentPluginMetadata.PluginCacheDirectoryPath, - "FaviconCache"); - + // 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 = Volatile.Read(ref _bookmarks); // use a local copy with proper memory barrier + + 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 static 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(); - } + Context.API.OpenUrl(bookmark.Url); + return true; + }, + ContextData = bookmark.Url + }; - string param = query.Search.TrimStart(); + public async Task ReloadDataAsync() + { + await _reloadGate.WaitAsync(_cancellationTokenSource.Token); + try + { + var (bookmarks, discoveredFiles) = await _bookmarkLoader.LoadBookmarksAsync(_cancellationTokenSource.Token); - // Should top results be returned? (true if no search parameters have been passed) - var topResults = string.IsNullOrEmpty(param); + // Atomically swap the list. This prevents the Query method from seeing a partially loaded list. + Volatile.Write(ref _bookmarks, bookmarks); - if (!topResults) + _bookmarkWatcher.UpdateWatchers(discoveredFiles); + + // Fire and forget favicon processing to not block the UI + _ = _faviconService.ProcessBookmarkFavicons(_bookmarks, _cancellationTokenSource.Token); + } + finally { - // 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); - - return true; - }, - ContextData = new BookmarkAttributes { Url = c.Url } - } - ) - .Where(r => r.Score > 0) - .ToList(); + _reloadGate.Release(); } - else + } + + 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 void OnCustomBrowsersChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + StartFirefoxBookmarkTimer(); + _ = ReloadDataAsync(); + } - private static readonly SemaphoreSlim _fileMonitorSemaphore = new(1, 1); + 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); + + var timer = _firefoxBookmarkTimer!; + _ = Task.Run(async () => { - if (reader.TryRead(out _)) + try { - ReloadAllBookmarks(false); + while (await timer.WaitForNextTickAsync(_cancellationTokenSource.Token)) + { + await ReloadFirefoxBookmarksAsync(); + } } - } - _fileMonitorSemaphore.Release(); + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + }, _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))) + // Share the same gate to avoid conflicting with full reloads + await _reloadGate.WaitAsync(_cancellationTokenSource.Token); + try { - return; - } + Context.API.LogInfo(nameof(Main), "Starting periodic reload of Firefox bookmarks."); - var watcher = new FileSystemWatcher(directory!) - { - Filter = Path.GetFileName(path), - NotifyFilter = NotifyFilters.FileName | - NotifyFilters.LastWrite | - NotifyFilters.Size - }; + var firefoxLoaders = _bookmarkLoader.GetFirefoxBookmarkLoaders().ToList(); + if (firefoxLoaders.Count == 0) + { + Context.API.LogInfo(nameof(Main), "No Firefox bookmark loaders enabled, skipping reload."); + return; + } - watcher.Changed += static (_, _) => - { - _refreshQueue.Writer.TryWrite(default); - }; + var tasks = firefoxLoaders.Select(async loader => + { + var loadedBookmarks = new List(); + try + { + await foreach (var bookmark in loader.GetBookmarksAsync(_cancellationTokenSource.Token)) + { + loadedBookmarks.Add(bookmark); + } + return (Loader: loader, Bookmarks: loadedBookmarks, Success: true); + } + catch (OperationCanceledException) + { + return (Loader: loader, Bookmarks: new List(), Success: false); + } + catch (Exception e) + { + Context.API.LogException(nameof(Main), $"Failed to load bookmarks from {loader.Name}.", e); + return (Loader: loader, Bookmarks: new List(), Success: false); + } + }); - watcher.Renamed += static (_, _) => - { - _refreshQueue.Writer.TryWrite(default); - }; + var results = await Task.WhenAll(tasks); + var successfulResults = results.Where(r => r.Success).ToList(); - watcher.EnableRaisingEvents = true; + if (successfulResults.Count == 0) + { + Context.API.LogInfo(nameof(Main), "No Firefox bookmarks successfully reloaded."); + return; + } - Watchers.Add(watcher); - } + var newFirefoxBookmarks = successfulResults.SelectMany(r => r.Bookmarks).ToList(); + var successfulLoaderNames = successfulResults.Select(r => r.Loader.Name).ToHashSet(); - public void ReloadData() - { - ReloadAllBookmarks(); + var currentBookmarks = Volatile.Read(ref _bookmarks); + + var otherBookmarks = currentBookmarks.Where(b => !successfulLoaderNames.Any(name => b.Source.StartsWith(name, StringComparison.OrdinalIgnoreCase))); + + var newBookmarkList = otherBookmarks.Concat(newFirefoxBookmarks).Distinct().ToList(); + + Volatile.Write(ref _bookmarks, newBookmarkList); + + Context.API.LogInfo(nameof(Main), $"Periodic reload complete. Loaded {newFirefoxBookmarks.Count} Firefox bookmarks from {successfulLoaderNames.Count} sources."); + + _ = _faviconService.ProcessBookmarkFavicons(newFirefoxBookmarks, _cancellationTokenSource.Token); + } + finally + { + _reloadGate.Release(); + } } - public static void ReloadAllBookmarks(bool disposeFileWatchers = true) + public Control CreateSettingPanel() { - _cachedBookmarks.Clear(); - if (disposeFileWatchers) - DisposeFileWatchers(); - LoadBookmarksIfEnabled(); + return new Views.SettingsControl(_settings); } public string GetTranslatedPluginTitle() @@ -220,56 +252,45 @@ 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 []; + + return + [ 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 { - 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/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..f6258bc5644 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/CustomBrowser.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/CustomBrowser.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using Flow.Launcher.Localization.Attributes; namespace Flow.Launcher.Plugin.BrowserBookmark.Models; @@ -7,7 +6,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 +34,6 @@ public string DataDirectoryPath } } - public List AllBrowserTypes { get; } = BrowserTypeLocalized.GetValues(); - public BrowserType BrowserType { get => _browserType; @@ -54,6 +51,9 @@ public BrowserType BrowserType [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..37f4ad29bad 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/Settings.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/Settings.cs @@ -4,15 +4,103 @@ 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 + { + if (_loadChromeBookmark != value) + { + _loadChromeBookmark = value; + OnPropertyChanged(); + } + } + } - public bool EnableFavicons { get; set; } = false; + public bool LoadFirefoxBookmark + { + get => _loadFirefoxBookmark; + set + { + if (_loadFirefoxBookmark != value) + { + _loadFirefoxBookmark = value; + OnPropertyChanged(); + } + } + } - public bool LoadChromeBookmark { get; set; } = true; - public bool LoadFirefoxBookmark { get; set; } = true; - public bool LoadEdgeBookmark { get; set; } = true; + public bool LoadEdgeBookmark + { + get => _loadEdgeBookmark; + set + { + if (_loadEdgeBookmark != value) + { + _loadEdgeBookmark = value; + OnPropertyChanged(); + } + } + } - public ObservableCollection CustomChromiumBrowsers { get; set; } = new(); + public bool LoadChromiumBookmark + { + get => _loadChromiumBookmark; + set + { + if (_loadChromiumBookmark != value) + { + _loadChromiumBookmark = value; + OnPropertyChanged(); + } + } + } + + public bool EnableFavicons + { + get => _enableFavicons; + set + { + if (_enableFavicons != value) + { + _enableFavicons = value; + OnPropertyChanged(); + } + } + } + + public bool FetchMissingFavicons + { + get => _fetchMissingFavicons; + set + { + if (_fetchMissingFavicons != value) + { + _fetchMissingFavicons = value; + OnPropertyChanged(); + } + } + } + + private ObservableCollection _customBrowsers = []; + + public ObservableCollection CustomBrowsers + { + get => _customBrowsers; + set + { + if (_customBrowsers != value) + { + _customBrowsers = value ?? []; + OnPropertyChanged(); + } + } + } } 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..1ff5972e3c5 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/BookmarkLoaderService.cs @@ -0,0 +1,160 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Flow.Launcher.Plugin.BrowserBookmark.Models; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Services; + +public class BookmarkLoaderService +{ + private readonly Settings _settings; + private readonly string _tempPath; + + public BookmarkLoaderService(Settings settings, string tempPath) + { + _settings = settings; + _tempPath = tempPath; + } + + public async Task<(List Bookmarks, List DiscoveredFiles)> LoadBookmarksAsync(CancellationToken cancellationToken) + { + var discoveredBookmarkFiles = new ConcurrentBag(); + var bookmarks = new ConcurrentBag(); + var loaders = GetBookmarkLoaders(discoveredBookmarkFiles); + + 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) + { + Main.Context.API.LogException(nameof(BookmarkLoaderService), $"Failed to load bookmarks from {loader.Name}.", e); + } + }); + + await Task.WhenAll(tasks); + + return (bookmarks.Distinct().ToList(), discoveredBookmarkFiles.Distinct().ToList()); + } + + public IEnumerable GetBookmarkLoaders(ConcurrentBag discoveredBookmarkFiles) + { + return GetChromiumBookmarkLoaders(discoveredBookmarkFiles).Concat(GetFirefoxBookmarkLoaders()); + } + + public IEnumerable GetChromiumBookmarkLoaders(ConcurrentBag discoveredBookmarkFiles) + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + + if (_settings.LoadChromeBookmark) + { + var path = Path.Combine(localAppData, @"Google\Chrome\User Data"); + if (Directory.Exists(path)) + yield return new ChromiumBookmarkLoader("Google Chrome", path, discoveredBookmarkFiles); + + var canaryPath = Path.Combine(localAppData, @"Google\Chrome SxS\User Data"); + if (Directory.Exists(canaryPath)) + yield return new ChromiumBookmarkLoader("Google Chrome Canary", canaryPath, discoveredBookmarkFiles); + } + + if (_settings.LoadEdgeBookmark) + { + var path = Path.Combine(localAppData, @"Microsoft\Edge\User Data"); + if (Directory.Exists(path)) + yield return new ChromiumBookmarkLoader("Microsoft Edge", path, discoveredBookmarkFiles); + + var devPath = Path.Combine(localAppData, @"Microsoft\Edge Dev\User Data"); + if (Directory.Exists(devPath)) + yield return new ChromiumBookmarkLoader("Microsoft Edge Dev", devPath, discoveredBookmarkFiles); + + var canaryPath = Path.Combine(localAppData, @"Microsoft\Edge SxS\User Data"); + if (Directory.Exists(canaryPath)) + yield return new ChromiumBookmarkLoader("Microsoft Edge Canary", canaryPath, discoveredBookmarkFiles); + } + + if (_settings.LoadChromiumBookmark) + { + var path = Path.Combine(localAppData, @"Chromium\User Data"); + if (Directory.Exists(path)) + yield return new ChromiumBookmarkLoader("Chromium", path, 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, discoveredBookmarkFiles); + } + } + + public IEnumerable GetFirefoxBookmarkLoaders() + { + if (_settings.LoadFirefoxBookmark) + { + string? placesPath = null; + try + { + placesPath = FirefoxProfileFinder.GetFirefoxPlacesPath(); + } + catch (Exception ex) + { + Main.Context.API.LogException(nameof(BookmarkLoaderService), "Failed to find Firefox profile", ex); + } + if (!string.IsNullOrEmpty(placesPath)) + { + yield return new FirefoxBookmarkLoader("Firefox", placesPath, _tempPath); + } + + string? msixPlacesPath = null; + try + { + msixPlacesPath = FirefoxProfileFinder.GetFirefoxMsixPlacesPath(); + } + catch (Exception ex) + { + Main.Context.API.LogException(nameof(BookmarkLoaderService), "Failed to find Firefox MSIX package", ex); + } + if (!string.IsNullOrEmpty(msixPlacesPath)) + { + yield return new FirefoxBookmarkLoader("Firefox (Store)", msixPlacesPath, _tempPath); + } + } + + 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) + { + // 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); + } +} 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..0036ccdf885 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/BookmarkWatcherService.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +namespace Flow.Launcher.Plugin.BrowserBookmark.Services; + +public class BookmarkWatcherService : IDisposable +{ + private readonly List _watchers = []; + public event Action OnBookmarkFileChanged; + + // Timer to debounce file change events + private readonly Timer _debounceTimer; + private readonly Lock _lock = new(); + private volatile bool _disposed; + + 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) + { + if (_disposed) return; + _debounceTimer?.Change(2000, Timeout.Infinite); + } + } + + public void Dispose() + { + lock (_lock) + { + _disposed = true; + _debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _debounceTimer?.Dispose(); + } + foreach (var watcher in _watchers) + { + watcher.EnableRaisingEvents = false; + watcher.Changed -= OnFileChanged; + watcher.Created -= OnFileChanged; + watcher.Deleted -= OnFileChanged; + watcher.Renamed -= OnFileRenamed; + 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..9d51f9ccfd0 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/BrowserDetector.cs @@ -0,0 +1,50 @@ +#nullable enable +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Flow.Launcher.Plugin.BrowserBookmark.Models; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Services; + +public static class BrowserDetector +{ + public static IEnumerable GetChromiumProfileDirectories(string basePath) + { + if (!Directory.Exists(basePath)) + return []; + + var profileDirs = Directory.EnumerateDirectories(basePath, "Profile *").ToList(); + + var defaultProfile = Path.Combine(basePath, "Default"); + if (Directory.Exists(defaultProfile)) + profileDirs.Add(defaultProfile); + + // Also check the base path itself, as some browsers use it as the profile directory, + // or the user might provide a direct path to a profile. + profileDirs.Add(basePath); + + return profileDirs.Distinct(); + } + + 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. + var profileDirectories = GetChromiumProfileDirectories(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..fe101f15f13 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/ChromiumBookmarkLoader.cs @@ -0,0 +1,129 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using Flow.Launcher.Plugin.BrowserBookmark.Models; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Services; + +public class ChromiumBookmarkLoader : IBookmarkLoader +{ + private readonly string _browserName; + private readonly string _browserDataPath; + private readonly ConcurrentBag _discoveredFiles; + + public string Name => _browserName; + + public ChromiumBookmarkLoader(string browserName, string browserDataPath, ConcurrentBag discoveredFiles) + { + _browserName = browserName; + _browserDataPath = browserDataPath; + _discoveredFiles = discoveredFiles; + } + + public async IAsyncEnumerable GetBookmarksAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (!Directory.Exists(_browserDataPath)) + yield break; + + var profileDirectories = BrowserDetector.GetChromiumProfileDirectories(_browserDataPath); + + 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 (IOException ex) + { + Main.Context.API.LogException(nameof(ChromiumBookmarkLoader), $"IO error reading {_browserName} bookmarks: {bookmarkPath}", ex); + } + catch (UnauthorizedAccessException ex) + { + Main.Context.API.LogException(nameof(ChromiumBookmarkLoader), $"Unauthorized to read {_browserName} bookmarks: {bookmarkPath}", ex); + } + catch (JsonException ex) + { + Main.Context.API.LogException(nameof(ChromiumBookmarkLoader), $"Failed to parse bookmarks file for {_browserName}: {bookmarkPath}", ex); + } + catch (Exception ex) + { + Main.Context.API.LogException(nameof(ChromiumBookmarkLoader), $"Unexpected error loading {_browserName} bookmarks: {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) + continue; + + // Fix for Opera. It stores bookmarks slightly different than chrome. + if (folder.Name == "custom_root") + bookmarks.AddRange(EnumerateBookmarks(folder.Value, source, profilePath)); + else + 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 + { + Main.Context.API.LogException(nameof(ChromiumBookmarkLoader), "type property not found in bookmark node.", 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..8433d9ec2be --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/FaviconService.cs @@ -0,0 +1,342 @@ +#nullable enable + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Flow.Launcher.Plugin.BrowserBookmark.Models; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Services; + +public readonly record struct FaviconCandidate(string Url, int Score); + +public partial class FaviconService : IDisposable +{ + private readonly Settings _settings; + private readonly string _faviconCacheDir; + private readonly LocalFaviconExtractor _localExtractor; + private readonly FaviconWebClient _webClient; + private readonly HtmlFaviconParser _htmlParser; + private readonly ImageConverter _imageConverter; + private readonly CancellationTokenSource _cts = new(); + private readonly ConcurrentDictionary> _ongoingFetches = new(StringComparer.OrdinalIgnoreCase); + private ConcurrentDictionary _failedFetches = new(StringComparer.OrdinalIgnoreCase); + private readonly string _failsFilePath; + private readonly SemaphoreSlim _fileLock = new(1, 1); + private record struct FetchResult(byte[]? PngData, int Size); + private static readonly TimeSpan FailedFaviconCooldown = TimeSpan.FromHours(24); + + public FaviconService(Settings settings, string tempPath) + { + _settings = settings; + + _faviconCacheDir = Path.Combine(Main.Context.CurrentPluginMetadata.PluginCacheDirectoryPath, "FaviconCache"); + Directory.CreateDirectory(_faviconCacheDir); + + var failsDir = Path.Combine(Main.Context.CurrentPluginMetadata.PluginCacheDirectoryPath, "FaviconFails"); + Directory.CreateDirectory(failsDir); + _failsFilePath = Path.Combine(failsDir, "FaviconFails.json"); + + LoadFailedFetches(); + + _localExtractor = new LocalFaviconExtractor(tempPath); + _webClient = new FaviconWebClient(); + _htmlParser = new HtmlFaviconParser(); + _imageConverter = new ImageConverter(); + } + + private void LoadFailedFetches() + { + if (!File.Exists(_failsFilePath)) return; + + try + { + var json = File.ReadAllText(_failsFilePath); + var fails = JsonSerializer.Deserialize>(json); + if (fails != null) + { + _failedFetches = new ConcurrentDictionary(fails, StringComparer.OrdinalIgnoreCase); + } + } + catch (Exception ex) + { + Main.Context.API.LogException(nameof(FaviconService), $"Failed to load failed favicons file from {_failsFilePath}", ex); + } + } + + private async Task SaveFailedFetchesAsync() + { + var acquired = false; + try + { + await _fileLock.WaitAsync(_cts.Token); + acquired = true; + var json = JsonSerializer.Serialize(_failedFetches); + await File.WriteAllTextAsync(_failsFilePath, json, _cts.Token); + } + catch (OperationCanceledException) { /* Swallow if app is closing */ } + catch (ObjectDisposedException) { /* Swallow if disposing */ } + catch (Exception ex) + { + Main.Context.API.LogException(nameof(FaviconService), $"Failed to save failed favicons file to {_failsFilePath}", ex); + } + finally + { + if (acquired) + _fileLock.Release(); + } + } + + 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 pageCachePath = GetCachePath(bookmark.Url, _faviconCacheDir); + var hostCachePath = Uri.TryCreate(bookmark.Url, UriKind.Absolute, out var pageUri) + ? GetCachePath(pageUri.GetLeftPart(UriPartial.Authority), _faviconCacheDir) + : pageCachePath; + if (File.Exists(hostCachePath)) + { + bookmark.FaviconPath = hostCachePath; + return; + } + if (File.Exists(pageCachePath)) + { + bookmark.FaviconPath = pageCachePath; + return; + } + // 1. Try local browser database + var localData = await _localExtractor.GetFaviconDataAsync(bookmark, token); + if (localData != null) + { + using var ms = new MemoryStream(localData, writable: false); + var (pngData, _) = await _imageConverter.ToPngAsync(ms, token); + if (pngData != null) + { + var path = hostCachePath; + var tmp = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + await File.WriteAllBytesAsync(tmp, pngData, token); + try { File.Move(tmp, path, overwrite: false); } + catch (IOException) + { + // Another thread may have created it concurrently. + } + } + finally + { + try { if (File.Exists(tmp)) File.Delete(tmp); } catch { /* best effort */ } + } + if (File.Exists(path)) + { + bookmark.FaviconPath = path; + return; + } + // If write failed and file still doesn't exist, fall through to web fallback. + } + } + + // 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 async Task GetFaviconFromWebAsync(Uri url, CancellationToken token) + { + if (url is null || (url.Scheme != "http" && url.Scheme != "https")) + return null; + + var authority = url.GetLeftPart(UriPartial.Authority); + + if (_failedFetches.TryGetValue(authority, out var lastAttemptTime) && + (DateTime.UtcNow - lastAttemptTime < FailedFaviconCooldown)) + { + Main.Context.API.LogDebug(nameof(FaviconService), + $"Skipping favicon fetch for {authority} due to recent failure (cooldown active)."); + return null; + } + + var fetchTask = _ongoingFetches.GetOrAdd(authority, key => FetchAndCacheFaviconAsync(new Uri(key))); + try + { + return await fetchTask.WaitAsync(token); + } + catch (OperationCanceledException) + { + // Do not cancel the shared fetch; just stop waiting for this caller. + return null; + } + } + + private static string GetCachePath(string url, string cacheDir) + { + var hash = SHA256.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) + { + var urlString = url.GetLeftPart(UriPartial.Authority); + var cachePath = GetCachePath(urlString, _faviconCacheDir); + if (File.Exists(cachePath)) return cachePath; + + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token); + + FetchResult icoResult = default; + FetchResult htmlResult = default; + bool fetchAttempted = false; + + try + { + var icoTask = FetchFromUrlAsync(new Uri(url, "/favicon.ico"), linkedCts.Token); + var htmlTask = FetchFromHtmlAsync(url, linkedCts.Token); + + var tasks = new List> { icoTask, htmlTask }; + + while (tasks.Any()) + { + var completedTask = await Task.WhenAny(tasks); + tasks.Remove(completedTask); + fetchAttempted = true; + + if (completedTask.IsCompletedSuccessfully && completedTask.Result.Size >= ImageConverter.TargetIconSize) + { + linkedCts.Cancel(); + break; + } + } + + try { icoResult = await icoTask; } catch (OperationCanceledException) { /* Expected */ } + try { htmlResult = await htmlTask; } catch (OperationCanceledException) { /* Expected */ } + + var bestResult = SelectBestFavicon(icoResult, htmlResult); + + if (bestResult.PngData != null) + { + try + { + await File.WriteAllBytesAsync(cachePath, bestResult.PngData, _cts.Token); + } + catch (IOException) + { + // Another thread may have created it concurrently. + } + if (File.Exists(cachePath)) + { + Main.Context.API.LogDebug(nameof(FaviconService), $"Favicon for {urlString} cached successfully."); + if (_failedFetches.TryRemove(urlString, out _)) + { + _ = SaveFailedFetchesAsync(); + } + return cachePath; + } + // Do not treat as success; let finally record failure if needed. + } + + Main.Context.API.LogDebug(nameof(FaviconService), $"No suitable favicon found for {urlString} after all tasks."); + } + catch (Exception ex) + { + Main.Context.API.LogException(nameof(FaviconService), $"Error in favicon fetch for {urlString}", ex); + fetchAttempted = true; + } + finally + { + _ongoingFetches.TryRemove(urlString, out _); + + if (fetchAttempted && !File.Exists(cachePath)) + { + _failedFetches[urlString] = DateTime.UtcNow; + _ = SaveFailedFetchesAsync(); + } + } + + return null; + } + + private static FetchResult SelectBestFavicon(FetchResult icoResult, FetchResult htmlResult) + { + var htmlValid = htmlResult.PngData != null; + var icoValid = icoResult.PngData != null; + + if (htmlValid && htmlResult.Size >= ImageConverter.TargetIconSize) return htmlResult; + if (icoValid && icoResult.Size >= ImageConverter.TargetIconSize) return icoResult; + + if (htmlValid && icoValid) return htmlResult.Size >= icoResult.Size ? htmlResult : icoResult; + if (htmlValid) return htmlResult; + if (icoValid) return icoResult; + return default; + } + + private async Task FetchFromHtmlAsync(Uri pageUri, CancellationToken token) + { + var htmlResult = await _webClient.GetHtmlHeadAsync(pageUri, token); + if (htmlResult is not { Html: not null, BaseUri: not null }) + return default; + + var candidates = _htmlParser.Parse(htmlResult.Value.Html, htmlResult.Value.BaseUri); + + foreach (var candidate in candidates.OrderByDescending(c => c.Score)) + { + if (Uri.TryCreate(candidate.Url, UriKind.Absolute, out var candidateUri)) + { + var result = await FetchFromUrlAsync(candidateUri, token); + if (result.PngData != null) + { + return result; + } + } + } + + return default; + } + + private async Task FetchFromUrlAsync(Uri faviconUri, CancellationToken token) + { + await using var stream = await _webClient.DownloadFaviconAsync(faviconUri, token); + if (stream == null) + return default; + + var (pngData, size) = await _imageConverter.ToPngAsync(stream, token); + if (pngData is { Length: > 0 }) + { + return new FetchResult(pngData, size); + } + + return default; + } + + public void Dispose() + { + _cts.Cancel(); + _ongoingFetches.Clear(); + _webClient.Dispose(); + _fileLock.Dispose(); + _cts.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/FaviconWebClient.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/FaviconWebClient.cs new file mode 100644 index 00000000000..58aae0c05df --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/FaviconWebClient.cs @@ -0,0 +1,128 @@ +#nullable enable +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Services; + +public class FaviconWebClient : IDisposable +{ + private readonly HttpClient _httpClient; + private const int MaxFaviconBytes = 250 * 1024; + + public FaviconWebClient() + { + var handler = new HttpClientHandler + { + AllowAutoRedirect = true, + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli + }; + _httpClient = new HttpClient(handler); + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6409.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<(string? Html, Uri BaseUri)?> GetHtmlHeadAsync(Uri pageUri, CancellationToken token) + { + try + { + using 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 maxHeadChars = 150 * 1024; + + while (!token.IsCancellationRequested && (charsRead = await reader.ReadAsync(buffer, 0, buffer.Length)) > 0 && totalCharsRead < maxHeadChars) + { + contentBuilder.Append(buffer, 0, charsRead); + totalCharsRead += charsRead; + + if (contentBuilder.ToString().Contains("", StringComparison.OrdinalIgnoreCase)) + { + break; + } + } + return (contentBuilder.ToString(), baseUri); + } + catch (TaskCanceledException) when (!token.IsCancellationRequested) + { + Main.Context.API.LogWarn(nameof(FaviconWebClient), $"HttpClient timed out fetching HTML for {pageUri}."); + } + catch (OperationCanceledException) { /* Expected if another task cancels this one */ } + catch (Exception ex) + { + Main.Context.API.LogException(nameof(FaviconWebClient), $"Failed to fetch or parse HTML head for {pageUri}", ex); + } + return null; + } + + public async Task DownloadFaviconAsync(Uri faviconUri, CancellationToken token) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, faviconUri); + using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token); + + if (!response.IsSuccessStatusCode) + return null; + + if (response.Content.Headers.ContentLength > MaxFaviconBytes) + return null; + + await using var contentStream = await response.Content.ReadAsStreamAsync(token); + var memoryStream = new MemoryStream(); + + var buffer = new byte[8192]; + int bytesRead; + long totalBytesRead = 0; + + while ((bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length, token)) > 0) + { + totalBytesRead += bytesRead; + if (totalBytesRead > MaxFaviconBytes) + { + await memoryStream.DisposeAsync(); + return null; + } + await memoryStream.WriteAsync(buffer, 0, bytesRead, token); + } + + memoryStream.Position = 0; + return memoryStream; + } + catch (HttpRequestException ex) when (ex.InnerException is SocketException { SocketErrorCode: SocketError.HostNotFound }) + { + Main.Context.API.LogDebug(nameof(FaviconWebClient), $"Favicon host not found for URI: {faviconUri}"); + } + catch (TaskCanceledException) when (!token.IsCancellationRequested) + { + Main.Context.API.LogWarn(nameof(FaviconWebClient), $"HttpClient timed out for {faviconUri}."); + } + catch (OperationCanceledException) { /* Expected if another task cancels this one */ } + catch (Exception ex) + { + Main.Context.API.LogException(nameof(FaviconWebClient), $"Favicon fetch failed for {faviconUri}", ex); + } + return null; + } + + 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..6573eb71672 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/FirefoxBookmarkLoader.cs @@ -0,0 +1,145 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Flow.Launcher.Plugin.BrowserBookmark.Models; +using Microsoft.Data.Sqlite; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Services; + +public class FirefoxBookmarkLoader : IBookmarkLoader +{ + private readonly string _browserName; + private readonly string _placesPath; + 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 = moz_places.id + WHERE moz_bookmarks.fk IS NOT NULL + AND moz_bookmarks.title IS NOT NULL + AND moz_places.url IS NOT NULL + ORDER BY moz_places.visit_count DESC + """; + + public FirefoxBookmarkLoader(string browserName, string placesPath, string tempPath) + { + _browserName = browserName; + _placesPath = placesPath; + _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"); + var walPath = _placesPath + "-wal"; + var shmPath = _placesPath + "-shm"; + + File.Copy(_placesPath, tempDbPath, true); + if (File.Exists(walPath)) + File.Copy(walPath, tempDbPath + "-wal", true); + if (File.Exists(shmPath)) + File.Copy(shmPath, tempDbPath + "-shm", true); + + // Clear any partially-read results before fallback to avoid duplicates + bookmarks.Clear(); + await ReadBookmarksFromDb(tempDbPath, bookmarks, cancellationToken); + } + catch (Exception copyEx) + { + Main.Context.API.LogException(nameof(FirefoxBookmarkLoader), $"Failed to load {_browserName} bookmarks from fallback copy: {_placesPath}", copyEx); + } + } + catch (Exception e) + { + Main.Context.API.LogException(nameof(FirefoxBookmarkLoader), $"Failed to load {_browserName} bookmarks: {_placesPath}", e); + } + finally + { + if (tempDbPath != null) + { + CleanupTempFiles(tempDbPath); + } + } + + foreach (var bookmark in bookmarks) + { + yield return bookmark; + } + } + + private async Task ReadBookmarksFromDb(string dbPath, ICollection bookmarks, CancellationToken cancellationToken) + { + // Always use the original profile directory, even when reading from a temp DB copy + var profilePath = Path.GetDirectoryName(_placesPath) ?? 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)); + } + } + } + + 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 + Main.Context.API.LogException(nameof(FirefoxBookmarkLoader), $"Failed to delete temporary file chunk: {file}", ex); + } + } + } + catch (Exception ex) + { + Main.Context.API.LogException(nameof(FirefoxBookmarkLoader), $"Failed to clean up temporary files for base: {mainTempDbPath}", ex); + } + } +} 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..4cfdceae405 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/FirefoxProfileFinder.cs @@ -0,0 +1,114 @@ +#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); + + // Priority 1: Check for an [Install...] section which often contains the default profile path. + var installMatch = Regex.Match(iniContent, @"^\[Install[^\]]+\](?:.|\n|\r)*?^Default\s*=\s*(.+)", RegexOptions.Multiline | RegexOptions.IgnoreCase); + if (installMatch.Success) + { + var path = installMatch.Groups[1].Value.Trim(); + // This path is typically relative, e.g., "Profiles/xyz.default-release" + var profilePath = Path.Combine(profileFolderPath, path.Replace('/', Path.DirectorySeparatorChar)); + var placesDb = Path.Combine(profilePath, "places.sqlite"); + if (File.Exists(placesDb)) + { + return placesDb; + } + } + + // Priority 2: Parse individual [Profile...] sections. + var profileSections = Regex.Matches(iniContent, @"^\[Profile[^\]]+\](?:.|\n|\r)*?(?=\r?\n\[|$)", RegexOptions.Multiline) + .Cast() + .Select(m => m.Value) + .ToArray(); + + string? targetSection = null; + + // Find a profile named "default-release" that is also marked as default. + targetSection = profileSections.FirstOrDefault(s => + Regex.IsMatch(s, @"^Name\s*=\s*default-release", RegexOptions.Multiline | RegexOptions.IgnoreCase) && + Regex.IsMatch(s, @"^Default\s*=\s*1", RegexOptions.Multiline | RegexOptions.IgnoreCase)); + + // Find any profile marked as default. + if (targetSection == null) + { + targetSection = profileSections.FirstOrDefault(s => Regex.IsMatch(s, @"^Default\s*=\s*1", RegexOptions.Multiline | RegexOptions.IgnoreCase)); + } + + // Fallback to the first profile in the file if no default is marked. + if (targetSection == null) + { + targetSection = profileSections.FirstOrDefault(); + } + + if (targetSection == null) + return null; + + var pathMatch = Regex.Match(targetSection, @"^Path\s*=\s*(.+)", RegexOptions.Multiline | RegexOptions.IgnoreCase); + if (!pathMatch.Success) + return null; + + var pathValue = pathMatch.Groups[1].Value.Trim(); + + // IsRelative=0 means it's an absolute path. The default is relative (IsRelative=1). + var isRelative = !Regex.IsMatch(targetSection, @"^IsRelative\s*=\s*0", RegexOptions.Multiline | RegexOptions.IgnoreCase); + + var finalProfilePath = isRelative ? Path.Combine(profileFolderPath, pathValue.Replace('/', Path.DirectorySeparatorChar)) : pathValue; + var finalPlacesDb = Path.Combine(finalProfilePath, "places.sqlite"); + + return File.Exists(finalPlacesDb) ? finalPlacesDb : null; + } + catch + { + // Logged in the calling service + return null; + } + } +} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/HtmlFaviconParser.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/HtmlFaviconParser.cs new file mode 100644 index 00000000000..cc9b459e079 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/HtmlFaviconParser.cs @@ -0,0 +1,97 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Services; + +public partial class HtmlFaviconParser +{ + [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(); + + public List Parse(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; + + Main.Context.API.LogDebug(nameof(Parse), $"Found potential favicon link. Raw tag: '{linkTag}', Extracted href: '{href}', Base URI: '{effectiveBaseUri}'"); + + if (href.StartsWith("//")) + { + href = effectiveBaseUri.Scheme + ":" + href; + } + + if (!Uri.TryCreate(effectiveBaseUri, href, out var fullUrl)) + { + Main.Context.API.LogWarn(nameof(Parse), $"Failed to create a valid URI from href: '{href}' and base URI: '{effectiveBaseUri}'"); + continue; + } + + var score = CalculateFaviconScore(linkTag, fullUrl.ToString()); + candidates.Add(new FaviconCandidate(fullUrl.ToString(), score)); + + if (score >= ImageConverter.TargetIconSize) + { + Main.Context.API.LogDebug(nameof(Parse), $"Found suitable favicon candidate (score: {score}). Halting further HTML parsing."); + break; + } + } + + 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; + + return 16; + } +} 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..747f2fa91b9 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/IBookmarkLoader.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Threading; +using Flow.Launcher.Plugin.BrowserBookmark.Models; + +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/ImageConverter.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/ImageConverter.cs new file mode 100644 index 00000000000..d87b4c26e9d --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/ImageConverter.cs @@ -0,0 +1,123 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using SkiaSharp; +using Svg.Skia; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Services; + +public class ImageConverter +{ + public const int TargetIconSize = 48; + + public async Task<(byte[]? PngData, int Size)> ToPngAsync(Stream stream, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + + await using var ms = new MemoryStream(); + await stream.CopyToAsync(ms, token); + ms.Position = 0; + + // The returned 'Size' is the original width of the icon, used for scoring the best favicon. + // It does not reflect the final dimensions of the resized PNG. + + var (pngData, size) = TryConvertSvgToPng(ms); + if (pngData is not null) return (pngData, size); + + ms.Position = 0; + (pngData, size) = TryConvertIcoToPng(ms); + if (pngData is not null) return (pngData, size); + + ms.Position = 0; + (pngData, size) = TryConvertBitmapToPng(ms); + return (pngData, size); + } + + private static (byte[]? PngData, int Size) TryConvertSvgToPng(Stream stream) + { + try + { + using var svg = new SKSvg(); + if (svg.Load(stream) is not null && svg.Picture is not null) + { + using var bitmap = new SKBitmap(TargetIconSize, TargetIconSize); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.Transparent); + var scaleMatrix = SKMatrix.CreateScale((float)TargetIconSize / svg.Picture.CullRect.Width, (float)TargetIconSize / 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(), TargetIconSize); + } + } + catch { /* Not a valid SVG */ } + + return (null, 0); + } + + private static (byte[]? PngData, int Size) TryConvertIcoToPng(Stream stream) + { + try + { + var decoder = new IconBitmapDecoder(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad); + if (decoder.Frames.Any()) + { + var largestFrame = decoder.Frames.OrderByDescending(f => f.Width * f.Height).First(); + + 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(TargetIconSize, TargetIconSize, 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 { /* Not a supported ICO format */ } + + return (null, 0); + } + + private (byte[]? PngData, int Size) TryConvertBitmapToPng(Stream stream) + { + try + { + using var original = SKBitmap.Decode(stream); + if (original != null) + { + var originalWidth = original.Width; + var info = new SKImageInfo(TargetIconSize, TargetIconSize, 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) + { + Main.Context.API.LogException(nameof(ImageConverter), "Failed to decode or convert bitmap with final fallback", ex); + } + + return (null, 0); + } +} 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..57880e74c90 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Services/LocalFaviconExtractor.cs @@ -0,0 +1,148 @@ +#nullable enable +using System; +using System.IO; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; +using Flow.Launcher.Plugin.BrowserBookmark.Models; +using Microsoft.Data.Sqlite; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Services; + +public class LocalFaviconExtractor +{ + private readonly string _tempPath; + + public LocalFaviconExtractor(string tempPath) + { + _tempPath = tempPath; + } + + public async Task GetFaviconDataAsync(Bookmark bookmark, CancellationToken token) + { + var profilePath = bookmark.ProfilePath; + if (!string.IsNullOrEmpty(profilePath)) + { + if (File.Exists(Path.Combine(profilePath, "favicons.sqlite"))) + return await GetFirefoxFaviconAsync(bookmark, token); + + if (File.Exists(Path.Combine(profilePath, "Favicons"))) + return await GetChromiumFaviconAsync(bookmark, token); + } + + return bookmark.Source?.IndexOf("Firefox", StringComparison.OrdinalIgnoreCase) >= 0 + ? await GetFirefoxFaviconAsync(bookmark, token) + : await GetChromiumFaviconAsync(bookmark, token); + } + + 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 + { + var walPath = dbPath + "-wal"; + var shmPath = dbPath + "-shm"; + + File.Copy(dbPath, tempDbPath, true); + if (File.Exists(walPath)) + File.Copy(walPath, tempDbPath + "-wal", true); + if (File.Exists(shmPath)) + File.Copy(shmPath, tempDbPath + "-shm", 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; + + Main.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) + { + Main.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 + Main.Context.API.LogException(nameof(LocalFaviconExtractor), $"Failed to delete temporary file chunk: {file}", ex); + } + } + } + catch (Exception ex) + { + Main.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..448cf9f40af --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/ViewModels/CustomBrowserSettingViewModel.cs @@ -0,0 +1,108 @@ +#nullable enable +using System; +using System.ComponentModel; +using System.IO; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Flow.Launcher.Plugin.BrowserBookmark.Models; +using Flow.Launcher.Plugin.BrowserBookmark.Services; + +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 System.Windows.Forms.FolderBrowserDialog + { + Description = "Select the browser's profile data directory.", + UseDescriptionForTitle = true + }; + + if (!string.IsNullOrEmpty(EditableBrowser.DataDirectoryPath) && Directory.Exists(EditableBrowser.DataDirectoryPath)) + { + dialog.SelectedPath = EditableBrowser.DataDirectoryPath; + } + + if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK) + { + var path = dialog.SelectedPath; + if (!string.IsNullOrEmpty(path)) + { + EditableBrowser.DataDirectoryPath = path; + } + } + } +} 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 @@