From c4549d663dfc32ce21d43baa7aac97fcf41a661c Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Sun, 28 Dec 2025 17:32:20 +0100 Subject: [PATCH 01/35] Implement reusing of existing tabs --- ...low.Launcher.Plugin.BrowserBookmark.csproj | 1 + .../Main.cs | 21 ++- .../Models/Settings.cs | 1 + .../THIRD_PARTY_NOTICES.json | 92 ++++++++++ .../THIRD_PARTY_NOTICES.md | 26 +++ .../Tabs/README.md | 42 +++++ .../Tabs/TabsCache.cs | 51 ++++++ .../Tabs/TabsDebug.cs | 73 ++++++++ .../Tabs/TabsTracker.cs | 163 ++++++++++++++++++ .../Tabs/TabsWalker.cs | 117 +++++++++++++ 10 files changed, 578 insertions(+), 9 deletions(-) create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.json create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.md create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsDebug.cs create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs 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 ba547c86ad9..76f97240f8b 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/Main.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs index 07ce510fb3e..413fc5e2aba 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 readonly TabsTracker tabsTracker = new(); + public void Init(PluginInitContext context) { Context = context; @@ -58,6 +61,8 @@ public void Init(PluginInitContext context) } LoadBookmarksIfEnabled(); + + tabsTracker.Init(); } private static void LoadBookmarksIfEnabled() @@ -88,11 +93,10 @@ public List Query(Query query) // Should top results be returned? (true if no search parameters have been passed) var topResults = string.IsNullOrEmpty(param); - if (!topResults) { // Since we mixed chrome and firefox bookmarks, we should order them again - return _cachedBookmarks + return tabsTracker.InjectExistingTabs(_settings, _cachedBookmarks .Select( c => new Result { @@ -104,19 +108,18 @@ public List Query(Query query) Score = BookmarkLoader.MatchProgram(c, param).Score, Action = _ => { - Context.API.OpenUrl(c.Url); - + tabsTracker.OpenUrlAndTrack(_settings, c.Url); return true; }, ContextData = new BookmarkAttributes { Url = c.Url } } ) .Where(r => r.Score > 0) - .ToList(); + .ToList()); } else { - return _cachedBookmarks + return tabsTracker.InjectExistingTabs(_settings, _cachedBookmarks .Select( c => new Result { @@ -128,13 +131,13 @@ public List Query(Query query) Score = 5, Action = _ => { - Context.API.OpenUrl(c.Url); + tabsTracker.OpenUrlAndTrack(_settings, c.Url); return true; }, ContextData = new BookmarkAttributes { Url = c.Url } } ) - .ToList(); + .ToList()); } } 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..d51a8423df4 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.json @@ -0,0 +1,92 @@ +[ + { + "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": "", + "Url": "https://github.com/jjw24/BrowserTabs", + "Commit": "" + } + }, + { + "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": "", + "Copyright": "", + "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.0", + "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": "b0f34d51fccc69fd334253924abd8d6853fad7aa" + } + }, + { + "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..bd05686144c --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.md @@ -0,0 +1,26 @@ +# 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.0 | 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). + +# 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 + +``` +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..08426176629 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md @@ -0,0 +1,42 @@ +# 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!). + +# Known issues + +The extension bases on RuntimeId of AutomationElement from Microsoft UI Automation. +This is a weak spot as browsers are used to regenerate internal structures and even reuse RuntimeId. +It sometimes means that a bookmark activates a wrong tab. +Still **"just take me to THIS place in milliseconds** works almost all of the time so it bring so much value that it is worthwhile to accepts the fact it fails sometimes. + +The quickest workaround is: + +- close the wrong tab +- rerun opening the bookmark which will create a new tab this time + +# 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 exposes 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). + +_"Reuse tabs" settings created initially by [Andrzej Martyna](https://github.com/andrzejmartyna)_ 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..205129d21c2 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using System.Windows.Automation; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs; + +/// +/// Keeps record of all known browser's tabs. +/// It is used by TabsWalker to identify new tabs as they appear. +/// +internal class TabsCache +{ + private readonly HashSet _knownTabs = new(); + private readonly object sync = new(); + + private static string RuntimeIdToKey(AutomationElement elem) => elem != null ? string.Join("-", elem.GetRuntimeId()) : "NULL"; + + public bool Empty() + { + lock (sync) + { + return _knownTabs.Count == 0; + } + } + + public void Add(AutomationElement tab) + { + lock (sync) + { + _knownTabs.Add(RuntimeIdToKey(tab)); + } + } + + public void Add(IEnumerable tabs) + { + lock (sync) + { + foreach (var tab in tabs) + { + _knownTabs.Add(RuntimeIdToKey(tab)); + } + } + } + + public bool Contains(AutomationElement tab) + { + lock (sync) + { + return _knownTabs.Contains(RuntimeIdToKey(tab)); + } + } +} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsDebug.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsDebug.cs new file mode 100644 index 00000000000..25809a5c68f --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsDebug.cs @@ -0,0 +1,73 @@ +using System; +using System.Windows.Automation; +using static Flow.Launcher.Plugin.BrowserBookmark.Main; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs; + +/// +/// Just for debugging. +/// Call DumpElements whenever you need to analyze browser's internal structure. +/// +internal class TabsDebug +{ + private static readonly string ClassName = nameof(TabsDebug); + + public static void DumpElements(AutomationElement parent, string classNameOnly = null, string controlTypeOnly = null, int indent = 0) + { + AutomationElementCollection children; + try + { + children = parent.FindAll(TreeScope.Children, Condition.TrueCondition); + } + catch (ElementNotAvailableException ex) + { + Context.API.LogDebug(ClassName, $"Parent not available: {ex.Message}"); + return; + } + + foreach (AutomationElement child in children) + { + try + { + var ct = child.Current.ControlType; + var type = ct?.ProgrammaticName?.Replace("ControlType.", ""); + var name = child.Current.Name; + var className = child.Current.ClassName; + var isOffscreen = child.Current.IsOffscreen; + var isEnabled = child.Current.IsEnabled; + var rect = child.Current.BoundingRectangle; + + var dump = true; + if (!string.IsNullOrEmpty(classNameOnly) && className != classNameOnly) + dump = false; + + if (!string.IsNullOrEmpty(controlTypeOnly) && type != controlTypeOnly) + dump = false; + + if (dump) + { + Context.API.LogDebug( + ClassName, + $"{new string(' ', indent)}" + + $"Type='{type}', " + + $"ClassName='{className}', " + + $"Name='{name}', " + + $"IsOffscreen={isOffscreen}, " + + $"IsEnabled={isEnabled}, " + + $"BoundingRectangle={rect}" + ); + } + + DumpElements(child, classNameOnly, controlTypeOnly, indent + 2); + } + catch (ElementNotAvailableException ex) + { + Context.API.LogDebug(ClassName, $"Child not available: {ex.Message}"); + } + catch (Exception ex) + { + Context.API.LogException(ClassName, $"Unexpected error", ex); + } + } + } +} 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..319f74b4c3b --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Windows.Automation; +using BrowserTabs; +using Flow.Launcher.Plugin.BrowserBookmark.Models; +using static Flow.Launcher.Plugin.BrowserBookmark.Main; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs; + +/// +/// TabsTracker maps initial URLs into existing browser's tabs. +/// The sequence of events: +/// 1. OpenUrlAndTrack - before lauching an URL it is remembered for later mapping to a browser's tab +/// 2. OnFocusChanged - whenever a browser's window gets focused a new tab discovery is started and result is put into the UrlToBrowserTab map +/// 3. InjectExistingTabs - iterates over BrowserBookmark's query result and replaces OpenUrl with ActivateTab for known, existing tabs +/// +public class TabsTracker : IDisposable +{ + private static readonly string ClassName = nameof(TabsTracker); + private static readonly HashSet chromiumProcessNames = new HashSet(["msedge", "chrome", "brave", "vivaldi", "opera", "chromium"], StringComparer.OrdinalIgnoreCase); + private static readonly HashSet firefoxProcessNames = new HashSet(["firefox"], StringComparer.OrdinalIgnoreCase); + private readonly TabsWalker _walker = new(); + private string? _expectedUrl; + private Dictionary UrlToBrowserTab { get; } = []; + private readonly object _sync = new(); + + private AutomationFocusChangedEventHandler? _focusHandler; + private bool _initialized; + + public void OpenUrlAndTrack(Settings settings, string url) + { + if (settings.ReuseTabs) + { + Context.API.LogDebug(ClassName, $"Opening... {url}"); + ExpectUrl(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)) + { + Context.API.LogDebug(ClassName, $"Mapped {bookmarkUrl}"); + + r.ContextData = existingTab; + r.Action = c => + { + if (!existingTab.ActivateTab()) + { + Context.API.LogError(ClassName, "Failed to activate a tab"); + Remove(bookmarkUrl); + OpenUrlAndTrack(settings, bookmarkUrl); + } + return true; + }; + } + } + return results; + } + + public void Init() + { + if (_initialized) + return; + + _focusHandler = OnFocusChanged; + Automation.AddAutomationFocusChangedEventHandler(_focusHandler); + _initialized = true; + } + + public void Dispose() + { + if (_focusHandler != null) + Automation.RemoveAutomationFocusChangedEventHandler(_focusHandler); + } + + public void ExpectUrl(string url) + { + lock (_sync) + { + if (_expectedUrl != null) + { + Context.API.LogError(ClassName, $"Opening {url} while older is still not resolved ({_expectedUrl}). Forgetting the older."); + } + _expectedUrl = url; + } + } + + private void Remove(string url) + { + lock (_sync) + { + UrlToBrowserTab.Remove(url); + } + } + + private void OnFocusChanged(object sender, AutomationFocusChangedEventArgs e) + { + string? urlToBind; + lock (_sync) + { + urlToBind = _expectedUrl; + } + if (urlToBind is null) + return; + + try + { + Context.API.LogDebug(ClassName, $"Searching for... {urlToBind}"); + + if (sender is not AutomationElement element) + return; + + int pid = element.Current.ProcessId; + Process? process = null; + try { process = Process.GetProcessById(pid); } + catch { /* could disappear */ } + if (process is null) + return; + + var chromium = chromiumProcessNames.Contains(process.ProcessName); + var firefox = firefoxProcessNames.Contains(process.ProcessName); + if (!chromium && !firefox) + return; // not a browser + + Context.API.LogDebug(ClassName, $"The active browser is {process.ProcessName}"); + + var rootElement = AutomationElement.FromHandle(process.MainWindowHandle); + if (rootElement == null) + return; + + Context.API.LogDebug(ClassName, $"The root element is {rootElement.Current.Name}"); + + var currentTab = _walker.GetCurrentTabFromWindow(rootElement, process, CancellationToken.None); + if (currentTab != null) + { + lock (_sync) + { + Context.API.LogDebug(ClassName, $"Registering {urlToBind} as tab: {currentTab.Title}"); + UrlToBrowserTab[urlToBind] = currentTab; + _expectedUrl = null; + + // required to take the tab into account by Flow Launcher main UI search window + Context.API.ReQuery(); + } + } + } + catch (Exception ex) + { + Context.API.LogException(ClassName, "Exception", ex); + } + } +} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs new file mode 100644 index 00000000000..570b1102af5 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Windows.Automation; +using BrowserTabs; +using static Flow.Launcher.Plugin.BrowserBookmark.Main; + +namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs; + +/// +/// TabsWalker waits for a new browser's tab to appear. +/// It uses TabsCache to keep known tabs. +/// Note that browsers don't provide full control over this process, so we have to rely on heuristics and a "best effort" approach. +/// +internal class TabsWalker +{ + private static readonly string ClassName = nameof(TabsTracker); + private readonly TimeSpan _tabRetryTimeout = TimeSpan.FromSeconds(4); + private readonly TimeSpan _tabRetryInterval = TimeSpan.FromMilliseconds(250); + private readonly TabsCache _cache = new(); + + private static IEnumerable FindAllValidTabs(AutomationElement mainWindow) + { + Condition tabCondition = new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.TabItem); + foreach (AutomationElement tab in mainWindow.FindAll(TreeScope.Descendants, tabCondition)) + { + var name = tab.Current.Name; + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + + // on Chrome, 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, $"Skipping name='{name}', className='{className}'"); + continue; + } + + yield return tab; + } + } + + private static BrowserTab InitiateTab(Process process, AutomationElement tab) => new() + { + Title = tab.Current.Name, + BrowserName = process.ProcessName, + Hwnd = process.MainWindowHandle, + AutomationElement = tab + }; + + public BrowserTab GetCurrentTabFromWindow(AutomationElement mainWindow, Process process, CancellationToken cancellationToken) + { + try + { + var sw = Stopwatch.StartNew(); + var count = 1; + + while (sw.Elapsed < _tabRetryTimeout && !cancellationToken.IsCancellationRequested) + { + Context.API.LogDebug(ClassName, $"Start searching for a new tab... Try no {count++}"); + + var tabs = FindAllValidTabs(mainWindow).ToList(); + if (tabs.Count == 0) + { + Context.API.LogDebug(ClassName, "No valid tabs found"); + Thread.Sleep(_tabRetryInterval); + continue; + } + + if (_cache.Empty()) + { + Context.API.LogDebug(ClassName, "First time filling the cache"); + _cache.Add(tabs); + + // Let's take the last one and assume this is the one that was created recently + // This is the best known approach as of today + // There might be some browsers' settings that change this behavior but weren't tested nor considered yet + //TODO: research browsers' settings and check if it may break current assumption of just taking the last tab + return InitiateTab(process, tabs.Last()); + } + + Context.API.LogDebug(ClassName, $"Found tabs: {tabs.Count}"); + //TabsDebug.DumpElements(mainWindow, null, "Tab"); + + // searching from the end and looking for a tab not in the cache + for (var i = tabs.Count - 1; i >= 0; i--) + { + var tab = tabs[i]; + if (!_cache.Contains(tab)) + { + Context.API.LogDebug(ClassName, $"FOUND NEW TAB: name={tab.Current.Name}, className={tab.Current.ClassName}"); + _cache.Add(tab); + return InitiateTab(process, tab); + } + } + + Context.API.LogDebug(ClassName, "No new tab found"); + Thread.Sleep(_tabRetryInterval); + } + + Context.API.LogDebug(ClassName, "Timeout waiting for new tab"); + } + catch (ElementNotAvailableException ex) + { + Context.API.LogException(ClassName, "Element not available", ex); + } + catch (Exception ex) + { + Context.API.LogException(ClassName, "Error getting current tab from window", ex); + } + return null; + } +} From 02248eb971ae4826e9aa09764189eb317f58d870 Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Sun, 28 Dec 2025 17:34:10 +0100 Subject: [PATCH 02/35] Add ReuseTabs to Settings --- .../Languages/en.xaml | 1 + .../Languages/pl.xaml | 1 + .../Views/SettingsControl.xaml | 8 ++++++++ .../Views/SettingsControl.xaml.cs | 10 ++++++++++ 4 files changed, 20 insertions(+) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/en.xaml b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/en.xaml index 56471417309..0cd3bdbf113 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 (experimental) \ No newline at end of file diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/pl.xaml b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/pl.xaml index b63c2ffc5c0..197d913a3e8 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/pl.xaml +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/pl.xaml @@ -29,5 +29,6 @@ Jeśli nie używasz Chrome, Firefox lub Edge, lub używasz ich wersji przenośnej, musisz dodać katalog danych zakładek i wybrać poprawny silnik przeglądarki, aby wtyczka działała. Na przykład: silnikiem przeglądarki Brave jest Chromium, a domyślna lokalizacja danych zakładek to: "%LOCALAPPDATA%\BraveSoftware\Brave-Browser\UserData". W przypadku silnika Firefoksa, katalog zakładek to folder danych użytkownika zawierający plik places.sqlite. Wczytaj ikony ulubionych (może być czasochłonne podczas uruchamiania) + Ponownie używaj otwartych zakładek (funkcja eksperymentalna) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml index 0767ee98069..34891762d93 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..5a10cca3745 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml.cs @@ -48,6 +48,16 @@ public bool LoadEdgeBookmark } } + public bool ReuseTabs + { + get => Settings.ReuseTabs; + set + { + Settings.ReuseTabs = value; + _ = Task.Run(() => Main.ReloadAllBookmarks()); + } + } + public bool OpenInNewBrowserWindow { get => Settings.OpenInNewBrowserWindow; From 20f898ce2e036ce7a58badd34ff501ea293672f7 Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Sun, 28 Dec 2025 17:47:06 +0100 Subject: [PATCH 03/35] Additional attribution --- .../THIRD_PARTY_NOTICES.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.md b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.md index bd05686144c..aeb9b8fafb2 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.md +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.md @@ -13,6 +13,11 @@ This project uses third-party NuGet packages. 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 **it's code had great impact on this final solution**. + # How to generate the list 1. Install `dotnet-project-licenses` From df900f59191b1a9563086c50840afdcd61d379d9 Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Sun, 28 Dec 2025 18:06:32 +0100 Subject: [PATCH 04/35] small tweaks --- Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs | 1 + Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs index 413fc5e2aba..5b9e71ff861 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs @@ -93,6 +93,7 @@ public List Query(Query query) // Should top results be returned? (true if no search parameters have been passed) var topResults = string.IsNullOrEmpty(param); + if (!topResults) { // Since we mixed chrome and firefox bookmarks, we should order them again diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md index 08426176629..e4d011e0a87 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md @@ -23,8 +23,8 @@ Next, each time the bookmark is triggered again, it just switches to the existin The extension bases on RuntimeId of AutomationElement from Microsoft UI Automation. This is a weak spot as browsers are used to regenerate internal structures and even reuse RuntimeId. -It sometimes means that a bookmark activates a wrong tab. -Still **"just take me to THIS place in milliseconds** works almost all of the time so it bring so much value that it is worthwhile to accepts the fact it fails sometimes. +It **rarely** happens that a bookmark activates a wrong tab. +Still **"just take me to THIS place in milliseconds"** works almost all of the time so it bring so much value that it is worthwhile to accepts the fact it fails sometimes. The quickest workaround is: @@ -39,4 +39,4 @@ However due to security reasons it has several limitations: - different browsers exposes 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). -_"Reuse tabs" settings created initially by [Andrzej Martyna](https://github.com/andrzejmartyna)_ +_"Reuse tabs" setting was created initially by [Andrzej Martyna](https://github.com/andrzejmartyna)_ From 9a2e3408db52c3898bc7a66cffbbd7473cb314fc Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Sun, 28 Dec 2025 18:28:39 +0100 Subject: [PATCH 05/35] remove self-marketing --- Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md index e4d011e0a87..5eeefda1e81 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md @@ -38,5 +38,3 @@ However due to security reasons it has several limitations: - different browsers exposes 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). - -_"Reuse tabs" setting was created initially by [Andrzej Martyna](https://github.com/andrzejmartyna)_ From 382dd791ff062b4b8ffb7dc4437daf69255e93d5 Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Sun, 28 Dec 2025 23:41:16 +0100 Subject: [PATCH 06/35] Update THIRD_PARTY_NOTICES --- .../THIRD_PARTY_NOTICES.json | 28 +++++++++++++------ .../THIRD_PARTY_NOTICES.md | 3 +- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.json b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.json index d51a8423df4..8b63604dccf 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.json +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.json @@ -4,7 +4,9 @@ "PackageVersion": "0.2.0", "PackageUrl": "https://github.com/jjw24/BrowserTabs", "Copyright": "Jeremy Wu", - "Authors": [ "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", @@ -19,7 +21,9 @@ "PackageVersion": "8.4.0", "PackageUrl": "https://github.com/CommunityToolkit/dotnet", "Copyright": "(c) .NET Foundation and Contributors. All rights reserved.", - "Authors": [ "Microsoft" ], + "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", @@ -34,7 +38,9 @@ "PackageVersion": "0.0.6", "PackageUrl": "", "Copyright": "", - "Authors": [ "Flow-Launcher" ], + "Authors": [ + "Flow-Launcher" + ], "Description": "Localization toolkit for Flow Launcher and its plugins", "LicenseUrl": "https://licenses.nuget.org/MIT", "LicenseType": "MIT", @@ -46,17 +52,19 @@ }, { "PackageName": "Microsoft.Data.Sqlite", - "PackageVersion": "10.0.0", + "PackageVersion": "10.0.1", "PackageUrl": "https://docs.microsoft.com/dotnet/standard/data/sqlite/", "Copyright": "© Microsoft Corporation. All rights reserved.", - "Authors": [ "Microsoft" ], + "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": "b0f34d51fccc69fd334253924abd8d6853fad7aa" + "Commit": "fad253f51b461736dfd3cd9c15977bb7493becef" } }, { @@ -64,7 +72,9 @@ "PackageVersion": "3.119.1", "PackageUrl": "https://go.microsoft.com/fwlink/?linkid=868515", "Copyright": "© Microsoft Corporation. All rights reserved.", - "Authors": [ "Microsoft" ], + "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", @@ -79,7 +89,9 @@ "PackageVersion": "3.2.1", "PackageUrl": "https://github.com/wieslawsoltes/Svg.Skia", "Copyright": "Copyright © Wiesław Šoltés 2025", - "Authors": [ "Wiesław Šoltés" ], + "Authors": [ + "Wiesław Šoltés" + ], "Description": "An SVG rendering library.", "LicenseUrl": "https://licenses.nuget.org/MIT", "LicenseType": "MIT", diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.md b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.md index aeb9b8fafb2..c71aa1bd916 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.md +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.md @@ -7,7 +7,7 @@ This project uses third-party NuGet packages. | 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.0 | 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 | @@ -26,6 +26,7 @@ Finally Browser Tabs plugin wasn't used but **it's code had great impact on this 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 ``` From ecfe8c302dd5b261c5e5c0dd2670b82910c9ed5a Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Sun, 28 Dec 2025 23:46:38 +0100 Subject: [PATCH 07/35] Resolved: TabsTracker is not disposed - potential resource leak. --- .../Flow.Launcher.Plugin.BrowserBookmark/Main.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs index 5b9e71ff861..6d098bc4b64 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs @@ -28,7 +28,7 @@ public class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex private static bool _initialized = false; - private readonly TabsTracker tabsTracker = new(); + private readonly TabsTracker _tabsTracker = new(); public void Init(PluginInitContext context) { @@ -62,7 +62,7 @@ public void Init(PluginInitContext context) LoadBookmarksIfEnabled(); - tabsTracker.Init(); + _tabsTracker.Init(); } private static void LoadBookmarksIfEnabled() @@ -97,7 +97,7 @@ public List Query(Query query) if (!topResults) { // Since we mixed chrome and firefox bookmarks, we should order them again - return tabsTracker.InjectExistingTabs(_settings, _cachedBookmarks + return _tabsTracker.InjectExistingTabs(_settings, _cachedBookmarks .Select( c => new Result { @@ -109,7 +109,7 @@ public List Query(Query query) Score = BookmarkLoader.MatchProgram(c, param).Score, Action = _ => { - tabsTracker.OpenUrlAndTrack(_settings, c.Url); + _tabsTracker.OpenUrlAndTrack(_settings, c.Url); return true; }, ContextData = new BookmarkAttributes { Url = c.Url } @@ -120,7 +120,7 @@ public List Query(Query query) } else { - return tabsTracker.InjectExistingTabs(_settings, _cachedBookmarks + return _tabsTracker.InjectExistingTabs(_settings, _cachedBookmarks .Select( c => new Result { @@ -132,7 +132,7 @@ public List Query(Query query) Score = 5, Action = _ => { - tabsTracker.OpenUrlAndTrack(_settings, c.Url); + _tabsTracker.OpenUrlAndTrack(_settings, c.Url); return true; }, ContextData = new BookmarkAttributes { Url = c.Url } @@ -265,6 +265,7 @@ internal class BookmarkAttributes public void Dispose() { + _tabsTracker?.Dispose(); DisposeFileWatchers(); } From f2dd4d97f07a448555796f4d26c2ec97d1de42e4 Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Sun, 28 Dec 2025 23:49:28 +0100 Subject: [PATCH 08/35] Resolved: Fix grammar: use 'its' instead of 'it's'. --- .../Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.md b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.md index c71aa1bd916..c1d33e177f1 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.md +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.md @@ -16,7 +16,7 @@ Detailed information (package id, version, license, repository URL) is available # 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 **it's code had great impact on this final solution**. +Finally Browser Tabs plugin wasn't used but **its code had great impact on this final solution**. # How to generate the list From 944e3d74a34fa073a0faf09e1e5c84229b65f837 Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Sun, 28 Dec 2025 23:50:58 +0100 Subject: [PATCH 09/35] Resolved: Minor grammar and style corrections. --- Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md index 5eeefda1e81..93582259aa8 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md @@ -7,7 +7,7 @@ Especially if one works with **tens or hundreds of bookmarks and open tabs** (I 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"**. +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? @@ -17,14 +17,14 @@ 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!). +**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!). # Known issues The extension bases on RuntimeId of AutomationElement from Microsoft UI Automation. This is a weak spot as browsers are used to regenerate internal structures and even reuse RuntimeId. It **rarely** happens that a bookmark activates a wrong tab. -Still **"just take me to THIS place in milliseconds"** works almost all of the time so it bring so much value that it is worthwhile to accepts the fact it fails sometimes. +Still **"just take me to THIS place in milliseconds"** works almost all of the time so it brings so much value that it is worthwhile to accepts the fact it fails sometimes. The quickest workaround is: From 951ef814fadef1001c885957a72e708455750791 Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Mon, 29 Dec 2025 00:34:11 +0100 Subject: [PATCH 10/35] Resolved: Populate or clarify empty Type and Commit fields for BrowserTabs. --- .../THIRD_PARTY_NOTICES.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.json b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.json index 8b63604dccf..7ba344d0e0b 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.json +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.json @@ -11,9 +11,9 @@ "LicenseUrl": "https://licenses.nuget.org/Apache-2.0", "LicenseType": "Apache-2.0", "Repository": { - "Type": "", + "Type": "git", "Url": "https://github.com/jjw24/BrowserTabs", - "Commit": "" + "Commit": "8d81f8f686e82ddceb3e1a8a49e698cec56b5e3d" } }, { From f8c17807faf6e113e97dbf88cd8a9774f5b22ab1 Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Mon, 29 Dec 2025 00:39:58 +0100 Subject: [PATCH 11/35] Resolved: Populate empty PackageUrl and Copyright fields for Flow.Launcher.Localization. --- .../THIRD_PARTY_NOTICES.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.json b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.json index 7ba344d0e0b..1cd0ce2cd1a 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.json +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/THIRD_PARTY_NOTICES.json @@ -36,8 +36,8 @@ { "PackageName": "Flow.Launcher.Localization", "PackageVersion": "0.0.6", - "PackageUrl": "", - "Copyright": "", + "PackageUrl": "https://github.com/Flow-Launcher/Flow.Launcher.Localization", + "Copyright": "Flow-Launcher", "Authors": [ "Flow-Launcher" ], From 7890102384e12dd3a813a72038da978e1113ef6f Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Mon, 29 Dec 2025 00:46:22 +0100 Subject: [PATCH 12/35] Resolved: Incorrect ClassName - copy-paste error. --- Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs index 570b1102af5..d4393d934e6 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs @@ -16,7 +16,7 @@ namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs; /// internal class TabsWalker { - private static readonly string ClassName = nameof(TabsTracker); + private static readonly string ClassName = nameof(TabsWalker); private readonly TimeSpan _tabRetryTimeout = TimeSpan.FromSeconds(4); private readonly TimeSpan _tabRetryInterval = TimeSpan.FromMilliseconds(250); private readonly TabsCache _cache = new(); From b390645718cf2d9578dbaebf9aed9044bcaeae43 Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Mon, 29 Dec 2025 08:42:14 +0100 Subject: [PATCH 13/35] Resolved: Revert changes in Non-English language. They will be updated automatically in Crowdin --- Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/pl.xaml | 1 - 1 file changed, 1 deletion(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/pl.xaml b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/pl.xaml index 197d913a3e8..b63c2ffc5c0 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/pl.xaml +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/pl.xaml @@ -29,6 +29,5 @@ Jeśli nie używasz Chrome, Firefox lub Edge, lub używasz ich wersji przenośnej, musisz dodać katalog danych zakładek i wybrać poprawny silnik przeglądarki, aby wtyczka działała. Na przykład: silnikiem przeglądarki Brave jest Chromium, a domyślna lokalizacja danych zakładek to: "%LOCALAPPDATA%\BraveSoftware\Brave-Browser\UserData". W przypadku silnika Firefoksa, katalog zakładek to folder danych użytkownika zawierający plik places.sqlite. Wczytaj ikony ulubionych (może być czasochłonne podczas uruchamiania) - Ponownie używaj otwartych zakładek (funkcja eksperymentalna) From 876cbfd006524b7468e338b623ab9c435ba7044a Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Mon, 29 Dec 2025 09:13:35 +0100 Subject: [PATCH 14/35] Resolved: a few issues reported by coderabbitai --- .../Tabs/TabsCache.cs | 31 ++++++--- .../Tabs/TabsTracker.cs | 66 ++++++++++++------- 2 files changed, 64 insertions(+), 33 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs index 205129d21c2..6f9d10e8835 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs @@ -12,7 +12,16 @@ internal class TabsCache private readonly HashSet _knownTabs = new(); private readonly object sync = new(); - private static string RuntimeIdToKey(AutomationElement elem) => elem != null ? string.Join("-", elem.GetRuntimeId()) : "NULL"; + private static string RuntimeIdToKey(AutomationElement elem) { + try + { + return elem != null ? string.Join("-", elem.GetRuntimeId()) : null; + } + catch (ElementNotAvailableException) + { + return null; + } + } public bool Empty() { @@ -26,18 +35,19 @@ public void Add(AutomationElement tab) { lock (sync) { - _knownTabs.Add(RuntimeIdToKey(tab)); + var key = RuntimeIdToKey(tab); + if (key != null) + { + _knownTabs.Add(key); + } } } public void Add(IEnumerable tabs) { - lock (sync) + foreach (var tab in tabs) { - foreach (var tab in tabs) - { - _knownTabs.Add(RuntimeIdToKey(tab)); - } + Add(tab); } } @@ -45,7 +55,12 @@ public bool Contains(AutomationElement tab) { lock (sync) { - return _knownTabs.Contains(RuntimeIdToKey(tab)); + var key = RuntimeIdToKey(tab); + if (key != null) + { + return _knownTabs.Contains(key); + } } + return false; } } diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs index 319f74b4c3b..ba3a38abd2b 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs @@ -48,7 +48,8 @@ public List InjectExistingTabs(Settings settings, List results) foreach (var r in results) { var bookmarkUrl = ((BookmarkAttributes)r.ContextData).Url; - if (UrlToBrowserTab.TryGetValue(bookmarkUrl, out var existingTab)) + var existingTab = GetExistingTab(bookmarkUrl); + if (existingTab != null) { Context.API.LogDebug(ClassName, $"Mapped {bookmarkUrl}"); @@ -96,6 +97,18 @@ public void ExpectUrl(string url) } } + private BrowserTab GetExistingTab(string url) + { + lock (_sync) + { + if (UrlToBrowserTab.TryGetValue(url, out var existingTab)) + { + return existingTab; + } + } + return null; + } + private void Remove(string url) { lock (_sync) @@ -122,38 +135,41 @@ private void OnFocusChanged(object sender, AutomationFocusChangedEventArgs e) return; int pid = element.Current.ProcessId; - Process? process = null; - try { process = Process.GetProcessById(pid); } - catch { /* could disappear */ } - if (process is null) - return; - - var chromium = chromiumProcessNames.Contains(process.ProcessName); - var firefox = firefoxProcessNames.Contains(process.ProcessName); - if (!chromium && !firefox) - return; // not a browser + try + { + using var process = Process.GetProcessById(pid); + var chromium = chromiumProcessNames.Contains(process.ProcessName); + var firefox = firefoxProcessNames.Contains(process.ProcessName); + if (!chromium && !firefox) + return; // not a browser - Context.API.LogDebug(ClassName, $"The active browser is {process.ProcessName}"); + Context.API.LogDebug(ClassName, $"The active browser is {process.ProcessName}"); - var rootElement = AutomationElement.FromHandle(process.MainWindowHandle); - if (rootElement == null) - return; + var rootElement = AutomationElement.FromHandle(process.MainWindowHandle); + if (rootElement == null) + return; - Context.API.LogDebug(ClassName, $"The root element is {rootElement.Current.Name}"); + Context.API.LogDebug(ClassName, $"The root element is {rootElement.Current.Name}"); - var currentTab = _walker.GetCurrentTabFromWindow(rootElement, process, CancellationToken.None); - if (currentTab != null) - { - lock (_sync) + var currentTab = _walker.GetCurrentTabFromWindow(rootElement, process, CancellationToken.None); + if (currentTab != null) { - Context.API.LogDebug(ClassName, $"Registering {urlToBind} as tab: {currentTab.Title}"); - UrlToBrowserTab[urlToBind] = currentTab; - _expectedUrl = null; + lock (_sync) + { + Context.API.LogDebug(ClassName, $"Registering {urlToBind} as tab: {currentTab.Title}"); + UrlToBrowserTab[urlToBind] = currentTab; + _expectedUrl = null; - // required to take the tab into account by Flow Launcher main UI search window - Context.API.ReQuery(); + // required to take the tab into account by Flow Launcher main UI search window + Context.API.ReQuery(); + } } } + catch (ArgumentException) + { + // No such process / not running + return; + } } catch (Exception ex) { From 1f45c02bf1762d602f9d25b3737a8b742950eacc Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Mon, 29 Dec 2025 09:38:18 +0100 Subject: [PATCH 15/35] Added TODO.md for ideas to improve --- Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TODO.md | 8 ++++++++ .../Tabs/TabsWalker.cs | 1 - 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TODO.md diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TODO.md b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TODO.md new file mode 100644 index 00000000000..554483a5016 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TODO.md @@ -0,0 +1,8 @@ +# Items to consider to improve the code + +- TabsWalker.GetCurrentTabFromWindow + - Research browsers' settings and check if it may break current assumption of just taking the last tab + +- absTracker + - AutomationFocusChangedEventHandler could be replaced with AutomationStructureChangedEventHandler, StructureChangeType.ChildAdded + - Removal of tabs should be handled to save memory by using AutomationStructureChangedEventHandler, StructureChangeType.ChildRemoved diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs index d4393d934e6..04b780be447 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs @@ -79,7 +79,6 @@ public BrowserTab GetCurrentTabFromWindow(AutomationElement mainWindow, Process // Let's take the last one and assume this is the one that was created recently // This is the best known approach as of today // There might be some browsers' settings that change this behavior but weren't tested nor considered yet - //TODO: research browsers' settings and check if it may break current assumption of just taking the last tab return InitiateTab(process, tabs.Last()); } From 1bac95dde4f56073a99c5f61bdc5c3aa24f865c7 Mon Sep 17 00:00:00 2001 From: Andrzej Martyna <4319685+andrzejmartyna@users.noreply.github.com> Date: Mon, 29 Dec 2025 09:54:04 +0100 Subject: [PATCH 16/35] Resolved: Use the wrapper property for consistent binding. by coderabbitai Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../Views/SettingsControl.xaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml index 34891762d93..5ebc98d91a8 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml @@ -106,6 +106,6 @@ HorizontalAlignment="Left" VerticalAlignment="Center" Content="{DynamicResource flowlauncher_plugin_browserbookmark_reuse_tabs}" - IsChecked="{Binding Settings.ReuseTabs}" /> + IsChecked="{Binding ReuseTabs}" /> \ No newline at end of file From 0f7ef77512150a98b843a9c2d4f219f2ae33f849 Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Mon, 29 Dec 2025 09:59:09 +0100 Subject: [PATCH 17/35] typo --- Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TODO.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TODO.md b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TODO.md index 554483a5016..439b78bc214 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TODO.md +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TODO.md @@ -3,6 +3,6 @@ - TabsWalker.GetCurrentTabFromWindow - Research browsers' settings and check if it may break current assumption of just taking the last tab -- absTracker +- TabsTracker - AutomationFocusChangedEventHandler could be replaced with AutomationStructureChangedEventHandler, StructureChangeType.ChildAdded - Removal of tabs should be handled to save memory by using AutomationStructureChangedEventHandler, StructureChangeType.ChildRemoved From 791ff47c2f6b7108f8f81ff24e6de4183aa4766a Mon Sep 17 00:00:00 2001 From: Jack Ye Date: Mon, 29 Dec 2025 18:05:54 +0800 Subject: [PATCH 18/35] Fix typos Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md index 93582259aa8..cb316705a6b 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md @@ -36,5 +36,5 @@ The quickest workaround is: 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 exposes internals differently +- 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). From a6acdd4c1a52e1b1c5e40e1d5196d8af38c42e3e Mon Sep 17 00:00:00 2001 From: Jack Ye Date: Mon, 29 Dec 2025 18:45:34 +0800 Subject: [PATCH 19/35] Fix typos Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md index cb316705a6b..fa41cf10eda 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md @@ -24,7 +24,7 @@ Next, each time the bookmark is triggered again, it just switches to the existin The extension bases on RuntimeId of AutomationElement from Microsoft UI Automation. This is a weak spot as browsers are used to regenerate internal structures and even reuse RuntimeId. It **rarely** happens that a bookmark activates a wrong tab. -Still **"just take me to THIS place in milliseconds"** works almost all of the time so it brings so much value that it is worthwhile to accepts the fact it fails sometimes. +Still **"just take me to THIS place in milliseconds"** works almost all of the time so it brings so much value that it is worthwhile to accept the fact it fails sometimes. The quickest workaround is: From d1e5b70759258207b7719feaf3108b422ea2ed67 Mon Sep 17 00:00:00 2001 From: Jack Ye Date: Mon, 29 Dec 2025 18:50:14 +0800 Subject: [PATCH 20/35] Fix typos Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs index ba3a38abd2b..36ccdd86392 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs @@ -12,7 +12,7 @@ namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs; /// /// TabsTracker maps initial URLs into existing browser's tabs. /// The sequence of events: -/// 1. OpenUrlAndTrack - before lauching an URL it is remembered for later mapping to a browser's tab +/// 1. OpenUrlAndTrack - before launching an URL it is remembered for later mapping to a browser's tab /// 2. OnFocusChanged - whenever a browser's window gets focused a new tab discovery is started and result is put into the UrlToBrowserTab map /// 3. InjectExistingTabs - iterates over BrowserBookmark's query result and replaces OpenUrl with ActivateTab for known, existing tabs /// From 9d27bcf5891b85b7e96ebcecb62e1ab341a85423 Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Mon, 29 Dec 2025 23:38:00 +0100 Subject: [PATCH 21/35] feat: added cleaning cache on tabs and windows closing --- .../Tabs/TODO.md | 8 --- .../Tabs/TabsCache.cs | 48 +++++++++++++---- .../Tabs/TabsTracker.cs | 54 +++++++++++++++++++ .../Tabs/TabsWalker.cs | 15 +++++- 4 files changed, 107 insertions(+), 18 deletions(-) delete mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TODO.md diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TODO.md b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TODO.md deleted file mode 100644 index 439b78bc214..00000000000 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TODO.md +++ /dev/null @@ -1,8 +0,0 @@ -# Items to consider to improve the code - -- TabsWalker.GetCurrentTabFromWindow - - Research browsers' settings and check if it may break current assumption of just taking the last tab - -- TabsTracker - - AutomationFocusChangedEventHandler could be replaced with AutomationStructureChangedEventHandler, StructureChangeType.ChildAdded - - Removal of tabs should be handled to save memory by using AutomationStructureChangedEventHandler, StructureChangeType.ChildRemoved diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs index 6f9d10e8835..331ba2bae12 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs @@ -1,5 +1,11 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Linq; using System.Windows.Automation; +using System.Windows.Forms; +using System.Windows.Input; +using System.Xml.Linq; +using static Flow.Launcher.Plugin.BrowserBookmark.Main; namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs; @@ -9,13 +15,16 @@ namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs; /// internal class TabsCache { - private readonly HashSet _knownTabs = new(); - private readonly object sync = new(); + private static readonly string ClassName = nameof(TabsCache); + private readonly HashSet _knownTabs = []; + private readonly object _sync = new(); - private static string RuntimeIdToKey(AutomationElement elem) { + public static string RuntimeIdToKey(int[] runtimeId) => string.Join("-", runtimeId); + + public static string RuntimeIdToKey(AutomationElement elem) { try { - return elem != null ? string.Join("-", elem.GetRuntimeId()) : null; + return elem != null ? RuntimeIdToKey(elem.GetRuntimeId()) : null; } catch (ElementNotAvailableException) { @@ -25,7 +34,7 @@ private static string RuntimeIdToKey(AutomationElement elem) { public bool Empty() { - lock (sync) + lock (_sync) { return _knownTabs.Count == 0; } @@ -33,16 +42,16 @@ public bool Empty() public void Add(AutomationElement tab) { - lock (sync) + lock (_sync) { var key = RuntimeIdToKey(tab); if (key != null) { + Context.API.LogDebug(ClassName, $"Adding a tab to cache: {tab.Current.Name}"); _knownTabs.Add(key); } } } - public void Add(IEnumerable tabs) { foreach (var tab in tabs) @@ -53,7 +62,7 @@ public void Add(IEnumerable tabs) public bool Contains(AutomationElement tab) { - lock (sync) + lock (_sync) { var key = RuntimeIdToKey(tab); if (key != null) @@ -63,4 +72,25 @@ public bool Contains(AutomationElement tab) } return false; } + + public void RemoveAllNonExistentTabs(AutomationElement rootElement, IEnumerable existingTabs) + { + if (rootElement == null || existingTabs == null) + return; + + var rootKey = RuntimeIdToKey(rootElement); + var existingKeys = existingTabs.Select(RuntimeIdToKey).Where(k => k != null).ToHashSet(); + var keysToRemove = _knownTabs.Where(t => t.StartsWith(rootKey) && !existingKeys.Contains(t)); + + //Context.API.LogDebug(ClassName, $"Rootkey: {rootKey}"); + //Context.API.LogDebug(ClassName, $"Existing keys:\r\n{string.Join("\r\n", existingKeys)}"); + //Context.API.LogDebug(ClassName, $"Known Tabs:\r\n{string.Join("\r\n", _knownTabs)}"); + //Context.API.LogDebug(ClassName, $"Tabs to remove:\r\n{string.Join("\r\n", keysToRemove)}"); + + foreach (var key in keysToRemove) + { + Context.API.LogDebug(ClassName, $"Removing a tab from cache: {key}"); + _knownTabs.Remove(key); + } + } } diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs index 36ccdd86392..5d849c2e523 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Threading; using System.Windows.Automation; using BrowserTabs; @@ -27,6 +28,7 @@ public class TabsTracker : IDisposable private readonly object _sync = new(); private AutomationFocusChangedEventHandler? _focusHandler; + private readonly HashSet _browserWindowsTracked = []; private bool _initialized; public void OpenUrlAndTrack(Settings settings, string url) @@ -82,7 +84,15 @@ public void Init() public void Dispose() { if (_focusHandler != null) + { Automation.RemoveAutomationFocusChangedEventHandler(_focusHandler); + _focusHandler = null; + } + var windowsToUnsubscribe = _browserWindowsTracked.ToList(); + foreach (var wnd in windowsToUnsubscribe) + { + UnsubscribeStructureChangedForWindow(wnd); + } } public void ExpectUrl(string url) @@ -163,6 +173,10 @@ private void OnFocusChanged(object sender, AutomationFocusChangedEventArgs e) // required to take the tab into account by Flow Launcher main UI search window Context.API.ReQuery(); } + + Automation.AddStructureChangedEventHandler(rootElement, TreeScope.Subtree, OnStructureChanged); + Automation.AddAutomationEventHandler(WindowPattern.WindowClosedEvent, rootElement, TreeScope.Subtree, OnWindowClosed); + _browserWindowsTracked.Add(rootElement); } } catch (ArgumentException) @@ -176,4 +190,44 @@ private void OnFocusChanged(object sender, AutomationFocusChangedEventArgs e) Context.API.LogException(ClassName, "Exception", ex); } } + + private void OnStructureChanged(object sender, StructureChangedEventArgs e) + { + // TODO: Consider ChildAdded to handle new tabs appearance instead of AutomationFocusChangedEventHandler + // However think twice if it is worthwhile as the current approach might already be a good one + //if (e.StructureChangeType == StructureChangeType.ChildAdded) + //{ + //} + if (e.StructureChangeType == StructureChangeType.ChildRemoved) + { + var eventRuntimeId = TabsCache.RuntimeIdToKey(e.GetRuntimeId()); + foreach (var window in _browserWindowsTracked) + { + var windowRuntimeId = TabsCache.RuntimeIdToKey(window); + if (windowRuntimeId != null && eventRuntimeId.StartsWith(windowRuntimeId)) + { + _walker.RescanTabsForContainer(window); + } + } + } + } + + private void OnWindowClosed(object sender, AutomationEventArgs e) + { + var element = sender as AutomationElement; + if (element == null) + return; + UnsubscribeStructureChangedForWindow(element); + } + + private void UnsubscribeStructureChangedForWindow(AutomationElement wnd) + { + if (_browserWindowsTracked.Contains(wnd)) + { + Context.API.LogDebug(ClassName, "Unsubscribe window from StructureChanged events"); + Automation.RemoveStructureChangedEventHandler(wnd, OnStructureChanged); + _browserWindowsTracked.Remove(wnd); + _walker?.RemoveAllTabs(wnd); + } + } } diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs index 04b780be447..e264891f2ad 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs @@ -79,6 +79,7 @@ public BrowserTab GetCurrentTabFromWindow(AutomationElement mainWindow, Process // Let's take the last one and assume this is the one that was created recently // This is the best known approach as of today // There might be some browsers' settings that change this behavior but weren't tested nor considered yet + // TODO: Research browsers' settings and check if it may break current assumption of just taking the last tab return InitiateTab(process, tabs.Last()); } @@ -91,7 +92,7 @@ public BrowserTab GetCurrentTabFromWindow(AutomationElement mainWindow, Process var tab = tabs[i]; if (!_cache.Contains(tab)) { - Context.API.LogDebug(ClassName, $"FOUND NEW TAB: name={tab.Current.Name}, className={tab.Current.ClassName}"); + Context.API.LogDebug(ClassName, $"FOUND A NEW TAB: name={tab.Current.Name}, className={tab.Current.ClassName}"); _cache.Add(tab); return InitiateTab(process, tab); } @@ -113,4 +114,16 @@ public BrowserTab GetCurrentTabFromWindow(AutomationElement mainWindow, Process } return null; } + + internal void RescanTabsForContainer(AutomationElement browserWindow) + { + Context.API.LogDebug(ClassName, "Rescaning tabs in order to find removed tabs"); + _cache.RemoveAllNonExistentTabs(browserWindow, FindAllValidTabs(browserWindow)); + } + + internal void RemoveAllTabs(AutomationElement browserWindow) + { + Context.API.LogDebug(ClassName, "Removing all tabs in a window"); + _cache.RemoveAllNonExistentTabs(browserWindow, []); + } } From 736e3764e1e3b96abb8939c910690521b9d9151c Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Mon, 29 Dec 2025 23:51:50 +0100 Subject: [PATCH 22/35] Resolved: several issues reported in PR --- .../Tabs/TabsCache.cs | 6 +- .../Tabs/TabsTracker.cs | 66 +++++++++---------- .../Tabs/TabsWalker.cs | 5 +- .../Views/SettingsControl.xaml.cs | 2 +- 4 files changed, 35 insertions(+), 44 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs index 331ba2bae12..3bd8de31c04 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs @@ -1,10 +1,6 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Windows.Automation; -using System.Windows.Forms; -using System.Windows.Input; -using System.Xml.Linq; using static Flow.Launcher.Plugin.BrowserBookmark.Main; namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs; diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs index 5d849c2e523..bc508888561 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs @@ -83,16 +83,17 @@ public void Init() public void Dispose() { - if (_focusHandler != null) - { - Automation.RemoveAutomationFocusChangedEventHandler(_focusHandler); - _focusHandler = null; - } var windowsToUnsubscribe = _browserWindowsTracked.ToList(); foreach (var wnd in windowsToUnsubscribe) { UnsubscribeStructureChangedForWindow(wnd); } + if (_focusHandler != null) + { + Automation.RemoveAutomationFocusChangedEventHandler(_focusHandler); + _focusHandler = null; + _initialized = false; + } } public void ExpectUrl(string url) @@ -145,44 +146,37 @@ private void OnFocusChanged(object sender, AutomationFocusChangedEventArgs e) return; int pid = element.Current.ProcessId; - try - { - using var process = Process.GetProcessById(pid); - var chromium = chromiumProcessNames.Contains(process.ProcessName); - var firefox = firefoxProcessNames.Contains(process.ProcessName); - if (!chromium && !firefox) - return; // not a browser + using var process = Process.GetProcessById(pid); - Context.API.LogDebug(ClassName, $"The active browser is {process.ProcessName}"); + var chromium = chromiumProcessNames.Contains(process.ProcessName); + var firefox = firefoxProcessNames.Contains(process.ProcessName); + if (!chromium && !firefox) + return; // not a browser - var rootElement = AutomationElement.FromHandle(process.MainWindowHandle); - if (rootElement == null) - return; + Context.API.LogDebug(ClassName, $"The active browser is {process.ProcessName}"); - Context.API.LogDebug(ClassName, $"The root element is {rootElement.Current.Name}"); + var rootElement = AutomationElement.FromHandle(process.MainWindowHandle); + if (rootElement == null) + return; - var currentTab = _walker.GetCurrentTabFromWindow(rootElement, process, CancellationToken.None); - if (currentTab != null) - { - lock (_sync) - { - Context.API.LogDebug(ClassName, $"Registering {urlToBind} as tab: {currentTab.Title}"); - UrlToBrowserTab[urlToBind] = currentTab; - _expectedUrl = null; + Context.API.LogDebug(ClassName, $"The root element is {rootElement.Current.Name}"); - // required to take the tab into account by Flow Launcher main UI search window - Context.API.ReQuery(); - } + var currentTab = _walker.GetCurrentTabFromWindow(rootElement, process, CancellationToken.None); + if (currentTab != null) + { + lock (_sync) + { + Context.API.LogDebug(ClassName, $"Registering {urlToBind} as tab: {currentTab.Title}"); + UrlToBrowserTab[urlToBind] = currentTab; + _expectedUrl = null; - Automation.AddStructureChangedEventHandler(rootElement, TreeScope.Subtree, OnStructureChanged); - Automation.AddAutomationEventHandler(WindowPattern.WindowClosedEvent, rootElement, TreeScope.Subtree, OnWindowClosed); - _browserWindowsTracked.Add(rootElement); + // required to take the tab into account by Flow Launcher main UI search window + Context.API.ReQuery(); } - } - catch (ArgumentException) - { - // No such process / not running - return; + + Automation.AddStructureChangedEventHandler(rootElement, TreeScope.Subtree, OnStructureChanged); + Automation.AddAutomationEventHandler(WindowPattern.WindowClosedEvent, rootElement, TreeScope.Subtree, OnWindowClosed); + _browserWindowsTracked.Add(rootElement); } } catch (Exception ex) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs index e264891f2ad..17885485ec3 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Linq; using System.Threading; +using System.Threading.Tasks; using System.Windows.Automation; using BrowserTabs; using static Flow.Launcher.Plugin.BrowserBookmark.Main; @@ -67,7 +68,7 @@ public BrowserTab GetCurrentTabFromWindow(AutomationElement mainWindow, Process if (tabs.Count == 0) { Context.API.LogDebug(ClassName, "No valid tabs found"); - Thread.Sleep(_tabRetryInterval); + Task.Delay(_tabRetryInterval, cancellationToken); continue; } @@ -99,7 +100,7 @@ public BrowserTab GetCurrentTabFromWindow(AutomationElement mainWindow, Process } Context.API.LogDebug(ClassName, "No new tab found"); - Thread.Sleep(_tabRetryInterval); + Task.Delay(_tabRetryInterval, cancellationToken); } Context.API.LogDebug(ClassName, "Timeout waiting for new tab"); diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml.cs index 5a10cca3745..0fca79409da 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml.cs @@ -54,7 +54,7 @@ public bool ReuseTabs set { Settings.ReuseTabs = value; - _ = Task.Run(() => Main.ReloadAllBookmarks()); + // reloading of bookmarks is not needed while this settings changes } } From 75a03d032080b70900a9118300d5f5c4e57c05c5 Mon Sep 17 00:00:00 2001 From: Jack Ye Date: Tue, 30 Dec 2025 16:00:55 +0800 Subject: [PATCH 23/35] Fix typos Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs index 17885485ec3..78f285538a3 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs @@ -118,7 +118,7 @@ public BrowserTab GetCurrentTabFromWindow(AutomationElement mainWindow, Process internal void RescanTabsForContainer(AutomationElement browserWindow) { - Context.API.LogDebug(ClassName, "Rescaning tabs in order to find removed tabs"); + Context.API.LogDebug(ClassName, "Rescanning tabs in order to find removed tabs"); _cache.RemoveAllNonExistentTabs(browserWindow, FindAllValidTabs(browserWindow)); } From cdb2d9e6dce4922b71791189f2e61092c8cc0d49 Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Tue, 30 Dec 2025 09:05:46 +0100 Subject: [PATCH 24/35] Take Thread.Sleep out of OnFocusChanged --- .../Tabs/TabsCache.cs | 10 +- .../Tabs/TabsFocusEventDispatcher.cs | 85 ++++++++++++++ .../Tabs/TabsTracker.cs | 110 +++++++++++------- .../Tabs/TabsWalker.cs | 12 +- 4 files changed, 173 insertions(+), 44 deletions(-) create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsFocusEventDispatcher.cs diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs index 3bd8de31c04..47446e5ec68 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs @@ -57,13 +57,17 @@ public void Add(IEnumerable tabs) } public bool Contains(AutomationElement tab) + { + return Contains(RuntimeIdToKey(tab)); + } + + public bool Contains(string runtimeId) { lock (_sync) { - var key = RuntimeIdToKey(tab); - if (key != null) + if (runtimeId != null) { - return _knownTabs.Contains(key); + return _knownTabs.Contains(runtimeId); } } return false; diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsFocusEventDispatcher.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsFocusEventDispatcher.cs new file mode 100644 index 00000000000..1859d0a1592 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsFocusEventDispatcher.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Threading; +using System.Windows.Automation; +using Flow.Launcher.Plugin.BrowserBookmark.Tabs; +using static Flow.Launcher.Plugin.BrowserBookmark.Main; + +internal sealed class TabsFocusEventDispatcher : IDisposable +{ + private static readonly string ClassName = nameof(TabsFocusEventDispatcher); + private readonly BlockingCollection> _queue = []; + private readonly Thread _worker; + private readonly CancellationTokenSource _cts = new(); + private readonly TabsWalker _walker; + private readonly TabsTracker _tracker; + + public TabsFocusEventDispatcher(TabsWalker walker, TabsTracker tracker) + { + _worker = new Thread(WorkerLoop) + { + IsBackground = true, + Name = "FocusEventWorker" + }; + _worker.Start(); + _walker = walker; + _tracker = tracker; + } + + public void Enqueue(string url, AutomationElement element, int processId) + { + if (!_queue.IsAddingCompleted) + _queue.Add(Tuple.Create(url, element, processId)); + } + + void WorkerLoop() + { + try + { + foreach (var element in _queue.GetConsumingEnumerable(_cts.Token)) + { + HandleFocus(element); + } + } + catch (OperationCanceledException) + { + // shutting down + } + } + + void HandleFocus(Tuple tuple) + { + var url = tuple.Item1; + var rootElement = tuple.Item2; + var processId = tuple.Item3; + + try + { + using var process = Process.GetProcessById(processId); + + var currentTab = _walker.GetCurrentTabFromWindow(rootElement, process, CancellationToken.None); + if (currentTab != null) + { + _tracker.RegisterTab(url, rootElement, currentTab); + } + } + catch (ArgumentException) + { + Context.API.LogError(ClassName, $"Process {processId} no longer runs"); + } + catch (Exception ex) + { + Context.API.LogException(ClassName, "Exception", ex); + } + } + + public void Dispose() + { + _cts.Cancel(); + _queue.CompleteAdding(); + _worker.Join(); + _cts.Dispose(); + _queue.Dispose(); + } +} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs index bc508888561..c0e3fd97b38 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using System.Threading; using System.Windows.Automation; using BrowserTabs; using Flow.Launcher.Plugin.BrowserBookmark.Models; @@ -23,10 +22,11 @@ public class TabsTracker : IDisposable private static readonly HashSet chromiumProcessNames = new HashSet(["msedge", "chrome", "brave", "vivaldi", "opera", "chromium"], StringComparer.OrdinalIgnoreCase); private static readonly HashSet firefoxProcessNames = new HashSet(["firefox"], StringComparer.OrdinalIgnoreCase); private readonly TabsWalker _walker = new(); - private string? _expectedUrl; + private readonly Queue _expectedUrls = []; private Dictionary UrlToBrowserTab { get; } = []; private readonly object _sync = new(); + private TabsFocusEventDispatcher _focusHandlerDispatcher; private AutomationFocusChangedEventHandler? _focusHandler; private readonly HashSet _browserWindowsTracked = []; private bool _initialized; @@ -76,6 +76,7 @@ public void Init() if (_initialized) return; + _focusHandlerDispatcher = new(_walker, this); _focusHandler = OnFocusChanged; Automation.AddAutomationFocusChangedEventHandler(_focusHandler); _initialized = true; @@ -94,17 +95,14 @@ public void Dispose() _focusHandler = null; _initialized = false; } + _focusHandlerDispatcher?.Dispose(); } public void ExpectUrl(string url) { lock (_sync) { - if (_expectedUrl != null) - { - Context.API.LogError(ClassName, $"Opening {url} while older is still not resolved ({_expectedUrl}). Forgetting the older."); - } - _expectedUrl = url; + _expectedUrls.Enqueue(url); } } @@ -130,22 +128,27 @@ private void Remove(string url) private void OnFocusChanged(object sender, AutomationFocusChangedEventArgs e) { - string? urlToBind; lock (_sync) { - urlToBind = _expectedUrl; + if (_expectedUrls.Count == 0) + return; } - if (urlToBind is null) - return; try { - Context.API.LogDebug(ClassName, $"Searching for... {urlToBind}"); - if (sender is not AutomationElement element) return; - int pid = element.Current.ProcessId; + int pid = 0; + try + { + pid = element.Current.ProcessId; + } + catch (ElementNotAvailableException) + { + return; + } + using var process = Process.GetProcessById(pid); var chromium = chromiumProcessNames.Contains(process.ProcessName); @@ -161,23 +164,21 @@ private void OnFocusChanged(object sender, AutomationFocusChangedEventArgs e) Context.API.LogDebug(ClassName, $"The root element is {rootElement.Current.Name}"); - var currentTab = _walker.GetCurrentTabFromWindow(rootElement, process, CancellationToken.None); - if (currentTab != null) + string? urlToBind; + lock (_sync) { - lock (_sync) - { - Context.API.LogDebug(ClassName, $"Registering {urlToBind} as tab: {currentTab.Title}"); - UrlToBrowserTab[urlToBind] = currentTab; - _expectedUrl = null; - - // required to take the tab into account by Flow Launcher main UI search window - Context.API.ReQuery(); - } + if (_expectedUrls.Count == 0) + return; - Automation.AddStructureChangedEventHandler(rootElement, TreeScope.Subtree, OnStructureChanged); - Automation.AddAutomationEventHandler(WindowPattern.WindowClosedEvent, rootElement, TreeScope.Subtree, OnWindowClosed); - _browserWindowsTracked.Add(rootElement); + urlToBind = _expectedUrls.Dequeue(); } + if (urlToBind is null) + return; + + Context.API.LogDebug(ClassName, $"Searching for... {urlToBind}"); + + // Further handling requires waiting for tabs so its better to run it on a separate thread + _focusHandlerDispatcher?.Enqueue(urlToBind, rootElement, pid); } catch (Exception ex) { @@ -187,22 +188,37 @@ private void OnFocusChanged(object sender, AutomationFocusChangedEventArgs e) private void OnStructureChanged(object sender, StructureChangedEventArgs e) { - // TODO: Consider ChildAdded to handle new tabs appearance instead of AutomationFocusChangedEventHandler - // However think twice if it is worthwhile as the current approach might already be a good one - //if (e.StructureChangeType == StructureChangeType.ChildAdded) + //if (e.StructureChangeType == StructureChangeType.ChildAdded || e.StructureChangeType == StructureChangeType.ChildrenBulkAdded) //{ //} - if (e.StructureChangeType == StructureChangeType.ChildRemoved) + var eventRuntimeId = TabsCache.RuntimeIdToKey(e.GetRuntimeId()); + switch (e.StructureChangeType) { - var eventRuntimeId = TabsCache.RuntimeIdToKey(e.GetRuntimeId()); - foreach (var window in _browserWindowsTracked) - { - var windowRuntimeId = TabsCache.RuntimeIdToKey(window); - if (windowRuntimeId != null && eventRuntimeId.StartsWith(windowRuntimeId)) + // TODO: Consider ChildAdded to handle new tabs appearance instead of AutomationFocusChangedEventHandler + // However think twice if it is worthwhile as the current approach based on focus might already be a good one + //case StructureChangeType.ChildAdded: + // Context.API.LogDebug(ClassName, $"StructureChangeType.ChildAdded occurred on {eventRuntimeId}"); + // break; + //case StructureChangeType.ChildrenBulkAdded: + // Context.API.LogDebug(ClassName, $"StructureChangeType.ChildrenBulkAdded occurred on {eventRuntimeId}"); + // break; + //case StructureChangeType.ChildrenInvalidated: + // _walker.CheckTabExistence(eventRuntimeId, "StructureChangeType.ChildrenInvalidated"); + // break; + //case StructureChangeType.ChildrenReordered: + // _walker.CheckTabExistence(eventRuntimeId, "StructureChangeType.ChildrenReordered"); + // break; + case StructureChangeType.ChildRemoved: + case StructureChangeType.ChildrenBulkRemoved: + foreach (var window in _browserWindowsTracked) { - _walker.RescanTabsForContainer(window); + var windowRuntimeId = TabsCache.RuntimeIdToKey(window); + if (windowRuntimeId != null && eventRuntimeId.StartsWith(windowRuntimeId)) + { + _walker.RescanTabsForContainer(window); + } } - } + break; } } @@ -224,4 +240,20 @@ private void UnsubscribeStructureChangedForWindow(AutomationElement wnd) _walker?.RemoveAllTabs(wnd); } } + + internal void RegisterTab(string url, AutomationElement rootElement, BrowserTab currentTab) + { + lock (_sync) + { + Context.API.LogDebug(ClassName, $"Registering {url} as tab: {currentTab.Title}"); + UrlToBrowserTab[url] = currentTab; + + // required to take the tab into account by Flow Launcher main UI search window + Context.API.ReQuery(); + } + + Automation.AddStructureChangedEventHandler(rootElement, TreeScope.Subtree, OnStructureChanged); + Automation.AddAutomationEventHandler(WindowPattern.WindowClosedEvent, rootElement, TreeScope.Subtree, OnWindowClosed); + _browserWindowsTracked.Add(rootElement); + } } diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs index 78f285538a3..c0e32a3f285 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs @@ -68,7 +68,7 @@ public BrowserTab GetCurrentTabFromWindow(AutomationElement mainWindow, Process if (tabs.Count == 0) { Context.API.LogDebug(ClassName, "No valid tabs found"); - Task.Delay(_tabRetryInterval, cancellationToken); + Thread.Sleep(_tabRetryInterval); continue; } @@ -100,7 +100,7 @@ public BrowserTab GetCurrentTabFromWindow(AutomationElement mainWindow, Process } Context.API.LogDebug(ClassName, "No new tab found"); - Task.Delay(_tabRetryInterval, cancellationToken); + Thread.Sleep(_tabRetryInterval); } Context.API.LogDebug(ClassName, "Timeout waiting for new tab"); @@ -127,4 +127,12 @@ internal void RemoveAllTabs(AutomationElement browserWindow) Context.API.LogDebug(ClassName, "Removing all tabs in a window"); _cache.RemoveAllNonExistentTabs(browserWindow, []); } + + internal void CheckTabExistence(string runtimeId, string reason = "") + { + if (_cache.Contains(runtimeId)) + { + Context.API.LogDebug(ClassName, $"Tab exists {reason}"); + } + } } From a4727252094d903b6ccbe455bf5f0b9e885d8114 Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Tue, 30 Dec 2025 09:06:57 +0100 Subject: [PATCH 25/35] Resolved: Thread-safety concern: _browserWindowsTracked modified without synchronization. --- .../Tabs/README.md | 3 +- .../Tabs/TabsTracker.cs | 38 +++++++++++++++---- .../Tabs/TabsWalker.cs | 2 +- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md index fa41cf10eda..fb561d7ed38 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md @@ -23,7 +23,8 @@ Next, each time the bookmark is triggered again, it just switches to the existin The extension bases on RuntimeId of AutomationElement from Microsoft UI Automation. This is a weak spot as browsers are used to regenerate internal structures and even reuse RuntimeId. -It **rarely** happens that a bookmark activates a wrong tab. +Therefore it happens that a bookmark activates a wrong tab. +The most common case is while user opens several tabs one after the other quickly. Still **"just take me to THIS place in milliseconds"** works almost all of the time so it brings so much value that it is worthwhile to accept the fact it fails sometimes. The quickest workaround is: diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs index c0e3fd97b38..588bc44c5fa 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs @@ -84,7 +84,12 @@ public void Init() public void Dispose() { - var windowsToUnsubscribe = _browserWindowsTracked.ToList(); + List windowsToUnsubscribe; + lock (_sync) + { + windowsToUnsubscribe = _browserWindowsTracked.ToList(); + } + foreach (var wnd in windowsToUnsubscribe) { UnsubscribeStructureChangedForWindow(wnd); @@ -210,14 +215,22 @@ private void OnStructureChanged(object sender, StructureChangedEventArgs e) // break; case StructureChangeType.ChildRemoved: case StructureChangeType.ChildrenBulkRemoved: - foreach (var window in _browserWindowsTracked) + AutomationElement foundWindow = null; + lock (_sync) { - var windowRuntimeId = TabsCache.RuntimeIdToKey(window); - if (windowRuntimeId != null && eventRuntimeId.StartsWith(windowRuntimeId)) + foreach (var window in _browserWindowsTracked) { - _walker.RescanTabsForContainer(window); + var windowRuntimeId = TabsCache.RuntimeIdToKey(window); + if (windowRuntimeId != null && eventRuntimeId.StartsWith(windowRuntimeId)) + { + foundWindow = window; + } } } + if (foundWindow != null) + { + _walker.RescanTabsForContainer(foundWindow); + } break; } } @@ -232,11 +245,16 @@ private void OnWindowClosed(object sender, AutomationEventArgs e) private void UnsubscribeStructureChangedForWindow(AutomationElement wnd) { - if (_browserWindowsTracked.Contains(wnd)) + bool contains = false; + lock (_sync) + { + contains = _browserWindowsTracked.Contains(wnd); + _browserWindowsTracked.Remove(wnd); + } + if (contains) { Context.API.LogDebug(ClassName, "Unsubscribe window from StructureChanged events"); Automation.RemoveStructureChangedEventHandler(wnd, OnStructureChanged); - _browserWindowsTracked.Remove(wnd); _walker?.RemoveAllTabs(wnd); } } @@ -254,6 +272,10 @@ internal void RegisterTab(string url, AutomationElement rootElement, BrowserTab Automation.AddStructureChangedEventHandler(rootElement, TreeScope.Subtree, OnStructureChanged); Automation.AddAutomationEventHandler(WindowPattern.WindowClosedEvent, rootElement, TreeScope.Subtree, OnWindowClosed); - _browserWindowsTracked.Add(rootElement); + + lock (_sync) + { + _browserWindowsTracked.Add(rootElement); + } } } diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs index c0e32a3f285..f73297eecba 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs @@ -33,7 +33,7 @@ private static IEnumerable FindAllValidTabs(AutomationElement continue; } - // on Chrome, there are kind of technical tabs that should be ignored + // There are kind of technical tabs that should be ignored var className = tab.Current.ClassName; if (className.Contains("bolt-tab", StringComparison.OrdinalIgnoreCase)) { From 36b7799f2e7bfc7b43bf355c50c88ffefce56b9c Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Tue, 30 Dec 2025 09:11:56 +0100 Subject: [PATCH 26/35] small tweaks --- .../Tabs/TabsFocusEventDispatcher.cs | 4 ++++ .../Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsFocusEventDispatcher.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsFocusEventDispatcher.cs index 1859d0a1592..9b311713060 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsFocusEventDispatcher.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsFocusEventDispatcher.cs @@ -6,6 +6,10 @@ using Flow.Launcher.Plugin.BrowserBookmark.Tabs; using static Flow.Launcher.Plugin.BrowserBookmark.Main; +/// +/// TabsFocusEventDispatcher handles TabsTracker.OnFocusChanged events in a separate thread. +/// This is to avoid sleeping in Automation.AddAutomationFocusChangedEventHandler. +/// internal sealed class TabsFocusEventDispatcher : IDisposable { private static readonly string ClassName = nameof(TabsFocusEventDispatcher); diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs index f73297eecba..346b3b4f722 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs @@ -18,7 +18,7 @@ namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs; internal class TabsWalker { private static readonly string ClassName = nameof(TabsWalker); - private readonly TimeSpan _tabRetryTimeout = TimeSpan.FromSeconds(4); + private readonly TimeSpan _tabRetryTimeout = TimeSpan.FromSeconds(5); private readonly TimeSpan _tabRetryInterval = TimeSpan.FromMilliseconds(250); private readonly TabsCache _cache = new(); From 42a91fb3a05e6cf0e0e75fe67861974c2a838b4b Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Tue, 30 Dec 2025 16:23:43 +0800 Subject: [PATCH 27/35] Refactor tab tracking for thread safety and nullability Replaced object locks with a custom Lock class for clearer thread synchronization in TabsCache and TabsTracker. Enabled nullable reference types in TabsTracker and updated method signatures and variables to use nullable types where appropriate. Modernized collection initializations and refactored methods for improved readability. Cleaned up using directives. These changes enhance thread safety, code clarity, and nullability handling in browser tab management. --- .../Tabs/TabsCache.cs | 11 +++++++--- .../Tabs/TabsTracker.cs | 22 ++++++++++--------- .../Tabs/TabsWalker.cs | 16 ++++++++------ 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs index 47446e5ec68..3e2d73aa896 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Windows.Automation; using static Flow.Launcher.Plugin.BrowserBookmark.Main; @@ -13,11 +14,15 @@ internal class TabsCache { private static readonly string ClassName = nameof(TabsCache); private readonly HashSet _knownTabs = []; - private readonly object _sync = new(); + private readonly Lock _sync = new(); - public static string RuntimeIdToKey(int[] runtimeId) => string.Join("-", runtimeId); + public static string RuntimeIdToKey(int[] runtimeId) + { + return string.Join("-", runtimeId); + } - public static string RuntimeIdToKey(AutomationElement elem) { + public static string RuntimeIdToKey(AutomationElement elem) + { try { return elem != null ? RuntimeIdToKey(elem.GetRuntimeId()) : null; diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs index 588bc44c5fa..3007707376b 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; +using System.Threading; using System.Windows.Automation; using BrowserTabs; using Flow.Launcher.Plugin.BrowserBookmark.Models; @@ -9,6 +9,8 @@ namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs; +#nullable enable + /// /// TabsTracker maps initial URLs into existing browser's tabs. /// The sequence of events: @@ -19,14 +21,14 @@ namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs; public class TabsTracker : IDisposable { private static readonly string ClassName = nameof(TabsTracker); - private static readonly HashSet chromiumProcessNames = new HashSet(["msedge", "chrome", "brave", "vivaldi", "opera", "chromium"], StringComparer.OrdinalIgnoreCase); - private static readonly HashSet firefoxProcessNames = new HashSet(["firefox"], StringComparer.OrdinalIgnoreCase); + private static readonly HashSet chromiumProcessNames = new(["msedge", "chrome", "brave", "vivaldi", "opera", "chromium"], StringComparer.OrdinalIgnoreCase); + private static readonly HashSet firefoxProcessNames = new(["firefox"], StringComparer.OrdinalIgnoreCase); private readonly TabsWalker _walker = new(); private readonly Queue _expectedUrls = []; private Dictionary UrlToBrowserTab { get; } = []; - private readonly object _sync = new(); + private readonly Lock _sync = new(); - private TabsFocusEventDispatcher _focusHandlerDispatcher; + private TabsFocusEventDispatcher? _focusHandlerDispatcher; private AutomationFocusChangedEventHandler? _focusHandler; private readonly HashSet _browserWindowsTracked = []; private bool _initialized; @@ -87,7 +89,7 @@ public void Dispose() List windowsToUnsubscribe; lock (_sync) { - windowsToUnsubscribe = _browserWindowsTracked.ToList(); + windowsToUnsubscribe = [.. _browserWindowsTracked]; } foreach (var wnd in windowsToUnsubscribe) @@ -111,7 +113,7 @@ public void ExpectUrl(string url) } } - private BrowserTab GetExistingTab(string url) + private BrowserTab? GetExistingTab(string url) { lock (_sync) { @@ -144,7 +146,7 @@ private void OnFocusChanged(object sender, AutomationFocusChangedEventArgs e) if (sender is not AutomationElement element) return; - int pid = 0; + var pid = 0; try { pid = element.Current.ProcessId; @@ -215,7 +217,7 @@ private void OnStructureChanged(object sender, StructureChangedEventArgs e) // break; case StructureChangeType.ChildRemoved: case StructureChangeType.ChildrenBulkRemoved: - AutomationElement foundWindow = null; + AutomationElement? foundWindow = null; lock (_sync) { foreach (var window in _browserWindowsTracked) @@ -245,7 +247,7 @@ private void OnWindowClosed(object sender, AutomationEventArgs e) private void UnsubscribeStructureChangedForWindow(AutomationElement wnd) { - bool contains = false; + var contains = false; lock (_sync) { contains = _browserWindowsTracked.Contains(wnd); diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs index 346b3b4f722..6b1060e787a 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs @@ -3,7 +3,6 @@ using System.Diagnostics; using System.Linq; using System.Threading; -using System.Threading.Tasks; using System.Windows.Automation; using BrowserTabs; using static Flow.Launcher.Plugin.BrowserBookmark.Main; @@ -45,13 +44,16 @@ private static IEnumerable FindAllValidTabs(AutomationElement } } - private static BrowserTab InitiateTab(Process process, AutomationElement tab) => new() + private static BrowserTab InitiateTab(Process process, AutomationElement tab) { - Title = tab.Current.Name, - BrowserName = process.ProcessName, - Hwnd = process.MainWindowHandle, - AutomationElement = tab - }; + return new() + { + Title = tab.Current.Name, + BrowserName = process.ProcessName, + Hwnd = process.MainWindowHandle, + AutomationElement = tab + }; + } public BrowserTab GetCurrentTabFromWindow(AutomationElement mainWindow, Process process, CancellationToken cancellationToken) { From 9fadec36c79beeedfa6372bf0b7d591d87c3b066 Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Tue, 30 Dec 2025 16:25:10 +0800 Subject: [PATCH 28/35] Remove null check from _tabsTracker.Dispose() call The null-conditional operator was removed from _tabsTracker.Dispose() in the Dispose() method, assuming _tabsTracker is always non-null when Dispose() is called. --- Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs index 6d098bc4b64..e8235764cee 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs @@ -265,7 +265,7 @@ internal class BookmarkAttributes public void Dispose() { - _tabsTracker?.Dispose(); + _tabsTracker.Dispose(); DisposeFileWatchers(); } From 6c5695b94f8ae1b8b5fd9e99460bd9cdb975af4d Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Wed, 31 Dec 2025 18:29:29 +0100 Subject: [PATCH 29/35] Unify logging messages --- .../Tabs/TabsCache.cs | 9 ++---- .../Tabs/TabsDebug.cs | 16 +++++++--- .../Tabs/TabsFocusEventDispatcher.cs | 5 ++-- .../Tabs/TabsTracker.cs | 26 ++++++++-------- .../Tabs/TabsWalker.cs | 30 ++++++++++--------- 5 files changed, 46 insertions(+), 40 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs index 3e2d73aa896..a4eba27997b 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs @@ -48,7 +48,7 @@ public void Add(AutomationElement tab) var key = RuntimeIdToKey(tab); if (key != null) { - Context.API.LogDebug(ClassName, $"Adding a tab to cache: {tab.Current.Name}"); + Context.API.LogDebug(ClassName, $"TABS:{key}:Adding to cache: {tab.Current.Name}"); _knownTabs.Add(key); } } @@ -87,14 +87,9 @@ public void RemoveAllNonExistentTabs(AutomationElement rootElement, IEnumerable< var existingKeys = existingTabs.Select(RuntimeIdToKey).Where(k => k != null).ToHashSet(); var keysToRemove = _knownTabs.Where(t => t.StartsWith(rootKey) && !existingKeys.Contains(t)); - //Context.API.LogDebug(ClassName, $"Rootkey: {rootKey}"); - //Context.API.LogDebug(ClassName, $"Existing keys:\r\n{string.Join("\r\n", existingKeys)}"); - //Context.API.LogDebug(ClassName, $"Known Tabs:\r\n{string.Join("\r\n", _knownTabs)}"); - //Context.API.LogDebug(ClassName, $"Tabs to remove:\r\n{string.Join("\r\n", keysToRemove)}"); - foreach (var key in keysToRemove) { - Context.API.LogDebug(ClassName, $"Removing a tab from cache: {key}"); + Context.API.LogDebug(ClassName, $"TABS:{key}:Removing from cache"); _knownTabs.Remove(key); } } diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsDebug.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsDebug.cs index 25809a5c68f..30bdfa6a1b4 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsDebug.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsDebug.cs @@ -12,8 +12,16 @@ internal class TabsDebug { private static readonly string ClassName = nameof(TabsDebug); + public static void DumpTabs(AutomationElement parent) + { + DumpElements(parent, null, "Tab"); + } + public static void DumpElements(AutomationElement parent, string classNameOnly = null, string controlTypeOnly = null, int indent = 0) { + if (parent == null) + return; + AutomationElementCollection children; try { @@ -21,7 +29,7 @@ public static void DumpElements(AutomationElement parent, string classNameOnly = } catch (ElementNotAvailableException ex) { - Context.API.LogDebug(ClassName, $"Parent not available: {ex.Message}"); + Context.API.LogDebug(ClassName, $"TABS:Parent not available: {ex.Message}"); return; } @@ -48,7 +56,7 @@ public static void DumpElements(AutomationElement parent, string classNameOnly = { Context.API.LogDebug( ClassName, - $"{new string(' ', indent)}" + + $"TABS:{new string(' ', indent)}" + $"Type='{type}', " + $"ClassName='{className}', " + $"Name='{name}', " + @@ -62,11 +70,11 @@ public static void DumpElements(AutomationElement parent, string classNameOnly = } catch (ElementNotAvailableException ex) { - Context.API.LogDebug(ClassName, $"Child not available: {ex.Message}"); + Context.API.LogDebug(ClassName, $"TABS:Child not available: {ex.Message}"); } catch (Exception ex) { - Context.API.LogException(ClassName, $"Unexpected error", ex); + Context.API.LogException(ClassName, $"TABS:Unexpected error", ex); } } } diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsFocusEventDispatcher.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsFocusEventDispatcher.cs index 9b311713060..78c82c922b1 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsFocusEventDispatcher.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsFocusEventDispatcher.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Threading; using System.Windows.Automation; +using System.Windows.Input; using Flow.Launcher.Plugin.BrowserBookmark.Tabs; using static Flow.Launcher.Plugin.BrowserBookmark.Main; @@ -70,11 +71,11 @@ void HandleFocus(Tuple tuple) } catch (ArgumentException) { - Context.API.LogError(ClassName, $"Process {processId} no longer runs"); + Context.API.LogError(ClassName, $"TABS:Process {processId} no longer runs"); } catch (Exception ex) { - Context.API.LogException(ClassName, "Exception", ex); + Context.API.LogException(ClassName, "TABS:Exception", ex); } } diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs index 3007707376b..a8f67ba8d87 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs @@ -3,6 +3,8 @@ using System.Diagnostics; using System.Threading; using System.Windows.Automation; +using System.Windows.Forms; +using System.Windows.Input; using BrowserTabs; using Flow.Launcher.Plugin.BrowserBookmark.Models; using static Flow.Launcher.Plugin.BrowserBookmark.Main; @@ -37,7 +39,6 @@ public void OpenUrlAndTrack(Settings settings, string url) { if (settings.ReuseTabs) { - Context.API.LogDebug(ClassName, $"Opening... {url}"); ExpectUrl(url); } Context.API.OpenUrl(url); @@ -55,14 +56,12 @@ public List InjectExistingTabs(Settings settings, List results) var existingTab = GetExistingTab(bookmarkUrl); if (existingTab != null) { - Context.API.LogDebug(ClassName, $"Mapped {bookmarkUrl}"); - r.ContextData = existingTab; r.Action = c => { if (!existingTab.ActivateTab()) { - Context.API.LogError(ClassName, "Failed to activate a tab"); + Context.API.LogError(ClassName, $"TABS:{TabsCache.RuntimeIdToKey(existingTab.AutomationElement)}:Failed to activate"); Remove(bookmarkUrl); OpenUrlAndTrack(settings, bookmarkUrl); } @@ -163,14 +162,12 @@ private void OnFocusChanged(object sender, AutomationFocusChangedEventArgs e) if (!chromium && !firefox) return; // not a browser - Context.API.LogDebug(ClassName, $"The active browser is {process.ProcessName}"); + Context.API.LogDebug(ClassName, $"TABS:The active browser is {process.ProcessName}"); var rootElement = AutomationElement.FromHandle(process.MainWindowHandle); if (rootElement == null) return; - Context.API.LogDebug(ClassName, $"The root element is {rootElement.Current.Name}"); - string? urlToBind; lock (_sync) { @@ -182,14 +179,14 @@ private void OnFocusChanged(object sender, AutomationFocusChangedEventArgs e) if (urlToBind is null) return; - Context.API.LogDebug(ClassName, $"Searching for... {urlToBind}"); + Context.API.LogDebug(ClassName, $"TABS:Searching for... {urlToBind}"); // Further handling requires waiting for tabs so its better to run it on a separate thread _focusHandlerDispatcher?.Enqueue(urlToBind, rootElement, pid); } catch (Exception ex) { - Context.API.LogException(ClassName, "Exception", ex); + Context.API.LogException(ClassName, "TABS:Exception", ex); } } @@ -204,17 +201,20 @@ private void OnStructureChanged(object sender, StructureChangedEventArgs e) // TODO: Consider ChildAdded to handle new tabs appearance instead of AutomationFocusChangedEventHandler // However think twice if it is worthwhile as the current approach based on focus might already be a good one //case StructureChangeType.ChildAdded: - // Context.API.LogDebug(ClassName, $"StructureChangeType.ChildAdded occurred on {eventRuntimeId}"); + // Context.API.LogDebug(ClassName, $"TABS:StructureChangeType.ChildAdded occurred on {sender} for {eventRuntimeId}"); // break; //case StructureChangeType.ChildrenBulkAdded: - // Context.API.LogDebug(ClassName, $"StructureChangeType.ChildrenBulkAdded occurred on {eventRuntimeId}"); + // Context.API.LogDebug(ClassName, $"TABS:StructureChangeType.ChildrenBulkAdded occurred on {sender} for {eventRuntimeId}"); // break; //case StructureChangeType.ChildrenInvalidated: + // Context.API.LogDebug(ClassName, $"TABS:StructureChangeType.ChildrenInvalidated occurred on {sender} for {eventRuntimeId}"); // _walker.CheckTabExistence(eventRuntimeId, "StructureChangeType.ChildrenInvalidated"); // break; //case StructureChangeType.ChildrenReordered: + // Context.API.LogDebug(ClassName, $"TABS:StructureChangeType.ChildrenReordered occurred on {sender} for {eventRuntimeId}"); // _walker.CheckTabExistence(eventRuntimeId, "StructureChangeType.ChildrenReordered"); // break; + case StructureChangeType.ChildRemoved: case StructureChangeType.ChildrenBulkRemoved: AutomationElement? foundWindow = null; @@ -255,7 +255,7 @@ private void UnsubscribeStructureChangedForWindow(AutomationElement wnd) } if (contains) { - Context.API.LogDebug(ClassName, "Unsubscribe window from StructureChanged events"); + Context.API.LogDebug(ClassName, "TABS:Unsubscribe window from StructureChanged events"); Automation.RemoveStructureChangedEventHandler(wnd, OnStructureChanged); _walker?.RemoveAllTabs(wnd); } @@ -265,7 +265,7 @@ internal void RegisterTab(string url, AutomationElement rootElement, BrowserTab { lock (_sync) { - Context.API.LogDebug(ClassName, $"Registering {url} as tab: {currentTab.Title}"); + Context.API.LogDebug(ClassName, $"TABS:{TabsCache.RuntimeIdToKey(currentTab.AutomationElement)}:Registering {url} as tab: {currentTab.Title}"); UrlToBrowserTab[url] = currentTab; // required to take the tab into account by Flow Launcher main UI search window diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs index 6b1060e787a..90a3a75109e 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs @@ -4,6 +4,8 @@ using System.Linq; using System.Threading; using System.Windows.Automation; +using System.Windows.Forms; +using System.Windows.Input; using BrowserTabs; using static Flow.Launcher.Plugin.BrowserBookmark.Main; @@ -36,7 +38,7 @@ private static IEnumerable FindAllValidTabs(AutomationElement var className = tab.Current.ClassName; if (className.Contains("bolt-tab", StringComparison.OrdinalIgnoreCase)) { - Context.API.LogDebug(ClassName, $"Skipping name='{name}', className='{className}'"); + Context.API.LogDebug(ClassName, $"TABS:Skipping name='{name}', className='{className}'"); continue; } @@ -64,19 +66,19 @@ public BrowserTab GetCurrentTabFromWindow(AutomationElement mainWindow, Process while (sw.Elapsed < _tabRetryTimeout && !cancellationToken.IsCancellationRequested) { - Context.API.LogDebug(ClassName, $"Start searching for a new tab... Try no {count++}"); + Context.API.LogDebug(ClassName, $"TABS:Start searching for a new tab... Try no {count++}"); var tabs = FindAllValidTabs(mainWindow).ToList(); if (tabs.Count == 0) { - Context.API.LogDebug(ClassName, "No valid tabs found"); + Context.API.LogDebug(ClassName, "TABS:No valid tabs found"); Thread.Sleep(_tabRetryInterval); continue; } if (_cache.Empty()) { - Context.API.LogDebug(ClassName, "First time filling the cache"); + Context.API.LogDebug(ClassName, "TABS:First time filling the cache"); _cache.Add(tabs); // Let's take the last one and assume this is the one that was created recently @@ -86,8 +88,8 @@ public BrowserTab GetCurrentTabFromWindow(AutomationElement mainWindow, Process return InitiateTab(process, tabs.Last()); } - Context.API.LogDebug(ClassName, $"Found tabs: {tabs.Count}"); - //TabsDebug.DumpElements(mainWindow, null, "Tab"); + Context.API.LogDebug(ClassName, $"TABS:Found tabs: {tabs.Count}"); + //TabsDebug.DumpTabs(mainWindow); // searching from the end and looking for a tab not in the cache for (var i = tabs.Count - 1; i >= 0; i--) @@ -95,38 +97,38 @@ public BrowserTab GetCurrentTabFromWindow(AutomationElement mainWindow, Process var tab = tabs[i]; if (!_cache.Contains(tab)) { - Context.API.LogDebug(ClassName, $"FOUND A NEW TAB: name={tab.Current.Name}, className={tab.Current.ClassName}"); + Context.API.LogDebug(ClassName, $"TABS:FOUND A NEW TAB: name={tab.Current.Name}, className={tab.Current.ClassName}"); _cache.Add(tab); return InitiateTab(process, tab); } } - Context.API.LogDebug(ClassName, "No new tab found"); + Context.API.LogDebug(ClassName, "TABS:No new tab found"); Thread.Sleep(_tabRetryInterval); } - Context.API.LogDebug(ClassName, "Timeout waiting for new tab"); + Context.API.LogDebug(ClassName, "TABS:Timeout waiting for new tab"); } catch (ElementNotAvailableException ex) { - Context.API.LogException(ClassName, "Element not available", ex); + Context.API.LogException(ClassName, "TABS:Element not available", ex); } catch (Exception ex) { - Context.API.LogException(ClassName, "Error getting current tab from window", ex); + Context.API.LogException(ClassName, "TABS:Error getting current tab from window", ex); } return null; } internal void RescanTabsForContainer(AutomationElement browserWindow) { - Context.API.LogDebug(ClassName, "Rescanning tabs in order to find removed tabs"); + Context.API.LogDebug(ClassName, "TABS:Rescanning tabs in order to find removed tabs"); _cache.RemoveAllNonExistentTabs(browserWindow, FindAllValidTabs(browserWindow)); } internal void RemoveAllTabs(AutomationElement browserWindow) { - Context.API.LogDebug(ClassName, "Removing all tabs in a window"); + Context.API.LogDebug(ClassName, "TABS:Removing all tabs in a window"); _cache.RemoveAllNonExistentTabs(browserWindow, []); } @@ -134,7 +136,7 @@ internal void CheckTabExistence(string runtimeId, string reason = "") { if (_cache.Contains(runtimeId)) { - Context.API.LogDebug(ClassName, $"Tab exists {reason}"); + Context.API.LogDebug(ClassName, $"TABS:{runtimeId}:Tab exists {reason}"); } } } From 680de613b82b09980d9d99aa103cc6717125dca3 Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Fri, 2 Jan 2026 19:18:26 +0100 Subject: [PATCH 30/35] Significant improvements in handling tabs in multi-browsers, multi-windows circumstances --- .../Main.cs | 19 +- .../Tabs/TabsCache.cs | 126 ++++-- .../Tabs/TabsDebug.cs | 81 ---- .../Tabs/TabsEventsDispatcher.cs | 89 ++++ .../Tabs/TabsFocusEventDispatcher.cs | 90 ---- .../Tabs/TabsReservationService.cs | 196 +++++++++ .../Tabs/TabsTracker.cs | 415 ++++++++++-------- .../Tabs/TabsWalker.cs | 142 ------ .../Views/SettingsControl.xaml.cs | 3 +- 9 files changed, 625 insertions(+), 536 deletions(-) delete mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsDebug.cs create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsEventsDispatcher.cs delete mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsFocusEventDispatcher.cs create mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsReservationService.cs delete mode 100644 Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs index e8235764cee..7831acbc756 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs @@ -28,7 +28,7 @@ public class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex private static bool _initialized = false; - private readonly TabsTracker _tabsTracker = new(); + private static readonly TabsReservationService _tabsReservationService = new(); public void Init(PluginInitContext context) { @@ -61,8 +61,13 @@ public void Init(PluginInitContext context) } LoadBookmarksIfEnabled(); + SetReuseTabs(_settings.ReuseTabs); + } - _tabsTracker.Init(); + public static void SetReuseTabs(bool reuseTabs) + { + _tabsReservationService.EnableTracking(reuseTabs); + _settings.ReuseTabs = reuseTabs; } private static void LoadBookmarksIfEnabled() @@ -97,7 +102,7 @@ public List Query(Query query) if (!topResults) { // Since we mixed chrome and firefox bookmarks, we should order them again - return _tabsTracker.InjectExistingTabs(_settings, _cachedBookmarks + return _tabsReservationService.InjectExistingTabs(_settings, _cachedBookmarks .Select( c => new Result { @@ -109,7 +114,7 @@ public List Query(Query query) Score = BookmarkLoader.MatchProgram(c, param).Score, Action = _ => { - _tabsTracker.OpenUrlAndTrack(_settings, c.Url); + _tabsReservationService.OpenUrlAndTrack(_settings, c.Url); return true; }, ContextData = new BookmarkAttributes { Url = c.Url } @@ -120,7 +125,7 @@ public List Query(Query query) } else { - return _tabsTracker.InjectExistingTabs(_settings, _cachedBookmarks + return _tabsReservationService.InjectExistingTabs(_settings, _cachedBookmarks .Select( c => new Result { @@ -132,7 +137,7 @@ public List Query(Query query) Score = 5, Action = _ => { - _tabsTracker.OpenUrlAndTrack(_settings, c.Url); + _tabsReservationService.OpenUrlAndTrack(_settings, c.Url); return true; }, ContextData = new BookmarkAttributes { Url = c.Url } @@ -265,7 +270,7 @@ internal class BookmarkAttributes public void Dispose() { - _tabsTracker.Dispose(); + _tabsReservationService.Dispose(); DisposeFileWatchers(); } diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs index a4eba27997b..52de5ed5de5 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Windows.Automation; @@ -7,90 +8,129 @@ namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs; /// -/// Keeps record of all known browser's tabs. -/// It is used by TabsWalker to identify new tabs as they appear. +/// TabsCache keeps record of all known browser's tabs in a single web browser window. /// -internal class TabsCache +public class TabsCache { private static readonly string ClassName = nameof(TabsCache); - private readonly HashSet _knownTabs = []; + + // 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; - public static string RuntimeIdToKey(int[] runtimeId) + private class Info(int index) { - return string.Join("-", runtimeId); + public int Index { get; init; } = index; + public int Age { get; set; } = 0; } - public static string RuntimeIdToKey(AutomationElement elem) + private static string TryName(AutomationElement element) { try { - return elem != null ? RuntimeIdToKey(elem.GetRuntimeId()) : null; + return element.Current.Name; } - catch (ElementNotAvailableException) + catch (Exception e) { - return null; + return e.GetType().ToString(); } } - public bool Empty() + private static bool Destroyed(AutomationElement element) { - lock (_sync) + try + { + var _ = element.Current.Name; + } + catch (ElementNotAvailableException) { - return _knownTabs.Count == 0; + return true; } + return false; } - public void Add(AutomationElement tab) + public void Invalidate() { lock (_sync) { - var key = RuntimeIdToKey(tab); - if (key != null) - { - Context.API.LogDebug(ClassName, $"TABS:{key}:Adding to cache: {tab.Current.Name}"); - _knownTabs.Add(key); - } + Valid = false; } } - public void Add(IEnumerable tabs) + + public List GetTabs() { - foreach (var tab in tabs) + lock (_sync) { - Add(tab); + return [.. _indexToElement.Values]; } } - public bool Contains(AutomationElement tab) - { - return Contains(RuntimeIdToKey(tab)); - } - - public bool Contains(string runtimeId) + public AutomationElement TryGetTab(int index) { lock (_sync) { - if (runtimeId != null) + 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 _knownTabs.Contains(runtimeId); + return tab; } + return null; } - return false; } - public void RemoveAllNonExistentTabs(AutomationElement rootElement, IEnumerable existingTabs) + public int UpdateTabs(int lastAssignedIndex, List actualTabs, out List removedTabs) { - if (rootElement == null || existingTabs == null) - return; + 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}"); - var rootKey = RuntimeIdToKey(rootElement); - var existingKeys = existingTabs.Select(RuntimeIdToKey).Where(k => k != null).ToHashSet(); - var keysToRemove = _knownTabs.Where(t => t.StartsWith(rootKey) && !existingKeys.Contains(t)); + removedTabs = []; - foreach (var key in keysToRemove) - { - Context.API.LogDebug(ClassName, $"TABS:{key}:Removing from cache"); - _knownTabs.Remove(key); + 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/TabsDebug.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsDebug.cs deleted file mode 100644 index 30bdfa6a1b4..00000000000 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsDebug.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System; -using System.Windows.Automation; -using static Flow.Launcher.Plugin.BrowserBookmark.Main; - -namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs; - -/// -/// Just for debugging. -/// Call DumpElements whenever you need to analyze browser's internal structure. -/// -internal class TabsDebug -{ - private static readonly string ClassName = nameof(TabsDebug); - - public static void DumpTabs(AutomationElement parent) - { - DumpElements(parent, null, "Tab"); - } - - public static void DumpElements(AutomationElement parent, string classNameOnly = null, string controlTypeOnly = null, int indent = 0) - { - if (parent == null) - return; - - AutomationElementCollection children; - try - { - children = parent.FindAll(TreeScope.Children, Condition.TrueCondition); - } - catch (ElementNotAvailableException ex) - { - Context.API.LogDebug(ClassName, $"TABS:Parent not available: {ex.Message}"); - return; - } - - foreach (AutomationElement child in children) - { - try - { - var ct = child.Current.ControlType; - var type = ct?.ProgrammaticName?.Replace("ControlType.", ""); - var name = child.Current.Name; - var className = child.Current.ClassName; - var isOffscreen = child.Current.IsOffscreen; - var isEnabled = child.Current.IsEnabled; - var rect = child.Current.BoundingRectangle; - - var dump = true; - if (!string.IsNullOrEmpty(classNameOnly) && className != classNameOnly) - dump = false; - - if (!string.IsNullOrEmpty(controlTypeOnly) && type != controlTypeOnly) - dump = false; - - if (dump) - { - Context.API.LogDebug( - ClassName, - $"TABS:{new string(' ', indent)}" + - $"Type='{type}', " + - $"ClassName='{className}', " + - $"Name='{name}', " + - $"IsOffscreen={isOffscreen}, " + - $"IsEnabled={isEnabled}, " + - $"BoundingRectangle={rect}" - ); - } - - DumpElements(child, classNameOnly, controlTypeOnly, indent + 2); - } - catch (ElementNotAvailableException ex) - { - Context.API.LogDebug(ClassName, $"TABS:Child not available: {ex.Message}"); - } - catch (Exception ex) - { - Context.API.LogException(ClassName, $"TABS:Unexpected error", ex); - } - } - } -} 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/TabsFocusEventDispatcher.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsFocusEventDispatcher.cs deleted file mode 100644 index 78c82c922b1..00000000000 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsFocusEventDispatcher.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Diagnostics; -using System.Threading; -using System.Windows.Automation; -using System.Windows.Input; -using Flow.Launcher.Plugin.BrowserBookmark.Tabs; -using static Flow.Launcher.Plugin.BrowserBookmark.Main; - -/// -/// TabsFocusEventDispatcher handles TabsTracker.OnFocusChanged events in a separate thread. -/// This is to avoid sleeping in Automation.AddAutomationFocusChangedEventHandler. -/// -internal sealed class TabsFocusEventDispatcher : IDisposable -{ - private static readonly string ClassName = nameof(TabsFocusEventDispatcher); - private readonly BlockingCollection> _queue = []; - private readonly Thread _worker; - private readonly CancellationTokenSource _cts = new(); - private readonly TabsWalker _walker; - private readonly TabsTracker _tracker; - - public TabsFocusEventDispatcher(TabsWalker walker, TabsTracker tracker) - { - _worker = new Thread(WorkerLoop) - { - IsBackground = true, - Name = "FocusEventWorker" - }; - _worker.Start(); - _walker = walker; - _tracker = tracker; - } - - public void Enqueue(string url, AutomationElement element, int processId) - { - if (!_queue.IsAddingCompleted) - _queue.Add(Tuple.Create(url, element, processId)); - } - - void WorkerLoop() - { - try - { - foreach (var element in _queue.GetConsumingEnumerable(_cts.Token)) - { - HandleFocus(element); - } - } - catch (OperationCanceledException) - { - // shutting down - } - } - - void HandleFocus(Tuple tuple) - { - var url = tuple.Item1; - var rootElement = tuple.Item2; - var processId = tuple.Item3; - - try - { - using var process = Process.GetProcessById(processId); - - var currentTab = _walker.GetCurrentTabFromWindow(rootElement, process, CancellationToken.None); - if (currentTab != null) - { - _tracker.RegisterTab(url, rootElement, currentTab); - } - } - catch (ArgumentException) - { - Context.API.LogError(ClassName, $"TABS:Process {processId} no longer runs"); - } - catch (Exception ex) - { - Context.API.LogException(ClassName, "TABS:Exception", ex); - } - } - - 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..7f3549e11d6 --- /dev/null +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsReservationService.cs @@ -0,0 +1,196 @@ +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 = _tabsTracker.TryGetTab(expectedIndex, out var foundInTrackingInfo); + trackingInfo = foundInTrackingInfo; + 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) + { + 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(); + } + } + + 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 index a8f67ba8d87..857ab355f33 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs @@ -1,283 +1,356 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Threading; using System.Windows.Automation; -using System.Windows.Forms; -using System.Windows.Input; -using BrowserTabs; -using Flow.Launcher.Plugin.BrowserBookmark.Models; using static Flow.Launcher.Plugin.BrowserBookmark.Main; +using static Flow.Launcher.Plugin.BrowserBookmark.Tabs.TabsReservationService; namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs; -#nullable enable - /// -/// TabsTracker maps initial URLs into existing browser's tabs. -/// The sequence of events: -/// 1. OpenUrlAndTrack - before launching an URL it is remembered for later mapping to a browser's tab -/// 2. OnFocusChanged - whenever a browser's window gets focused a new tab discovery is started and result is put into the UrlToBrowserTab map -/// 3. InjectExistingTabs - iterates over BrowserBookmark's query result and replaces OpenUrl with ActivateTab for known, existing 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); - private static readonly HashSet chromiumProcessNames = new(["msedge", "chrome", "brave", "vivaldi", "opera", "chromium"], StringComparer.OrdinalIgnoreCase); + + // 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 readonly TabsWalker _walker = new(); - private readonly Queue _expectedUrls = []; - private Dictionary UrlToBrowserTab { get; } = []; + 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 ConcurrentQueue _structureInvalidations = []; - private TabsFocusEventDispatcher? _focusHandlerDispatcher; - private AutomationFocusChangedEventHandler? _focusHandler; - private readonly HashSet _browserWindowsTracked = []; - private bool _initialized; + private TabsReservationService _service; - public void OpenUrlAndTrack(Settings settings, string url) + public TabsTracker(TabsReservationService service) { - if (settings.ReuseTabs) - { - ExpectUrl(url); - } - Context.API.OpenUrl(url); + _service = service; } - public List InjectExistingTabs(Settings settings, List results) + public static string RuntimeIdToKey(AutomationElement elem) { - if (!settings.ReuseTabs) + try { - return results; + return elem != null ? string.Join("-", elem.GetRuntimeId()) : null; } - foreach (var r in results) + catch (ElementNotAvailableException) { - var bookmarkUrl = ((BookmarkAttributes)r.ContextData).Url; - var existingTab = GetExistingTab(bookmarkUrl); - if (existingTab != null) + 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)) { - r.ContextData = existingTab; - r.Action = c => - { - if (!existingTab.ActivateTab()) - { - Context.API.LogError(ClassName, $"TABS:{TabsCache.RuntimeIdToKey(existingTab.AutomationElement)}:Failed to activate"); - Remove(bookmarkUrl); - OpenUrlAndTrack(settings, bookmarkUrl); - } - return true; - }; + 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; } - return results; } - public void Init() + /// + /// TrackingInfo keeps context of a single browser window + /// + public class TrackingInfo(AutomationElement rootElement, StructureChangedEventHandler structureChangedHandler, AutomationEventHandler windowCloseHandler, string processName, nint processMainWindowHandle) { - if (_initialized) - return; - - _focusHandlerDispatcher = new(_walker, this); - _focusHandler = OnFocusChanged; - Automation.AddAutomationFocusChangedEventHandler(_focusHandler); - _initialized = true; + 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() { - List windowsToUnsubscribe; + 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) { - windowsToUnsubscribe = [.. _browserWindowsTracked]; + EnsureHavingAllBrowsersWindows(); + EnsureHavingAllBrowsersTabs(); + if (registerToken != null) + { + var token = registerToken(_lastAssignedIndex); + _expectedUrls.Enqueue(Tuple.Create(requestedUrl, token)); + } } + } - foreach (var wnd in windowsToUnsubscribe) + private void EnsureHavingAllBrowsersWindows() + { + // this is done once + // later on list is updated using WindowOpen / WindowClose events + if (!_windowsHandlerInitialized) { - UnsubscribeStructureChangedForWindow(wnd); + 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; } - if (_focusHandler != null) + } + + 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) { - Automation.RemoveAutomationFocusChangedEventHandler(_focusHandler); - _focusHandler = null; - _initialized = false; + bool structureChangesTracked = _browserWindowsTracked.TryGetValue(element, out var trackingInfo); + if (structureChangesTracked && trackingInfo.WindowCloseHandler != null) + { + Automation.RemoveAutomationEventHandler(WindowPattern.WindowClosedEvent, element, trackingInfo.WindowCloseHandler); + } + + HandleProcessExit(element); } - _focusHandlerDispatcher?.Dispose(); } - public void ExpectUrl(string url) + private static Process TryProcess(int processId) { - lock (_sync) + try + { + return Process.GetProcessById(processId); + } + catch (Exception) { - _expectedUrls.Enqueue(url); + return null; } } - private BrowserTab? GetExistingTab(string url) + 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) { - if (UrlToBrowserTab.TryGetValue(url, out var existingTab)) + bool structureChangesTracked = _browserWindowsTracked.ContainsKey(element); + if (!structureChangesTracked) { - return existingTab; + SubscribeStructureChangedEventHandler(element, process); } } - return null; } - private void Remove(string url) + private void HandleProcessExit(AutomationElement element) { lock (_sync) { - UrlToBrowserTab.Remove(url); + bool structureChangesTracked = _browserWindowsTracked.TryGetValue(element, out var trackingInfo); + if (structureChangesTracked) + { + UnsubscribeStructureChangedEventHandler(element, trackingInfo); + } } } - private void OnFocusChanged(object sender, AutomationFocusChangedEventArgs e) + private void SubscribeStructureChangedEventHandler(AutomationElement element, Process process) { - lock (_sync) + void structureChangedHandler(object sender, StructureChangedEventArgs e) { - if (_expectedUrls.Count == 0) - return; + OnStructureChanged(element, sender, e); } - try - { - if (sender is not AutomationElement element) - return; + Automation.AddStructureChangedEventHandler(element, TreeScope.Subtree, structureChangedHandler); - var pid = 0; - try - { - pid = element.Current.ProcessId; - } - catch (ElementNotAvailableException) - { - return; - } - - using var process = Process.GetProcessById(pid); + void windowCloseHandler(object src, AutomationEventArgs e) + { + OnWindowClose(element, src, e); + } - var chromium = chromiumProcessNames.Contains(process.ProcessName); - var firefox = firefoxProcessNames.Contains(process.ProcessName); - if (!chromium && !firefox) - return; // not a browser + _browserWindowsTracked[element] = new TrackingInfo(element, structureChangedHandler, windowCloseHandler, process.ProcessName, process.MainWindowHandle); - Context.API.LogDebug(ClassName, $"TABS:The active browser is {process.ProcessName}"); + Automation.AddAutomationEventHandler(WindowPattern.WindowClosedEvent, element, TreeScope.Element, windowCloseHandler); - var rootElement = AutomationElement.FromHandle(process.MainWindowHandle); - if (rootElement == null) - return; + Context.API.LogDebug(ClassName, $"TABS:Window {RuntimeIdToKey(element)} SUBSCRIBED for StructureChanged events"); + } - string? urlToBind; - lock (_sync) - { - if (_expectedUrls.Count == 0) - return; + private void UnsubscribeStructureChangedEventHandler(AutomationElement element, TrackingInfo trackingInfo) + { + Automation.RemoveStructureChangedEventHandler(element, trackingInfo.StructureChangedHandler); + _service.UnregisterTabs(trackingInfo.Cache.GetTabs()); + _browserWindowsTracked.Remove(element); - urlToBind = _expectedUrls.Dequeue(); - } - if (urlToBind is null) - return; + Context.API.LogDebug(ClassName, $"TABS:Window {RuntimeIdToKey(element)} UNSUBSCRIBED from StructureChanged events"); + } - Context.API.LogDebug(ClassName, $"TABS:Searching for... {urlToBind}"); + private void EnsureHavingAllBrowsersTabs() + { + Context.API.LogDebug(ClassName, "TABS:EnsureHavingAllBrowsersTabs ..."); - // Further handling requires waiting for tabs so its better to run it on a separate thread - _focusHandlerDispatcher?.Enqueue(urlToBind, rootElement, pid); + var unique = _structureInvalidations.ToArray().Distinct().Where(_browserWindowsTracked.ContainsKey); + foreach (var element in unique) + { + _browserWindowsTracked[element].Cache.Invalidate(); } - catch (Exception ex) + + foreach (var pair in _browserWindowsTracked) { - Context.API.LogException(ClassName, "TABS:Exception", ex); + 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(object sender, StructureChangedEventArgs e) + private void OnStructureChanged(AutomationElement window, object sender, StructureChangedEventArgs e) { - //if (e.StructureChangeType == StructureChangeType.ChildAdded || e.StructureChangeType == StructureChangeType.ChildrenBulkAdded) - //{ - //} - var eventRuntimeId = TabsCache.RuntimeIdToKey(e.GetRuntimeId()); + //Context.API.LogDebug(ClassName, $"TABS:Received {e.StructureChangeType.ToString()} on {sender}"); switch (e.StructureChangeType) { - // TODO: Consider ChildAdded to handle new tabs appearance instead of AutomationFocusChangedEventHandler - // However think twice if it is worthwhile as the current approach based on focus might already be a good one - //case StructureChangeType.ChildAdded: - // Context.API.LogDebug(ClassName, $"TABS:StructureChangeType.ChildAdded occurred on {sender} for {eventRuntimeId}"); - // break; - //case StructureChangeType.ChildrenBulkAdded: - // Context.API.LogDebug(ClassName, $"TABS:StructureChangeType.ChildrenBulkAdded occurred on {sender} for {eventRuntimeId}"); - // break; - //case StructureChangeType.ChildrenInvalidated: - // Context.API.LogDebug(ClassName, $"TABS:StructureChangeType.ChildrenInvalidated occurred on {sender} for {eventRuntimeId}"); - // _walker.CheckTabExistence(eventRuntimeId, "StructureChangeType.ChildrenInvalidated"); - // break; - //case StructureChangeType.ChildrenReordered: - // Context.API.LogDebug(ClassName, $"TABS:StructureChangeType.ChildrenReordered occurred on {sender} for {eventRuntimeId}"); - // _walker.CheckTabExistence(eventRuntimeId, "StructureChangeType.ChildrenReordered"); - // break; - + case StructureChangeType.ChildAdded: + case StructureChangeType.ChildrenBulkAdded: + case StructureChangeType.ChildrenInvalidated: + case StructureChangeType.ChildrenReordered: case StructureChangeType.ChildRemoved: case StructureChangeType.ChildrenBulkRemoved: - AutomationElement? foundWindow = null; - lock (_sync) + _structureInvalidations.Enqueue(window); + break; + } + switch (e.StructureChangeType) + { + case StructureChangeType.ChildAdded: + case StructureChangeType.ChildrenBulkAdded: + while (_expectedUrls.TryDequeue(out var tuple)) { - foreach (var window in _browserWindowsTracked) + lock (_sync) { - var windowRuntimeId = TabsCache.RuntimeIdToKey(window); - if (windowRuntimeId != null && eventRuntimeId.StartsWith(windowRuntimeId)) - { - foundWindow = window; - } + _eventsDispatcher ??= new(this, _service); + _eventsDispatcher.Enqueue(tuple.Item1, tuple.Item2); } } - if (foundWindow != null) - { - _walker.RescanTabsForContainer(foundWindow); - } break; } } - private void OnWindowClosed(object sender, AutomationEventArgs e) + public AutomationElement TryGetTab(int expectedIndex, out TrackingInfo foundInTrackingInfo) { - var element = sender as AutomationElement; - if (element == null) - return; - UnsubscribeStructureChangedForWindow(element); + lock (_sync) + { + foreach (var trackingInfo in _browserWindowsTracked.Values) + { + var tab = trackingInfo.Cache.TryGetTab(expectedIndex); + if (tab != null) + { + foundInTrackingInfo = trackingInfo; + return tab; + } + } + foundInTrackingInfo = null; + return null; + } } - private void UnsubscribeStructureChangedForWindow(AutomationElement wnd) + public void EnableTracking(bool enable) { - var contains = false; lock (_sync) { - contains = _browserWindowsTracked.Contains(wnd); - _browserWindowsTracked.Remove(wnd); - } - if (contains) - { - Context.API.LogDebug(ClassName, "TABS:Unsubscribe window from StructureChanged events"); - Automation.RemoveStructureChangedEventHandler(wnd, OnStructureChanged); - _walker?.RemoveAllTabs(wnd); + if (_trackingEnabled == enable) + return; + + _trackingEnabled = enable; + if (!_trackingEnabled) + { + DisableTracking(); + } } } - internal void RegisterTab(string url, AutomationElement rootElement, BrowserTab currentTab) + private void DisableTracking() { lock (_sync) { - Context.API.LogDebug(ClassName, $"TABS:{TabsCache.RuntimeIdToKey(currentTab.AutomationElement)}:Registering {url} as tab: {currentTab.Title}"); - UrlToBrowserTab[url] = currentTab; + _eventsDispatcher?.Dispose(); + _eventsDispatcher = null; - // required to take the tab into account by Flow Launcher main UI search window - Context.API.ReQuery(); - } + if (_windowsHandlerInitialized) + { + Automation.RemoveAutomationEventHandler(WindowPattern.WindowOpenedEvent, AutomationElement.RootElement, OnWindowOpen); - Automation.AddStructureChangedEventHandler(rootElement, TreeScope.Subtree, OnStructureChanged); - Automation.AddAutomationEventHandler(WindowPattern.WindowClosedEvent, rootElement, TreeScope.Subtree, OnWindowClosed); + foreach (var tracking in _browserWindowsTracked) + { + UnsubscribeStructureChangedEventHandler(tracking.Key, tracking.Value); + } + _browserWindowsTracked.Clear(); + _structureInvalidations.Clear(); + _expectedUrls.Clear(); - lock (_sync) - { - _browserWindowsTracked.Add(rootElement); + _windowsHandlerInitialized = false; + } } } } diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs deleted file mode 100644 index 90a3a75109e..00000000000 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using System.Windows.Automation; -using System.Windows.Forms; -using System.Windows.Input; -using BrowserTabs; -using static Flow.Launcher.Plugin.BrowserBookmark.Main; - -namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs; - -/// -/// TabsWalker waits for a new browser's tab to appear. -/// It uses TabsCache to keep known tabs. -/// Note that browsers don't provide full control over this process, so we have to rely on heuristics and a "best effort" approach. -/// -internal class TabsWalker -{ - private static readonly string ClassName = nameof(TabsWalker); - private readonly TimeSpan _tabRetryTimeout = TimeSpan.FromSeconds(5); - private readonly TimeSpan _tabRetryInterval = TimeSpan.FromMilliseconds(250); - private readonly TabsCache _cache = new(); - - private static IEnumerable FindAllValidTabs(AutomationElement mainWindow) - { - Condition tabCondition = new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.TabItem); - foreach (AutomationElement tab in mainWindow.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; - } - } - - private static BrowserTab InitiateTab(Process process, AutomationElement tab) - { - return new() - { - Title = tab.Current.Name, - BrowserName = process.ProcessName, - Hwnd = process.MainWindowHandle, - AutomationElement = tab - }; - } - - public BrowserTab GetCurrentTabFromWindow(AutomationElement mainWindow, Process process, CancellationToken cancellationToken) - { - try - { - var sw = Stopwatch.StartNew(); - var count = 1; - - while (sw.Elapsed < _tabRetryTimeout && !cancellationToken.IsCancellationRequested) - { - Context.API.LogDebug(ClassName, $"TABS:Start searching for a new tab... Try no {count++}"); - - var tabs = FindAllValidTabs(mainWindow).ToList(); - if (tabs.Count == 0) - { - Context.API.LogDebug(ClassName, "TABS:No valid tabs found"); - Thread.Sleep(_tabRetryInterval); - continue; - } - - if (_cache.Empty()) - { - Context.API.LogDebug(ClassName, "TABS:First time filling the cache"); - _cache.Add(tabs); - - // Let's take the last one and assume this is the one that was created recently - // This is the best known approach as of today - // There might be some browsers' settings that change this behavior but weren't tested nor considered yet - // TODO: Research browsers' settings and check if it may break current assumption of just taking the last tab - return InitiateTab(process, tabs.Last()); - } - - Context.API.LogDebug(ClassName, $"TABS:Found tabs: {tabs.Count}"); - //TabsDebug.DumpTabs(mainWindow); - - // searching from the end and looking for a tab not in the cache - for (var i = tabs.Count - 1; i >= 0; i--) - { - var tab = tabs[i]; - if (!_cache.Contains(tab)) - { - Context.API.LogDebug(ClassName, $"TABS:FOUND A NEW TAB: name={tab.Current.Name}, className={tab.Current.ClassName}"); - _cache.Add(tab); - return InitiateTab(process, tab); - } - } - - Context.API.LogDebug(ClassName, "TABS:No new tab found"); - Thread.Sleep(_tabRetryInterval); - } - - Context.API.LogDebug(ClassName, "TABS:Timeout waiting for new tab"); - } - catch (ElementNotAvailableException ex) - { - Context.API.LogException(ClassName, "TABS:Element not available", ex); - } - catch (Exception ex) - { - Context.API.LogException(ClassName, "TABS:Error getting current tab from window", ex); - } - return null; - } - - internal void RescanTabsForContainer(AutomationElement browserWindow) - { - Context.API.LogDebug(ClassName, "TABS:Rescanning tabs in order to find removed tabs"); - _cache.RemoveAllNonExistentTabs(browserWindow, FindAllValidTabs(browserWindow)); - } - - internal void RemoveAllTabs(AutomationElement browserWindow) - { - Context.API.LogDebug(ClassName, "TABS:Removing all tabs in a window"); - _cache.RemoveAllNonExistentTabs(browserWindow, []); - } - - internal void CheckTabExistence(string runtimeId, string reason = "") - { - if (_cache.Contains(runtimeId)) - { - Context.API.LogDebug(ClassName, $"TABS:{runtimeId}:Tab exists {reason}"); - } - } -} diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml.cs index 0fca79409da..643c397c887 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Views/SettingsControl.xaml.cs @@ -53,8 +53,7 @@ public bool ReuseTabs get => Settings.ReuseTabs; set { - Settings.ReuseTabs = value; - // reloading of bookmarks is not needed while this settings changes + _ = Task.Run(() => Main.SetReuseTabs(value)); } } From a30180c8b40cee755b18ab242ae853422c40c0f8 Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Fri, 2 Jan 2026 19:46:14 +0100 Subject: [PATCH 31/35] doc: README.md updated as known issue was resolved --- .../Tabs/README.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md index fb561d7ed38..d467a8573f2 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md @@ -10,7 +10,6 @@ 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. @@ -19,19 +18,6 @@ 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!). -# Known issues - -The extension bases on RuntimeId of AutomationElement from Microsoft UI Automation. -This is a weak spot as browsers are used to regenerate internal structures and even reuse RuntimeId. -Therefore it happens that a bookmark activates a wrong tab. -The most common case is while user opens several tabs one after the other quickly. -Still **"just take me to THIS place in milliseconds"** works almost all of the time so it brings so much value that it is worthwhile to accept the fact it fails sometimes. - -The quickest workaround is: - -- close the wrong tab -- rerun opening the bookmark which will create a new tab this time - # Alternatives Reading URLs of existing tabs was tried. It would make mapping of bookmarks to tabs more reliable. From f9018368ab718adc3dc33ea8a55eeca1891a9370 Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Fri, 2 Jan 2026 19:55:16 +0100 Subject: [PATCH 32/35] improve _structureInvalidations handling --- .../Tabs/TabsTracker.cs | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs index 857ab355f33..2c455446ce3 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs @@ -37,7 +37,9 @@ public class TabsTracker : IDisposable private readonly ConcurrentQueue> _expectedUrls = []; private TabsEventsDispatcher _eventsDispatcher; - private ConcurrentQueue _structureInvalidations = []; + + private readonly Lock _syncInvalidations = new(); + private HashSet _structureInvalidations = []; private TabsReservationService _service; @@ -244,8 +246,13 @@ private void EnsureHavingAllBrowsersTabs() { Context.API.LogDebug(ClassName, "TABS:EnsureHavingAllBrowsersTabs ..."); - var unique = _structureInvalidations.ToArray().Distinct().Where(_browserWindowsTracked.ContainsKey); - foreach (var element in unique) + List elementsToInvalidate; + lock (_syncInvalidations) + { + elementsToInvalidate = [.. _structureInvalidations]; + _structureInvalidations.Clear(); + } + foreach (var element in elementsToInvalidate) { _browserWindowsTracked[element].Cache.Invalidate(); } @@ -278,7 +285,10 @@ private void OnStructureChanged(AutomationElement window, object sender, Structu case StructureChangeType.ChildrenReordered: case StructureChangeType.ChildRemoved: case StructureChangeType.ChildrenBulkRemoved: - _structureInvalidations.Enqueue(window); + lock (_syncInvalidations) + { + _structureInvalidations.Add(window); + } break; } switch (e.StructureChangeType) @@ -332,6 +342,11 @@ public void EnableTracking(bool enable) private void DisableTracking() { + lock (_syncInvalidations) + { + _structureInvalidations.Clear(); + } + lock (_sync) { _eventsDispatcher?.Dispose(); @@ -346,7 +361,6 @@ private void DisableTracking() UnsubscribeStructureChangedEventHandler(tracking.Key, tracking.Value); } _browserWindowsTracked.Clear(); - _structureInvalidations.Clear(); _expectedUrls.Clear(); _windowsHandlerInitialized = false; From c75cb1acecf0ba8df2d180e6cf6f7977a9a358c1 Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Fri, 2 Jan 2026 20:00:04 +0100 Subject: [PATCH 33/35] Resolved: a few PR issues reported by coderabbitai --- .../Tabs/TabsCache.cs | 6 ++-- .../Tabs/TabsReservationService.cs | 33 +++++++++++-------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs index 52de5ed5de5..432b62e5498 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs @@ -123,10 +123,10 @@ public int UpdateTabs(int lastAssignedIndex, List actualTabs, _indexToElement[newIndex] = tabToAdd; } - foreach (var tabtoRevive in tabsToRevive) + 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; + Context.API.LogDebug(ClassName, $"TABS:Reset age of {TryName(tabToRevive.Key)} as it appeared again"); + _elementToInfo[tabToRevive.Key].Age = 0; } Valid = true; diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsReservationService.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsReservationService.cs index 7f3549e11d6..d48a63d627d 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsReservationService.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsReservationService.cs @@ -144,20 +144,27 @@ public void RegisterTab(string url, TrackingInfo trackingInfo, AutomationElement { lock (_sync) { - var currentTab = new BrowserTab + try { - 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(); + 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}"); + } } } From 37c733789e145ab32754354211c256bb082e7e84 Mon Sep 17 00:00:00 2001 From: Andrzej Martyna Date: Sat, 3 Jan 2026 10:29:38 +0100 Subject: [PATCH 34/35] fixed: fixed KeyNotFoundException and improvement --- .../Tabs/TabsReservationService.cs | 4 ++-- .../Tabs/TabsTracker.cs | 10 ++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsReservationService.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsReservationService.cs index d48a63d627d..5f125a862cd 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsReservationService.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsReservationService.cs @@ -122,8 +122,8 @@ public AutomationElement TryToResolveToken(TokenForNewTab token, out TrackingInf _tabsTracker.MakeSnapshot(); int expectedIndex = Math.Max(tokenHandling.LastReturnedIndex, token.Index) + 1; - var tab = _tabsTracker.TryGetTab(expectedIndex, out var foundInTrackingInfo); - trackingInfo = foundInTrackingInfo; + var (tab, info) = _tabsTracker.TryGetTab(expectedIndex); + trackingInfo = info; if (tab != null) { if (tokenHandling.RequestedStill <= 1) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs index 2c455446ce3..18fdd0a52f0 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs @@ -249,7 +249,7 @@ private void EnsureHavingAllBrowsersTabs() List elementsToInvalidate; lock (_syncInvalidations) { - elementsToInvalidate = [.. _structureInvalidations]; + elementsToInvalidate = _structureInvalidations.Where(_browserWindowsTracked.ContainsKey).ToList(); _structureInvalidations.Clear(); } foreach (var element in elementsToInvalidate) @@ -307,7 +307,7 @@ private void OnStructureChanged(AutomationElement window, object sender, Structu } } - public AutomationElement TryGetTab(int expectedIndex, out TrackingInfo foundInTrackingInfo) + public (AutomationElement, TrackingInfo) TryGetTab(int expectedIndex) { lock (_sync) { @@ -316,12 +316,10 @@ public AutomationElement TryGetTab(int expectedIndex, out TrackingInfo foundInTr var tab = trackingInfo.Cache.TryGetTab(expectedIndex); if (tab != null) { - foundInTrackingInfo = trackingInfo; - return tab; + return (tab, trackingInfo); } } - foundInTrackingInfo = null; - return null; + return (null, null); } } From 84ef5aa9b69649a4481a58c2995c144cf90bb56c Mon Sep 17 00:00:00 2001 From: Andrzej Martyna <4319685+andrzejmartyna@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:39:00 +0100 Subject: [PATCH 35/35] Remove 'experimental' label from reuse tabs option Now it works well so it is no longer experimental. Maybe let's keep it "false" by default for some time. It will make it harder to spread among users but IMO it's safer. --- .../Flow.Launcher.Plugin.BrowserBookmark/Languages/en.xaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/en.xaml b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/en.xaml index 0cd3bdbf113..4bb16f35deb 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/en.xaml +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Languages/en.xaml @@ -31,6 +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 (experimental) + Reuse existing tabs - \ No newline at end of file +