Flow.Launcher/Plugins/Flow.Launcher.Plugin.BrowserBookmark/FirefoxBookmarkLoader.cs

342 lines
13 KiB
C#
Raw Permalink Normal View History

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading.Tasks;
2025-06-08 04:35:39 +00:00
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);
2025-03-19 20:04:19 +00:00
private readonly string _faviconCacheDir;
protected FirefoxBookmarkLoaderBase()
{
2025-04-02 14:39:30 +00:00
_faviconCacheDir = Main._faviconCacheDir;
2025-03-19 20:04:19 +00:00
}
public abstract List<Bookmark> GetBookmarks();
2023-02-03 05:07:46 +00:00
2025-03-19 20:04:19 +00:00
// 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
""";
2025-03-19 20:04:19 +00:00
protected List<Bookmark> GetBookmarksFromPath(string placesPath)
{
2025-03-19 20:04:19 +00:00
// Variable to store bookmark list
var bookmarks = new List<Bookmark>();
// Return empty list if places.sqlite file doesn't exist
if (string.IsNullOrEmpty(placesPath) || !File.Exists(placesPath))
2025-03-19 20:04:19 +00:00
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;
}
2025-04-09 09:27:34 +00:00
var tempDbPath = Path.Combine(_faviconCacheDir, $"tempplaces_{Guid.NewGuid()}.sqlite");
2025-03-19 20:04:19 +00:00
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
2025-06-04 17:31:06 +00:00
using var dbConnection = new SqliteConnection($"Data Source={tempDbPath};Mode=ReadOnly");
// Open connection to the database file and execute the query
2025-03-19 20:04:19 +00:00
dbConnection.Open();
var reader = new SqliteCommand(QueryAllBookmarks, dbConnection).ExecuteReader();
// Get results in List<Bookmark> format
2025-03-19 20:04:19 +00:00
bookmarks = reader
.Select(
x => new Bookmark(
x["title"] is DBNull ? string.Empty : x["title"].ToString(),
x["url"].ToString(),
"Firefox"
)
)
2025-03-19 20:04:19 +00:00
.ToList();
// Load favicons after loading bookmarks
2025-06-05 02:21:24 +00:00
if (Main._settings.EnableFavicons)
2025-03-19 20:04:19 +00:00
{
var faviconDbPath = Path.Combine(Path.GetDirectoryName(placesPath), "favicons.sqlite");
if (File.Exists(faviconDbPath))
{
Main.Context.API.StopwatchLogInfo(ClassName, $"Load {bookmarks.Count} favicons cost", () =>
2025-06-04 16:27:03 +00:00
{
LoadFaviconsFromDb(faviconDbPath, bookmarks);
});
}
2025-03-19 20:04:19 +00:00
}
2025-06-04 15:31:10 +00:00
// Close the connection so that we can delete the temporary file
2025-03-20 08:21:15 +00:00
// https://github.com/dotnet/efcore/issues/26580
SqliteConnection.ClearPool(dbConnection);
dbConnection.Close();
2025-03-19 20:04:19 +00:00
}
catch (Exception ex)
{
Main.Context.API.LogException(ClassName, $"Failed to load Firefox bookmarks: {placesPath}", ex);
2025-03-19 20:04:19 +00:00
}
2025-04-09 09:27:34 +00:00
// Delete temporary file
try
{
2025-06-04 15:31:18 +00:00
if (File.Exists(tempDbPath))
{
File.Delete(tempDbPath);
}
2025-04-09 09:27:34 +00:00
}
catch (Exception ex)
{
Main.Context.API.LogException(ClassName, $"Failed to delete temporary favicon DB: {tempDbPath}", ex);
2025-04-09 09:27:34 +00:00
}
2025-03-19 20:04:19 +00:00
return bookmarks;
2023-02-03 05:07:46 +00:00
}
private void LoadFaviconsFromDb(string dbPath, List<Bookmark> bookmarks)
2025-03-19 20:04:19 +00:00
{
2025-06-08 04:35:39 +00:00
FaviconHelper.LoadFaviconsFromDb(_faviconCacheDir, dbPath, (tempDbPath) =>
{
2025-06-05 02:21:24 +00:00
// Since some bookmarks may have same favicon id, we need to record them to avoid duplicates
var savedPaths = new ConcurrentDictionary<string, bool>();
// 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();
2025-03-25 04:51:39 +00:00
try
{
if (!Uri.TryCreate(bookmark.Url, UriKind.Absolute, out Uri uri))
return;
2025-03-19 20:04:19 +00:00
2025-03-25 04:51:39 +00:00
var domain = uri.Host;
2025-03-19 20:04:19 +00:00
2025-03-25 04:51:39 +00:00
// Query for latest Firefox version favicon structure
using var cmd = connection.CreateCommand();
cmd.CommandText = @"
SELECT i.id, i.data
2025-03-25 04:51:39 +00:00
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
2025-03-25 04:51:39 +00:00
LIMIT 1";
2025-03-19 20:04:19 +00:00
cmd.Parameters.AddWithValue("@domain", $"%{domain}%");
2025-03-19 20:04:19 +00:00
2025-03-25 04:51:39 +00:00
using var reader = cmd.ExecuteReader();
if (!reader.Read() || reader.IsDBNull(1))
return;
2025-03-20 08:43:38 +00:00
var iconId = reader.GetInt64(0).ToString();
2025-03-25 04:51:39 +00:00
var imageData = (byte[])reader["data"];
2025-03-20 08:43:38 +00:00
2025-03-25 04:51:39 +00:00
if (imageData is not { Length: > 0 })
return;
2025-07-14 01:01:59 +00:00
// 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();
}
2025-07-14 01:01:59 +00:00
// 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);
}
2025-03-25 04:51:39 +00:00
bookmark.FaviconPath = faviconPath;
}
2025-03-19 20:04:19 +00:00
}
2025-03-25 04:51:39 +00:00
catch (Exception ex)
{
Main.Context.API.LogException(ClassName, $"Failed to extract Firefox favicon: {bookmark.Url}", ex);
2025-03-25 04:51:39 +00:00
}
finally
{
// Cache connection and clear pool after all operations to avoid issue:
// ObjectDisposedException: Safe handle has been closed.
connection.Close();
connection.Dispose();
}
});
2025-06-08 04:35:39 +00:00
});
}
2025-03-19 20:04:19 +00:00
}
2023-02-03 05:07:46 +00:00
public class FirefoxBookmarkLoader : FirefoxBookmarkLoaderBase
{
/// <summary>
/// Searches the places.sqlite db and returns all bookmarks
/// </summary>
public override List<Bookmark> GetBookmarks()
{
2025-06-08 04:09:07 +00:00
var bookmarks = new List<Bookmark>();
bookmarks.AddRange(GetBookmarksFromPath(PlacesPath));
bookmarks.AddRange(GetBookmarksFromPath(MsixPlacesPath));
return bookmarks;
}
/// <summary>
2025-06-06 05:12:40 +00:00
/// Path to places.sqlite of Msi installer
/// E.g. C:\Users\{UserName}\AppData\Roaming\Mozilla\Firefox
/// <see href="https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data#w_finding-your-profile-without-opening-firefox"/>
/// </summary>
private static string PlacesPath
2023-02-03 05:07:46 +00:00
{
get
2023-02-03 05:07:46 +00:00
{
var profileFolderPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), @"Mozilla\Firefox");
2025-06-06 05:12:40 +00:00
return GetProfileIniPath(profileFolderPath);
}
}
2025-06-06 05:12:40 +00:00
/// <summary>
/// Path to places.sqlite of MSIX installer
/// E.g. C:\Users\{UserName}\AppData\Local\Packages\Mozilla.Firefox_n80bbvh6b1yt2\LocalCache\Roaming\Mozilla\Firefox
/// <see href="https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data#w_finding-your-profile-without-opening-firefox"/>
/// </summary>
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();
2025-06-06 05:12:40 +00:00
// Msix FireFox not installed
if (firefoxPackageFolder == null) return string.Empty;
2025-06-06 05:12:40 +00:00
var profileFolderPath = Path.Combine(firefoxPackageFolder, @"LocalCache\Roaming\Mozilla\Firefox");
return GetProfileIniPath(profileFolderPath);
}
catch
{
return string.Empty;
}
2025-06-06 05:12:40 +00:00
}
}
2025-06-06 05:12:40 +00:00
private static string GetProfileIniPath(string profileFolderPath)
{
var profileIni = Path.Combine(profileFolderPath, @"profiles.ini");
if (!File.Exists(profileIni))
return string.Empty;
2025-06-06 05:12:40 +00:00
// get firefox default profile directory from profiles.ini
using var sReader = new StreamReader(profileIni);
var ini = sReader.ReadToEnd();
2025-06-06 05:12:40 +00:00
var lines = ini.Split("\r\n").ToList();
2025-06-06 05:12:40 +00:00
var defaultProfileFolderNameRaw = lines.FirstOrDefault(x => x.Contains("Default=") && x != "Default=1") ?? string.Empty;
2025-06-06 05:12:40 +00:00
if (string.IsNullOrEmpty(defaultProfileFolderNameRaw))
return string.Empty;
2025-06-06 05:12:40 +00:00
var defaultProfileFolderName = defaultProfileFolderNameRaw.Split('=').Last();
2025-06-05 14:41:21 +00:00
2025-06-06 05:12:40 +00:00
var indexOfDefaultProfileAttributePath = lines.IndexOf("Path=" + defaultProfileFolderName);
2025-06-05 14:41:21 +00:00
2025-06-06 05:12:40 +00:00
/*
Current profiles.ini structure example as of Firefox version 69.0.1
2025-06-05 14:41:21 +00:00
2025-06-06 05:12:40 +00:00
[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
2025-06-05 14:41:21 +00:00
2025-06-06 05:12:40 +00:00
[Profile2]
2025-06-11 13:14:05 +00:00
Name=dummyprofile
2025-06-06 05:12:40 +00:00
IsRelative=0
2025-06-11 13:14:05 +00:00
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.
2025-06-05 14:41:21 +00:00
2025-06-06 05:12:40 +00:00
[Profile1]
Name=default
IsRelative=1
Path=Profiles/cydum7q4.default
Default=1
2025-06-06 05:12:40 +00:00
[Profile0]
Name=default-release
IsRelative=1
Path=Profiles/7789f565.default-release
2025-06-06 05:12:40 +00:00
[General]
StartWithLastProfile=1
Version=2
*/
// Seen in the example above, the IsRelative attribute is always above the Path attribute
2025-06-06 05:12:40 +00:00
var relativePath = Path.Combine(defaultProfileFolderName, "places.sqlite");
2025-06-12 08:27:48 +00:00
var absolutePath = Path.Combine(profileFolderPath, relativePath);
2025-06-06 05:12:40 +00:00
// 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)
{
2025-06-12 08:27:48 +00:00
return Directory.Exists(absolutePath) ? absolutePath : relativePath;
}
2025-06-06 05:12:40 +00:00
var relativeAttribute = lines[indexOfDefaultProfileAttributePath - 1];
2025-06-06 05:21:51 +00:00
// See above, the profile is located in a custom location, path is not relative, so IsRelative=0
return (relativeAttribute == "0" || relativeAttribute == "IsRelative=0")
2025-06-12 08:27:48 +00:00
? relativePath : absolutePath;
}
}
public static class Extensions
{
public static IEnumerable<T> Select<T>(this SqliteDataReader reader, Func<SqliteDataReader, T> projection)
{
while (reader.Read())
{
yield return projection(reader);
}
}
2022-11-14 13:43:09 +00:00
}