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 ed121375b31..d73028453a5 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Flow.Launcher.Plugin.BrowserBookmark.csproj @@ -103,6 +103,7 @@ + diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/en.xaml b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/en.xaml index 56471417309..4bb16f35deb 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/en.xaml +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/en.xaml @@ -31,5 +31,6 @@ 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) + Reuse existing tabs - \ 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..7831acbc756 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs @@ -8,6 +8,7 @@ using System.Windows.Controls; using Flow.Launcher.Plugin.BrowserBookmark.Commands; using Flow.Launcher.Plugin.BrowserBookmark.Models; +using Flow.Launcher.Plugin.BrowserBookmark.Tabs; using Flow.Launcher.Plugin.BrowserBookmark.Views; using Flow.Launcher.Plugin.SharedCommands; @@ -26,7 +27,9 @@ public class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex private static List _cachedBookmarks = new(); private static bool _initialized = false; - + + private static readonly TabsReservationService _tabsReservationService = new(); + public void Init(PluginInitContext context) { Context = context; @@ -58,6 +61,13 @@ public void Init(PluginInitContext context) } LoadBookmarksIfEnabled(); + SetReuseTabs(_settings.ReuseTabs); + } + + public static void SetReuseTabs(bool reuseTabs) + { + _tabsReservationService.EnableTracking(reuseTabs); + _settings.ReuseTabs = reuseTabs; } private static void LoadBookmarksIfEnabled() @@ -92,7 +102,7 @@ public List Query(Query query) if (!topResults) { // Since we mixed chrome and firefox bookmarks, we should order them again - return _cachedBookmarks + return _tabsReservationService.InjectExistingTabs(_settings, _cachedBookmarks .Select( c => new Result { @@ -104,19 +114,18 @@ public List Query(Query query) Score = BookmarkLoader.MatchProgram(c, param).Score, Action = _ => { - Context.API.OpenUrl(c.Url); - + _tabsReservationService.OpenUrlAndTrack(_settings, c.Url); return true; }, ContextData = new BookmarkAttributes { Url = c.Url } } ) .Where(r => r.Score > 0) - .ToList(); + .ToList()); } else { - return _cachedBookmarks + return _tabsReservationService.InjectExistingTabs(_settings, _cachedBookmarks .Select( c => new Result { @@ -128,13 +137,13 @@ public List Query(Query query) Score = 5, Action = _ => { - Context.API.OpenUrl(c.Url); + _tabsReservationService.OpenUrlAndTrack(_settings, c.Url); return true; }, ContextData = new BookmarkAttributes { Url = c.Url } } ) - .ToList(); + .ToList()); } } @@ -261,6 +270,7 @@ internal class BookmarkAttributes public void Dispose() { + _tabsReservationService.Dispose(); DisposeFileWatchers(); } diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/Settings.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/Settings.cs index a0041e0d6a0..b05cc82cd4d 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/Settings.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Models/Settings.cs @@ -13,6 +13,7 @@ public class Settings : BaseModel public bool LoadChromeBookmark { get; set; } = true; public bool LoadFirefoxBookmark { get; set; } = true; public bool LoadEdgeBookmark { get; set; } = true; + public bool ReuseTabs { get; set; } = false; public ObservableCollection CustomChromiumBrowsers { get; set; } = new(); } diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.json b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.json new file mode 100644 index 00000000000..1cd0ce2cd1a --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.json @@ -0,0 +1,104 @@ +[ + { + "PackageName": "BrowserTabs", + "PackageVersion": "0.2.0", + "PackageUrl": "https://github.com/jjw24/BrowserTabs", + "Copyright": "Jeremy Wu", + "Authors": [ + "Jeremy Wu" + ], + "Description": "Library for retrieving all opened browser tabs in Chromium-based and Firefox-based browsers", + "LicenseUrl": "https://licenses.nuget.org/Apache-2.0", + "LicenseType": "Apache-2.0", + "Repository": { + "Type": "git", + "Url": "https://github.com/jjw24/BrowserTabs", + "Commit": "8d81f8f686e82ddceb3e1a8a49e698cec56b5e3d" + } + }, + { + "PackageName": "CommunityToolkit.Mvvm", + "PackageVersion": "8.4.0", + "PackageUrl": "https://github.com/CommunityToolkit/dotnet", + "Copyright": "(c) .NET Foundation and Contributors. All rights reserved.", + "Authors": [ + "Microsoft" + ], + "Description": "This package includes a .NET MVVM library with helpers such as:\r\n - ObservableObject: a base class for objects implementing the INotifyPropertyChanged interface.\r\n - ObservableRecipient: a base class for observable objects with support for the IMessenger service.\r\n - ObservableValidator: a base class for objects implementing the INotifyDataErrorInfo interface.\r\n - RelayCommand: a simple delegate command implementing the ICommand interface.\r\n - AsyncRelayCommand: a delegate command supporting asynchronous operations and cancellation.\r\n - WeakReferenceMessenger: a messaging system to exchange messages through different loosely-coupled objects.\r\n - StrongReferenceMessenger: a high-performance messaging system that trades weak references for speed.\r\n - Ioc: a helper class to configure dependency injection service containers.", + "LicenseUrl": "https://licenses.nuget.org/MIT", + "LicenseType": "MIT", + "Repository": { + "Type": "git", + "Url": "https://github.com/CommunityToolkit/dotnet", + "Commit": "638b41dad30dffabb123a39aa38eabc7e3721371" + } + }, + { + "PackageName": "Flow.Launcher.Localization", + "PackageVersion": "0.0.6", + "PackageUrl": "https://github.com/Flow-Launcher/Flow.Launcher.Localization", + "Copyright": "Flow-Launcher", + "Authors": [ + "Flow-Launcher" + ], + "Description": "Localization toolkit for Flow Launcher and its plugins", + "LicenseUrl": "https://licenses.nuget.org/MIT", + "LicenseType": "MIT", + "Repository": { + "Type": "git", + "Url": "https://github.com/Flow-Launcher/Flow.Launcher.Localization", + "Commit": "456bdc7a986487d691a3ae8d36f8bce7b88b9bc7" + } + }, + { + "PackageName": "Microsoft.Data.Sqlite", + "PackageVersion": "10.0.1", + "PackageUrl": "https://docs.microsoft.com/dotnet/standard/data/sqlite/", + "Copyright": "© Microsoft Corporation. All rights reserved.", + "Authors": [ + "Microsoft" + ], + "Description": "Microsoft.Data.Sqlite is a lightweight ADO.NET provider for SQLite.\r\n\r\nCommonly Used Types:\r\nMicrosoft.Data.Sqlite.SqliteCommand\r\nMicrosoft.Data.Sqlite.SqliteConnection\r\nMicrosoft.Data.Sqlite.SqliteConnectionStringBuilder\r\nMicrosoft.Data.Sqlite.SqliteDataReader\r\nMicrosoft.Data.Sqlite.SqliteException\r\nMicrosoft.Data.Sqlite.SqliteFactory\r\nMicrosoft.Data.Sqlite.SqliteParameter\r\nMicrosoft.Data.Sqlite.SqliteTransaction", + "LicenseUrl": "https://licenses.nuget.org/MIT", + "LicenseType": "MIT", + "Repository": { + "Type": "git", + "Url": "https://github.com/dotnet/dotnet", + "Commit": "fad253f51b461736dfd3cd9c15977bb7493becef" + } + }, + { + "PackageName": "SkiaSharp", + "PackageVersion": "3.119.1", + "PackageUrl": "https://go.microsoft.com/fwlink/?linkid=868515", + "Copyright": "© Microsoft Corporation. All rights reserved.", + "Authors": [ + "Microsoft" + ], + "Description": "SkiaSharp is a cross-platform 2D graphics API for .NET platforms based on Google's Skia Graphics Library.\r\nIt provides a comprehensive 2D API that can be used across mobile, server and desktop models to render images.", + "LicenseUrl": "https://licenses.nuget.org/MIT", + "LicenseType": "MIT", + "Repository": { + "Type": "git", + "Url": "https://go.microsoft.com/fwlink/?linkid=868515", + "Commit": "cc78b5933d23e6383db5d246e70db915770d55d6" + } + }, + { + "PackageName": "Svg.Skia", + "PackageVersion": "3.2.1", + "PackageUrl": "https://github.com/wieslawsoltes/Svg.Skia", + "Copyright": "Copyright © Wiesław Šoltés 2025", + "Authors": [ + "Wiesław Šoltés" + ], + "Description": "An SVG rendering library.", + "LicenseUrl": "https://licenses.nuget.org/MIT", + "LicenseType": "MIT", + "Repository": { + "Type": "git", + "Url": "https://github.com/wieslawsoltes/Svg.Skia", + "Commit": "0164d01769a8b577f6dcc678f25d4802a06ff8c0" + } + } +] diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.md b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000000..c1d33e177f1 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.md @@ -0,0 +1,32 @@ +# Third-party notices + +This project uses third-party NuGet packages. + +| Reference | Version | License Type | License | +|---------------------------------------------------------------------------------------------| +| BrowserTabs | 0.2.0 | Apache-2.0 | https://licenses.nuget.org/Apache-2.0 | +| CommunityToolkit.Mvvm | 8.4.0 | MIT | https://licenses.nuget.org/MIT | +| Flow.Launcher.Localization | 0.0.6 | MIT | https://licenses.nuget.org/MIT | +| Microsoft.Data.Sqlite | 10.0.1 | MIT | https://licenses.nuget.org/MIT | +| SkiaSharp | 3.119.1 | MIT | https://licenses.nuget.org/MIT | +| Svg.Skia | 3.2.1 | MIT | https://licenses.nuget.org/MIT | + +Detailed information (package id, version, license, repository URL) is available in [THIRD_PARTY_NOTICES.json](THIRD_PARTY_NOTICES.json). + +# Additional credits + +Initially there was a plan to integrate [Browser Bookmarks plugin](https://github.com/Flow-Launcher/Flow.Launcher/tree/dev/Plugins/Flow.Launcher.Plugin.BrowserBookmark) and [Browser Tabs plugin](https://github.com/Flow-Launcher/Flow.Launcher.Plugin.BrowserTabs) but it looks like inter-plugin communication or integration of plugins is not possible. +Finally Browser Tabs plugin wasn't used but **its code had great impact on this final solution**. + +# How to generate the list + +1. Install `dotnet-project-licenses` +1. Use the tool as below +1. Copy markdown above +1. Rename `licenses.json` to `THIRD_PARTY_NOTICES.json` and format the json + +``` +cd Plugins\Flow.Launcher.Plugin.BrowserBookmark +dotnet tool install --global dotnet-project-licenses +dotnet-project-licenses --input Flow.Launcher.Plugin.BrowserBookmark.csproj --json +``` diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md new file mode 100644 index 00000000000..d467a8573f2 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md @@ -0,0 +1,27 @@ +# Context + +Existing plugins focus on their areas of operation - e.g. Browser Bookmarks on bookmarks only, Browser Tabs on tabs only. +Why not join these two worlds to create synergy? +Especially if one works with **tens or hundreds of bookmarks and open tabs** (I do and I constantly struggle finding the correct tab. It is underestimated mental cost of finding, clicking several times, etc.). + +That's where "Reuse tabs" setting in Browser Bookmarks plugin makes sense. + +I believe it is in line with why Flow Launcher was created in the first place. +I strongly believe in a higher-level concept of **"just take me to THIS place - as fast as possible, as easy as possible"**. +Thus making bridges between plugins may sometimes produce huge value! BTW wouldn't it be nice to allow inter-plugin communication to create this kind of "bridges" more easily? + +# How it works + +The core is Browser Bookmarks plugin, unchanged by default. +You may enable "Reuse tabs" in the plugin settings. +Then, whenever one opens a bookmark, it also registers a new tab in its cache. +Next, each time the bookmark is triggered again, it just switches to the existing tab instead of launching a new one. +**It takes milliseconds instead of long seconds** (or sometimes close to half a minute in corporate environments where all is slow even if you have a high-end laptop - you won't believe it until you live it!). + +# Alternatives + +Reading URLs of existing tabs was tried. It would make mapping of bookmarks to tabs more reliable. +However due to security reasons it has several limitations: + +- different browsers expose internals differently +- it is not easily accessible (e.g. you cannot make Chrome expose internal details on a dev TCP port from default profile so user would have to take care about special settings). diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs new file mode 100644 index 00000000000..432b62e5498 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Windows.Automation; +using static Flow.Launcher.Plugin.BrowserBookmark.Main; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs; + +/// +/// TabsCache keeps record of all known browser's tabs in a single web browser window. +/// +public class TabsCache +{ + private static readonly string ClassName = nameof(TabsCache); + + // UIA is unreliable. It may return zero elements or a subset of elements. + // Thus removal of an AutomationElement from cache SHOULD NOT be done immediately but rather after a few times the element no longer exists. + private static int _removeAtAge = 3; + + private readonly Lock _sync = new(); + private Dictionary _elementToInfo = []; + private SortedDictionary _indexToElement = []; + public bool Valid { get; private set; } = false; + + private class Info(int index) + { + public int Index { get; init; } = index; + public int Age { get; set; } = 0; + } + + private static string TryName(AutomationElement element) + { + try + { + return element.Current.Name; + } + catch (Exception e) + { + return e.GetType().ToString(); + } + } + + private static bool Destroyed(AutomationElement element) + { + try + { + var _ = element.Current.Name; + } + catch (ElementNotAvailableException) + { + return true; + } + return false; + } + + public void Invalidate() + { + lock (_sync) + { + Valid = false; + } + } + + public List GetTabs() + { + lock (_sync) + { + return [.. _indexToElement.Values]; + } + } + + public AutomationElement TryGetTab(int index) + { + lock (_sync) + { + Context.API.LogDebug(ClassName, $"TABS:Checking if tab {index} exists in the cache of {_elementToInfo.Count} size and indices between {_indexToElement.Keys.FirstOrDefault()} and {_indexToElement.Keys.LastOrDefault()}"); + + if (_indexToElement.TryGetValue(index, out var tab)) + { + return tab; + } + return null; + } + } + + public int UpdateTabs(int lastAssignedIndex, List actualTabs, out List removedTabs) + { + lock (_sync) + { + Context.API.LogDebug(ClassName, $"TABS:Start comparing {actualTabs.Count} actual tabs to {_elementToInfo.Count} tabs in the cache; new tabs will start from {lastAssignedIndex+1}"); + + removedTabs = []; + + var tabsToRemove = _elementToInfo.Where(t => !actualTabs.Contains(t.Key)).Select(t => t.Key).ToList(); + var tabsToAdd = actualTabs.Where(t => !_elementToInfo.ContainsKey(t)).ToList(); + var tabsToRevive = _elementToInfo.Where(t => actualTabs.Contains(t.Key) && t.Value.Age > 0).ToList(); + + foreach (var tabToRemove in tabsToRemove) + { + if (_elementToInfo.TryGetValue(tabToRemove, out var info)) + { + if (Destroyed(tabToRemove) || info.Age >= _removeAtAge) + { + Context.API.LogDebug(ClassName, $"TABS:Removing {TryName(tabToRemove)} from cache"); + _elementToInfo.Remove(tabToRemove); + _indexToElement.Remove(info.Index); + removedTabs.Add(tabToRemove); + } + else + { + Context.API.LogDebug(ClassName, $"TABS:Aging {TryName(tabToRemove)} in cache (got age {info.Age + 1}, will be removed at age {_removeAtAge} or on ElementNotAvailableException)"); + _elementToInfo[tabToRemove].Age++; + } + } + } + + foreach (var tabToAdd in tabsToAdd) + { + Context.API.LogDebug(ClassName, $"TABS:Adding {TryName(tabToAdd)} to cache"); + var newIndex = ++lastAssignedIndex; + _elementToInfo[tabToAdd] = new Info(newIndex); + _indexToElement[newIndex] = tabToAdd; + } + + foreach (var tabToRevive in tabsToRevive) + { + Context.API.LogDebug(ClassName, $"TABS:Reset age of {TryName(tabToRevive.Key)} as it appeared again"); + _elementToInfo[tabToRevive.Key].Age = 0; + } + + Valid = true; + return lastAssignedIndex; + } + } +} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsEventsDispatcher.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsEventsDispatcher.cs new file mode 100644 index 00000000000..77197a26c33 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsEventsDispatcher.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Threading; +using Flow.Launcher.Plugin.BrowserBookmark.Tabs; +using static Flow.Launcher.Plugin.BrowserBookmark.Main; + +/// +/// TabsEventsDispatcher handles events in a separate thread. +/// This is to make handlers fast by not to blocking them. +/// +public sealed class TabsEventsDispatcher : IDisposable +{ + private static readonly string ClassName = nameof(TabsEventsDispatcher); + + private readonly BlockingCollection> _queue = []; + private readonly Thread _worker; + private readonly CancellationTokenSource _cts = new(); + private readonly TabsReservationService _reservationService; + + private readonly TimeSpan _tabRetryTimeout = TimeSpan.FromSeconds(10); + private readonly TimeSpan _tabRetryInterval = TimeSpan.FromMilliseconds(250); + + public TabsEventsDispatcher(TabsTracker tracker, TabsReservationService reservationService) + { + _reservationService = reservationService; + + _worker = new Thread(WorkerLoop) + { + IsBackground = true, + Name = "TabsEventsDispatcher" + }; + _worker.Start(); + } + + public void Enqueue(string url, TabsReservationService.TokenForNewTab token) + { + if (!_queue.IsAddingCompleted) + _queue.Add(Tuple.Create(url, token)); + } + + void WorkerLoop() + { + try + { + foreach (var tuple in _queue.GetConsumingEnumerable(_cts.Token)) + { + HandleUrl(tuple); + } + } + catch (OperationCanceledException) + { + // shutting down + } + } + + void HandleUrl(Tuple tuple) + { + var url = tuple.Item1; + var tokenForNewTab = tuple.Item2; + + var sw = Stopwatch.StartNew(); + var count = 1; + + while (sw.Elapsed < _tabRetryTimeout && !_cts.Token.IsCancellationRequested) + { + var element = _reservationService.TryToResolveToken(tokenForNewTab, out var trackingInfo); + if (element != null) + { + _reservationService.RegisterTab(url, trackingInfo, element); + return; + } + + Context.API.LogDebug(ClassName, $"TABS:No new tab found on try {count++}. Will sleep for {_tabRetryInterval.TotalMilliseconds} ms."); + Thread.Sleep(_tabRetryInterval); + } + + Context.API.LogError(ClassName, "TABS:Timeout waiting for a new tab - assuming events are guaranteed and handled well this situation should not happen"); + } + + public void Dispose() + { + _cts.Cancel(); + _queue.CompleteAdding(); + _worker.Join(); + _cts.Dispose(); + _queue.Dispose(); + } +} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsReservationService.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsReservationService.cs new file mode 100644 index 00000000000..5f125a862cd --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsReservationService.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Windows.Automation; +using BrowserTabs; +using Flow.Launcher.Plugin.BrowserBookmark.Models; +using static Flow.Launcher.Plugin.BrowserBookmark.Main; +using static Flow.Launcher.Plugin.BrowserBookmark.Tabs.TabsTracker; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs; + +/// +/// TabsReservationService provides mapping between URLs and browser tabs. +/// 1. You get a token while registering an URL +/// 2. Later on you may replace token for corresponding browser tab after it appears +/// TabsReservationService also integrates with BrowserBookmark's query by injecting activation of tabs instead of OpenUrl (InjectExistingTabs). +/// +public class TabsReservationService : IDisposable +{ + private static readonly string ClassName = nameof(TabsReservationService); + + private readonly Lock _sync = new(); + + private readonly Dictionary _tokens = []; + + private readonly ConcurrentDictionary _urlToBrowserTab = []; + private readonly ConcurrentDictionary _automationElementToUrl = []; + + private static TabsTracker _tabsTracker; + + public TabsReservationService() + { + _tabsTracker = new TabsTracker(this); + } + + /// + /// TokenForNewTab is kind of a promise that may be replaced for a real tab after it is finally created and available + /// + public class TokenForNewTab(int index) + { + public int Index { get; init; } = index; + } + + /// + /// TokenForNewTabHandling is a utility class for proper handling of tokens for new tabs (TokenForNewTab) + /// + private class TokenForNewTabHandling(TokenForNewTab token) + { + public TokenForNewTab Token { get; init; } = token; + public int LastReturnedIndex { get; set; } + public int RequestedStill { get; set; } = 1; + } + + public void OpenUrlAndTrack(Settings settings, string url) + { + if (settings.ReuseTabs) + { + _tabsTracker.MakeSnapshot(RegisterToken, url); + } + + Context.API.OpenUrl(url); + } + + public List InjectExistingTabs(Settings settings, List results) + { + if (!settings.ReuseTabs) + { + return results; + } + + foreach (var r in results) + { + var bookmarkUrl = ((BookmarkAttributes)r.ContextData).Url; + if (_urlToBrowserTab.TryGetValue(bookmarkUrl, out var existingTab)) + { + r.ContextData = existingTab; + r.Action = c => + { + if (!existingTab.ActivateTab()) + { + Context.API.LogError(ClassName, "TABS:Failed to activate a tab"); + _urlToBrowserTab.Remove(bookmarkUrl, out _); + _automationElementToUrl.Remove(existingTab.AutomationElement, out _); + + OpenUrlAndTrack(settings, bookmarkUrl); + } + return true; + }; + } + } + return results; + } + + private TokenForNewTab RegisterToken(int lastAssignedIndex) + { + lock (_sync) + { + if (_tokens.TryGetValue(lastAssignedIndex, out var tokenHandling)) + { + ++tokenHandling.RequestedStill; + return tokenHandling.Token; + } + + var token = new TokenForNewTab(lastAssignedIndex); + _tokens[lastAssignedIndex] = new TokenForNewTabHandling(token); + return token; + } + } + + public AutomationElement TryToResolveToken(TokenForNewTab token, out TrackingInfo trackingInfo) + { + lock (_sync) + { + if (!_tokens.TryGetValue(token.Index, out var tokenHandling)) + { + Context.API.LogError(ClassName, $"Trying to use an invalid token for index {token.Index}"); + trackingInfo = null; + return null; + } + + _tabsTracker.MakeSnapshot(); + + int expectedIndex = Math.Max(tokenHandling.LastReturnedIndex, token.Index) + 1; + var (tab, info) = _tabsTracker.TryGetTab(expectedIndex); + trackingInfo = info; + if (tab != null) + { + if (tokenHandling.RequestedStill <= 1) + { + _tokens.Remove(token.Index, out var _); + } + else + { + --tokenHandling.RequestedStill; + tokenHandling.LastReturnedIndex = expectedIndex; + } + } + return tab; + } + } + + public void RegisterTab(string url, TrackingInfo trackingInfo, AutomationElement tab) + { + lock (_sync) + { + try + { + var currentTab = new BrowserTab + { + Title = tab.Current.Name, + BrowserName = trackingInfo.ProcessName, + Hwnd = trackingInfo.ProcessMainWindowHandle, + AutomationElement = tab + }; + + Context.API.LogDebug(ClassName, $"TABS:{RuntimeIdToKey(currentTab.AutomationElement)}:Registering {url} as tab: {currentTab.Title}"); + _urlToBrowserTab[url] = currentTab; + _automationElementToUrl[currentTab.AutomationElement] = url; + + // required to take the tab into account by Flow Launcher main UI search window + Context.API.ReQuery(); + } + catch (ElementNotAvailableException) + { + Context.API.LogDebug(ClassName, $"TABS:Tab became unavailable before registration for {url}"); + } + } + } + + public void UnregisterTabs(IEnumerable elements) + { + lock (_sync) + { + foreach (var element in elements) + { + if (_automationElementToUrl.TryGetValue(element, out var url)) + { + _urlToBrowserTab.Remove(url, out _); + _automationElementToUrl.Remove(element, out _); + } + } + } + } + + public void Dispose() + { + _tabsTracker.Dispose(); + } + + public void EnableTracking(bool reuseTabs) + { + _tabsTracker.EnableTracking(reuseTabs); + if (reuseTabs) + { + lock (_sync) + { + _urlToBrowserTab.Clear(); + _automationElementToUrl.Clear(); + } + } + } +} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs new file mode 100644 index 00000000000..18fdd0a52f0 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs @@ -0,0 +1,368 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Windows.Automation; +using static Flow.Launcher.Plugin.BrowserBookmark.Main; +using static Flow.Launcher.Plugin.BrowserBookmark.Tabs.TabsReservationService; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs; + +/// +/// TabsTracker builds full list of all browsers windows and their tabs. +/// TabsTracker also maintains the lists (invalidates on events, updates lazily on demand) +/// +public class TabsTracker : IDisposable +{ + private static readonly string ClassName = nameof(TabsTracker); + + // Firefox - tested on version: 146.0.1 + // Chrome - tested on version: 143.0.7499.170 + // Edge - tested on version: 143.0.3650.96 + // Brave - NOT tested + // Vivaldi - NOT tested + // Opera - NOT tested + private static readonly HashSet firefoxProcessNames = new(["firefox"], StringComparer.OrdinalIgnoreCase); + private static readonly HashSet chromiumProcessNames = new(["msedge", "chrome", "brave", "vivaldi", "opera", "chromium"], StringComparer.OrdinalIgnoreCase); + + private bool _trackingEnabled = false; + + private readonly Lock _sync = new(); + private int _lastAssignedIndex; + private bool _windowsHandlerInitialized = false; + private Dictionary _browserWindowsTracked = []; + + private readonly ConcurrentQueue> _expectedUrls = []; + + private TabsEventsDispatcher _eventsDispatcher; + + private readonly Lock _syncInvalidations = new(); + private HashSet _structureInvalidations = []; + + private TabsReservationService _service; + + public TabsTracker(TabsReservationService service) + { + _service = service; + } + + public static string RuntimeIdToKey(AutomationElement elem) + { + try + { + return elem != null ? string.Join("-", elem.GetRuntimeId()) : null; + } + catch (ElementNotAvailableException) + { + return null; + } + } + + private static IEnumerable FindAllValidTabs(AutomationElement browserWindow) + { + Condition tabCondition = new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.TabItem); + + foreach (AutomationElement tab in browserWindow.FindAll(TreeScope.Descendants, tabCondition)) + { + var name = tab.Current.Name; + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + + // There are kind of technical tabs that should be ignored + var className = tab.Current.ClassName; + if (className.Contains("bolt-tab", StringComparison.OrdinalIgnoreCase)) + { + Context.API.LogDebug(ClassName, $"TABS:Skipping name='{name}', className='{className}'"); + continue; + } + + yield return tab; + } + } + + /// + /// TrackingInfo keeps context of a single browser window + /// + public class TrackingInfo(AutomationElement rootElement, StructureChangedEventHandler structureChangedHandler, AutomationEventHandler windowCloseHandler, string processName, nint processMainWindowHandle) + { + public AutomationElement RootElement { get; init; } = rootElement; + public StructureChangedEventHandler StructureChangedHandler { get; init; } = structureChangedHandler; + public AutomationEventHandler WindowCloseHandler { get; init; } = windowCloseHandler; + public string ProcessName { get; init; } = processName; + public nint ProcessMainWindowHandle { get; init; } = processMainWindowHandle; + public TabsCache Cache { get; init; } = new TabsCache(); + } + + public void Dispose() + { + DisableTracking(); + } + + /// + /// Makes snapshot of all browsers windows and all their tabs. + /// Optionally it may register a token. + /// + public void MakeSnapshot(Func registerToken = null, string requestedUrl = null) + { + lock (_sync) + { + EnsureHavingAllBrowsersWindows(); + EnsureHavingAllBrowsersTabs(); + if (registerToken != null) + { + var token = registerToken(_lastAssignedIndex); + _expectedUrls.Enqueue(Tuple.Create(requestedUrl, token)); + } + } + } + + private void EnsureHavingAllBrowsersWindows() + { + // this is done once + // later on list is updated using WindowOpen / WindowClose events + if (!_windowsHandlerInitialized) + { + Context.API.LogDebug(ClassName, "TABS:EnsureHavingAllBrowsersWindows initializing ..."); + + var desktop = AutomationElement.RootElement; + Automation.AddAutomationEventHandler(WindowPattern.WindowOpenedEvent, desktop, TreeScope.Children, OnWindowOpen); + + var topLevelWindows = desktop.FindAll(TreeScope.Children, Condition.TrueCondition); + foreach (AutomationElement element in topLevelWindows) + { + HandleProcessStart(element); + } + + _windowsHandlerInitialized = true; + } + } + + private void OnWindowOpen(object src, AutomationEventArgs e) + { + Context.API.LogDebug(ClassName, "TABS:OnWindowOpen"); + AutomationElement element = src as AutomationElement; + if (element != null) + HandleProcessStart(element); + } + + private void OnWindowClose(AutomationElement element, object src, AutomationEventArgs e) + { + Context.API.LogDebug(ClassName, $"TABS:OnWindowClose {RuntimeIdToKey(element)}"); + lock (_sync) + { + bool structureChangesTracked = _browserWindowsTracked.TryGetValue(element, out var trackingInfo); + if (structureChangesTracked && trackingInfo.WindowCloseHandler != null) + { + Automation.RemoveAutomationEventHandler(WindowPattern.WindowClosedEvent, element, trackingInfo.WindowCloseHandler); + } + + HandleProcessExit(element); + } + } + + private static Process TryProcess(int processId) + { + try + { + return Process.GetProcessById(processId); + } + catch (Exception) + { + return null; + } + } + + private void HandleProcessStart(AutomationElement element) + { + var processId = element.Current.ProcessId; + using var process = TryProcess(processId); + if (process == null) + return; + + string processName = process.ProcessName.ToLowerInvariant(); + var chromium = chromiumProcessNames.Contains(processName); + var firefox = firefoxProcessNames.Contains(processName); + if (!chromium && !firefox) + return; + + Context.API.LogDebug(ClassName, $"TABS:Found a browser window for {processName}, PID={processId}"); + lock (_sync) + { + bool structureChangesTracked = _browserWindowsTracked.ContainsKey(element); + if (!structureChangesTracked) + { + SubscribeStructureChangedEventHandler(element, process); + } + } + } + + private void HandleProcessExit(AutomationElement element) + { + lock (_sync) + { + bool structureChangesTracked = _browserWindowsTracked.TryGetValue(element, out var trackingInfo); + if (structureChangesTracked) + { + UnsubscribeStructureChangedEventHandler(element, trackingInfo); + } + } + } + + private void SubscribeStructureChangedEventHandler(AutomationElement element, Process process) + { + void structureChangedHandler(object sender, StructureChangedEventArgs e) + { + OnStructureChanged(element, sender, e); + } + + Automation.AddStructureChangedEventHandler(element, TreeScope.Subtree, structureChangedHandler); + + void windowCloseHandler(object src, AutomationEventArgs e) + { + OnWindowClose(element, src, e); + } + + _browserWindowsTracked[element] = new TrackingInfo(element, structureChangedHandler, windowCloseHandler, process.ProcessName, process.MainWindowHandle); + + Automation.AddAutomationEventHandler(WindowPattern.WindowClosedEvent, element, TreeScope.Element, windowCloseHandler); + + Context.API.LogDebug(ClassName, $"TABS:Window {RuntimeIdToKey(element)} SUBSCRIBED for StructureChanged events"); + } + + private void UnsubscribeStructureChangedEventHandler(AutomationElement element, TrackingInfo trackingInfo) + { + Automation.RemoveStructureChangedEventHandler(element, trackingInfo.StructureChangedHandler); + _service.UnregisterTabs(trackingInfo.Cache.GetTabs()); + _browserWindowsTracked.Remove(element); + + Context.API.LogDebug(ClassName, $"TABS:Window {RuntimeIdToKey(element)} UNSUBSCRIBED from StructureChanged events"); + } + + private void EnsureHavingAllBrowsersTabs() + { + Context.API.LogDebug(ClassName, "TABS:EnsureHavingAllBrowsersTabs ..."); + + List elementsToInvalidate; + lock (_syncInvalidations) + { + elementsToInvalidate = _structureInvalidations.Where(_browserWindowsTracked.ContainsKey).ToList(); + _structureInvalidations.Clear(); + } + foreach (var element in elementsToInvalidate) + { + _browserWindowsTracked[element].Cache.Invalidate(); + } + + foreach (var pair in _browserWindowsTracked) + { + if (!pair.Value.Cache.Valid) + { + try + { + _lastAssignedIndex = pair.Value.Cache.UpdateTabs(_lastAssignedIndex, [.. FindAllValidTabs(pair.Key)], out var removedTabs); + _service.UnregisterTabs(removedTabs); + } + catch (ElementNotAvailableException) + { + Context.API.LogError(ClassName, "ElementNotAvailableException while updating tabs"); + } + } + } + } + + private void OnStructureChanged(AutomationElement window, object sender, StructureChangedEventArgs e) + { + //Context.API.LogDebug(ClassName, $"TABS:Received {e.StructureChangeType.ToString()} on {sender}"); + switch (e.StructureChangeType) + { + case StructureChangeType.ChildAdded: + case StructureChangeType.ChildrenBulkAdded: + case StructureChangeType.ChildrenInvalidated: + case StructureChangeType.ChildrenReordered: + case StructureChangeType.ChildRemoved: + case StructureChangeType.ChildrenBulkRemoved: + lock (_syncInvalidations) + { + _structureInvalidations.Add(window); + } + break; + } + switch (e.StructureChangeType) + { + case StructureChangeType.ChildAdded: + case StructureChangeType.ChildrenBulkAdded: + while (_expectedUrls.TryDequeue(out var tuple)) + { + lock (_sync) + { + _eventsDispatcher ??= new(this, _service); + _eventsDispatcher.Enqueue(tuple.Item1, tuple.Item2); + } + } + break; + } + } + + public (AutomationElement, TrackingInfo) TryGetTab(int expectedIndex) + { + lock (_sync) + { + foreach (var trackingInfo in _browserWindowsTracked.Values) + { + var tab = trackingInfo.Cache.TryGetTab(expectedIndex); + if (tab != null) + { + return (tab, trackingInfo); + } + } + return (null, null); + } + } + + public void EnableTracking(bool enable) + { + lock (_sync) + { + if (_trackingEnabled == enable) + return; + + _trackingEnabled = enable; + if (!_trackingEnabled) + { + DisableTracking(); + } + } + } + + private void DisableTracking() + { + lock (_syncInvalidations) + { + _structureInvalidations.Clear(); + } + + lock (_sync) + { + _eventsDispatcher?.Dispose(); + _eventsDispatcher = null; + + if (_windowsHandlerInitialized) + { + Automation.RemoveAutomationEventHandler(WindowPattern.WindowOpenedEvent, AutomationElement.RootElement, OnWindowOpen); + + foreach (var tracking in _browserWindowsTracked) + { + UnsubscribeStructureChangedEventHandler(tracking.Key, tracking.Value); + } + _browserWindowsTracked.Clear(); + _expectedUrls.Clear(); + + _windowsHandlerInitialized = false; + } + } + } +} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml index 0767ee98069..5ebc98d91a8 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml @@ -13,6 +13,7 @@ + + \ No newline at end of file diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml.cs index 1ee6b5c4551..643c397c887 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml.cs @@ -48,6 +48,15 @@ public bool LoadEdgeBookmark } } + public bool ReuseTabs + { + get => Settings.ReuseTabs; + set + { + _ = Task.Run(() => Main.SetReuseTabs(value)); + } + } + public bool OpenInNewBrowserWindow { get => Settings.OpenInNewBrowserWindow;