using System; using System.Collections.Generic; using System.IO; using System.Linq; 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 """; private const string DbPathFormat = "Data Source={0}"; 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; var tempDbPath = Path.Combine(_faviconCacheDir, $"tempplaces_{Guid.NewGuid()}.sqlite"); try { // 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); } // Use a copy to avoid lock issues with the original file File.Copy(placesPath, tempDbPath, true); // Connect to database and execute query string dbPath = string.Format(DbPathFormat, tempDbPath); using var dbConnection = new SqliteConnection(dbPath); dbConnection.Open(); var reader = new SqliteCommand(QueryAllBookmarks, dbConnection).ExecuteReader(); // Create bookmark list bookmarks = reader .Select( x => new Bookmark( x["title"] is DBNull ? string.Empty : x["title"].ToString(), x["url"].ToString(), "Firefox" ) ) .ToList(); // Path to favicon database var faviconDbPath = Path.Combine(Path.GetDirectoryName(placesPath), "favicons.sqlite"); if (File.Exists(faviconDbPath)) { LoadFaviconsFromDb(faviconDbPath, bookmarks); } // 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 { 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 faviconDbPath, List bookmarks) { var tempDbPath = Path.Combine(_faviconCacheDir, $"tempfavicons_{Guid.NewGuid()}.sqlite"); try { // Use a copy to avoid lock issues with the original file File.Copy(faviconDbPath, tempDbPath, true); var defaultIconPath = Path.Combine( Path.GetDirectoryName(typeof(FirefoxBookmarkLoaderBase).Assembly.Location), "bookmark.png"); string dbPath = string.Format(DbPathFormat, tempDbPath); using var connection = new SqliteConnection(dbPath); connection.Open(); // Get favicons based on bookmark URLs foreach (var bookmark in bookmarks) { try { if (string.IsNullOrEmpty(bookmark.Url)) continue; // Extract domain from URL if (!Uri.TryCreate(bookmark.Url, UriKind.Absolute, out Uri uri)) continue; var domain = uri.Host; // Query for latest Firefox version favicon structure using var cmd = connection.CreateCommand(); cmd.CommandText = @" 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 LIKE @url AND i.data IS NOT NULL ORDER BY i.width DESC -- Select largest icon available LIMIT 1"; cmd.Parameters.AddWithValue("@url", $"%{domain}%"); using var reader = cmd.ExecuteReader(); if (!reader.Read() || reader.IsDBNull(0)) continue; var imageData = (byte[])reader["data"]; if (imageData is not { Length: > 0 }) continue; string faviconPath; if (IsSvgData(imageData)) { faviconPath = Path.Combine(_faviconCacheDir, $"firefox_{domain}.svg"); } else { faviconPath = Path.Combine(_faviconCacheDir, $"firefox_{domain}.png"); } SaveBitmapData(imageData, faviconPath); bookmark.FaviconPath = faviconPath; } catch (Exception ex) { Main._context.API.LogException(ClassName, $"Failed to extract Firefox favicon: {bookmark.Url}", ex); } } // https://github.com/dotnet/efcore/issues/26580 SqliteConnection.ClearPool(connection); connection.Close(); } catch (Exception ex) { Main._context.API.LogException(ClassName, $"Failed to load Firefox favicon DB: {faviconDbPath}", ex); } // Delete temporary file try { File.Delete(tempDbPath); } catch (Exception ex) { Main._context.API.LogException(ClassName, $"Failed to delete temporary favicon DB: {tempDbPath}", ex); } } private 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); } } private static bool IsSvgData(byte[] data) { if (data.Length < 5) return false; string start = System.Text.Encoding.ASCII.GetString(data, 0, Math.Min(100, data.Length)); return start.Contains(" /// Searches the places.sqlite db and returns all bookmarks /// public override List GetBookmarks() { return GetBookmarksFromPath(PlacesPath); } /// /// Path to places.sqlite /// private static string PlacesPath { get { var profileFolderPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), @"Mozilla\Firefox"); 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); // Seen in the example above, the IsRelative attribute is always above the Path attribute var relativeAttribute = lines[indexOfDefaultProfileAttributePath - 1]; return relativeAttribute == "0" // See above, the profile is located in a custom location, path is not relative, so IsRelative=0 ? defaultProfileFolderName + @"\places.sqlite" : Path.Combine(profileFolderPath, defaultProfileFolderName) + @"\places.sqlite"; } } } public static class Extensions { public static IEnumerable Select(this SqliteDataReader reader, Func projection) { while (reader.Read()) { yield return projection(reader); } } }