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 ba547c86a..76f97240f 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 07ce510fb..413fc5e2a 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs @@ -8,6 +8,7 @@ using System.Threading; 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 class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex } LoadBookmarksIfEnabled(); + + tabsTracker.Init(); } private static void LoadBookmarksIfEnabled() @@ -88,11 +93,10 @@ public class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex // 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 class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex 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 class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex 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 a0041e0d6..b05cc82cd 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 000000000..d51a8423d --- /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 000000000..bd0568614 --- /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 000000000..084261766 --- /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 000000000..205129d21 --- /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 000000000..25809a5c6 --- /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 000000000..319f74b4c --- /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 000000000..570b1102a --- /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 564714173..0cd3bdbf1 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 b63c2ffc5..197d913a3 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 0767ee980..34891762d 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 1ee6b5c45..5a10cca37 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 partial class SettingsControl } } + 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 bd0568614..aeb9b8faf 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 413fc5e2a..5b9e71ff8 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs @@ -93,6 +93,7 @@ public class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex // 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 084261766..e4d011e0a 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 e4d011e0a..5eeefda1e 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 d51a8423d..8b63604dc 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 aeb9b8faf..c71aa1bd9 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 5b9e71ff8..6d098bc4b 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 class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex LoadBookmarksIfEnabled(); - tabsTracker.Init(); + _tabsTracker.Init(); } private static void LoadBookmarksIfEnabled() @@ -97,7 +97,7 @@ public class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex 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 class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex 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 class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex } else { - return tabsTracker.InjectExistingTabs(_settings, _cachedBookmarks + return _tabsTracker.InjectExistingTabs(_settings, _cachedBookmarks .Select( c => new Result { @@ -132,7 +132,7 @@ public class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex 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 @@ public class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex 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 c71aa1bd9..c1d33e177 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 5eeefda1e..93582259a 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 8b63604dc..7ba344d0e 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 7ba344d0e..1cd0ce2cd 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 570b1102a..d4393d934 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 197d913a3..b63c2ffc5 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 | 74 +++++++++++-------- 2 files changed, 68 insertions(+), 37 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs index 205129d21..6f9d10e88 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 @@ internal class TabsCache { 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 @@ internal class TabsCache { 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 319f74b4c..ba3a38abd 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs @@ -48,7 +48,8 @@ public class TabsTracker : IDisposable 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 class TabsTracker : IDisposable } } + 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 @@ public class TabsTracker : IDisposable 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) + try { - lock (_sync) - { - Context.API.LogDebug(ClassName, $"Registering {urlToBind} as tab: {currentTab.Title}"); - UrlToBrowserTab[urlToBind] = currentTab; - _expectedUrl = null; + 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 - // required to take the tab into account by Flow Launcher main UI search window - Context.API.ReQuery(); + 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 (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 000000000..554483a50 --- /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 d4393d934..04b780be4 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs @@ -79,7 +79,6 @@ internal class TabsWalker // 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 34891762d..5ebc98d91 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 554483a50..439b78bc2 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 93582259a..cb316705a 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 cb316705a..fa41cf10e 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 ba3a38abd..36ccdd863 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 439b78bc2..000000000 --- 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 6f9d10e88..331ba2bae 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 @@ internal class TabsCache public bool Empty() { - lock (sync) + lock (_sync) { return _knownTabs.Count == 0; } @@ -33,16 +42,16 @@ internal class TabsCache 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 @@ internal class TabsCache public bool Contains(AutomationElement tab) { - lock (sync) + lock (_sync) { var key = RuntimeIdToKey(tab); if (key != null) @@ -63,4 +72,25 @@ internal class TabsCache } 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 36ccdd863..5d849c2e5 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 class TabsTracker : IDisposable 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 @@ public class TabsTracker : IDisposable // 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 @@ public class TabsTracker : IDisposable 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 04b780be4..e264891f2 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs @@ -79,6 +79,7 @@ internal class TabsWalker // 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 @@ internal class TabsWalker 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 @@ internal class TabsWalker } 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 | 72 +++++++++---------- .../Tabs/TabsWalker.cs | 5 +- .../Views/SettingsControl.xaml.cs | 2 +- 4 files changed, 38 insertions(+), 47 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs index 331ba2bae..3bd8de31c 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 5d849c2e5..bc5088885 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs @@ -83,16 +83,17 @@ public class TabsTracker : IDisposable 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 @@ public class TabsTracker : IDisposable 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 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(); - } - - Automation.AddStructureChangedEventHandler(rootElement, TreeScope.Subtree, OnStructureChanged); - Automation.AddAutomationEventHandler(WindowPattern.WindowClosedEvent, rootElement, TreeScope.Subtree, OnWindowClosed); - _browserWindowsTracked.Add(rootElement); - } - } - catch (ArgumentException) - { - // No such process / not running + 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(); + } + + 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 e264891f2..17885485e 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.Collections.Generic; 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 @@ internal class TabsWalker if (tabs.Count == 0) { Context.API.LogDebug(ClassName, "No valid tabs found"); - Thread.Sleep(_tabRetryInterval); + Task.Delay(_tabRetryInterval, cancellationToken); continue; } @@ -99,7 +100,7 @@ internal class TabsWalker } 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 5a10cca37..0fca79409 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 partial class SettingsControl 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 17885485e..78f285538 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs @@ -118,7 +118,7 @@ internal class TabsWalker 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 3bd8de31c..47446e5ec 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs @@ -57,13 +57,17 @@ internal class TabsCache } 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 000000000..1859d0a15 --- /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 bc5088885..c0e3fd97b 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 class TabsTracker : IDisposable if (_initialized) return; + _focusHandlerDispatcher = new(_walker, this); _focusHandler = OnFocusChanged; Automation.AddAutomationFocusChangedEventHandler(_focusHandler); _initialized = true; @@ -94,17 +95,14 @@ public class TabsTracker : IDisposable _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 @@ public class TabsTracker : IDisposable 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 @@ public class TabsTracker : IDisposable 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; + if (_expectedUrls.Count == 0) + return; - // 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); + 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 @@ public class TabsTracker : IDisposable 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 @@ public class TabsTracker : IDisposable _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 78f285538..c0e32a3f2 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs @@ -68,7 +68,7 @@ internal class TabsWalker if (tabs.Count == 0) { Context.API.LogDebug(ClassName, "No valid tabs found"); - Task.Delay(_tabRetryInterval, cancellationToken); + Thread.Sleep(_tabRetryInterval); continue; } @@ -100,7 +100,7 @@ internal class TabsWalker } 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 class TabsWalker 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 fa41cf10e..fb561d7ed 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 c0e3fd97b..588bc44c5 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs @@ -84,7 +84,12 @@ public class TabsTracker : IDisposable 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 @@ public class TabsTracker : IDisposable // 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 @@ public class TabsTracker : IDisposable 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 @@ public class TabsTracker : IDisposable 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 c0e32a3f2..f73297eec 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs @@ -33,7 +33,7 @@ internal class TabsWalker 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 1859d0a15..9b3117130 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsFocusEventDispatcher.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsFocusEventDispatcher.cs @@ -6,6 +6,10 @@ using System.Windows.Automation; 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 f73297eec..346b3b4f7 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 47446e5ec..3e2d73aa8 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 588bc44c5..300770737 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 @@ using static Flow.Launcher.Plugin.BrowserBookmark.Main; 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 class TabsTracker : IDisposable List windowsToUnsubscribe; lock (_sync) { - windowsToUnsubscribe = _browserWindowsTracked.ToList(); + windowsToUnsubscribe = [.. _browserWindowsTracked]; } foreach (var wnd in windowsToUnsubscribe) @@ -111,7 +113,7 @@ public class TabsTracker : IDisposable } } - private BrowserTab GetExistingTab(string url) + private BrowserTab? GetExistingTab(string url) { lock (_sync) { @@ -144,7 +146,7 @@ public class TabsTracker : IDisposable if (sender is not AutomationElement element) return; - int pid = 0; + var pid = 0; try { pid = element.Current.ProcessId; @@ -215,7 +217,7 @@ public class TabsTracker : IDisposable // break; case StructureChangeType.ChildRemoved: case StructureChangeType.ChildrenBulkRemoved: - AutomationElement foundWindow = null; + AutomationElement? foundWindow = null; lock (_sync) { foreach (var window in _browserWindowsTracked) @@ -245,7 +247,7 @@ public class TabsTracker : IDisposable 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 346b3b4f7..6b1060e78 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.Collections.Generic; 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 @@ internal class TabsWalker } } - 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 6d098bc4b..e8235764c 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Main.cs @@ -265,7 +265,7 @@ public class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex 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 3e2d73aa8..a4eba2799 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs @@ -48,7 +48,7 @@ internal class TabsCache 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 @@ internal class TabsCache 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 25809a5c6..30bdfa6a1 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 @@ internal class TabsDebug } 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 @@ internal class TabsDebug { Context.API.LogDebug( ClassName, - $"{new string(' ', indent)}" + + $"TABS:{new string(' ', indent)}" + $"Type='{type}', " + $"ClassName='{className}', " + $"Name='{name}', " + @@ -62,11 +70,11 @@ internal class TabsDebug } 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 9b3117130..78c82c922 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.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; @@ -70,11 +71,11 @@ internal sealed class TabsFocusEventDispatcher : IDisposable } 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 300770737..a8f67ba8d 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.Collections.Generic; 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 class TabsTracker : IDisposable { if (settings.ReuseTabs) { - Context.API.LogDebug(ClassName, $"Opening... {url}"); ExpectUrl(url); } Context.API.OpenUrl(url); @@ -55,14 +56,12 @@ public class TabsTracker : IDisposable 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 @@ public class TabsTracker : IDisposable 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 @@ public class TabsTracker : IDisposable 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 @@ public class TabsTracker : IDisposable // 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 @@ public class TabsTracker : IDisposable } 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 @@ public class TabsTracker : IDisposable { 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 6b1060e78..90a3a7510 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.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; @@ -36,7 +38,7 @@ internal class TabsWalker 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 @@ internal class TabsWalker 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 @@ internal class TabsWalker 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 @@ internal class TabsWalker 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 class TabsWalker { 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 | 172 ++++--- .../Tabs/TabsDebug.cs | 81 ---- .../Tabs/TabsEventsDispatcher.cs | 89 ++++ .../Tabs/TabsFocusEventDispatcher.cs | 90 ---- .../Tabs/TabsReservationService.cs | 196 ++++++++ .../Tabs/TabsTracker.cs | 457 ++++++++++-------- .../Tabs/TabsWalker.cs | 142 ------ .../Views/SettingsControl.xaml.cs | 3 +- 9 files changed, 669 insertions(+), 580 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 e8235764c..7831acbc7 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 class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex } 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 class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex 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 class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex 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 class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex } else { - return _tabsTracker.InjectExistingTabs(_settings, _cachedBookmarks + return _tabsReservationService.InjectExistingTabs(_settings, _cachedBookmarks .Select( c => new Result { @@ -132,7 +137,7 @@ public class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex 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 @@ public class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex 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 a4eba2799..52de5ed5d 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 @@ using static Flow.Launcher.Plugin.BrowserBookmark.Main; 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 = []; - private readonly Lock _sync = new(); - public static string RuntimeIdToKey(int[] runtimeId) + // UIA is unreliable. It may return zero elements or a subset of elements. + // Thus removal of an AutomationElement from cache SHOULD NOT be done immediately but rather after a few times the element no longer exists. + private static int _removeAtAge = 3; + + private readonly Lock _sync = new(); + private Dictionary _elementToInfo = []; + private SortedDictionary _indexToElement = []; + public bool Valid { get; private set; } = false; + + private class Info(int index) { - 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 (Exception e) + { + return e.GetType().ToString(); + } + } + + private static bool Destroyed(AutomationElement element) + { + try + { + var _ = element.Current.Name; } catch (ElementNotAvailableException) { - return null; - } - } - - public bool Empty() - { - lock (_sync) - { - return _knownTabs.Count == 0; - } - } - - public void Add(AutomationElement tab) - { - lock (_sync) - { - var key = RuntimeIdToKey(tab); - if (key != null) - { - Context.API.LogDebug(ClassName, $"TABS:{key}:Adding to cache: {tab.Current.Name}"); - _knownTabs.Add(key); - } - } - } - public void Add(IEnumerable tabs) - { - foreach (var tab in tabs) - { - Add(tab); - } - } - - public bool Contains(AutomationElement tab) - { - return Contains(RuntimeIdToKey(tab)); - } - - public bool Contains(string runtimeId) - { - lock (_sync) - { - if (runtimeId != null) - { - return _knownTabs.Contains(runtimeId); - } + return true; } return false; } - public void RemoveAllNonExistentTabs(AutomationElement rootElement, IEnumerable existingTabs) + public void Invalidate() { - 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)); - - foreach (var key in keysToRemove) + lock (_sync) { - Context.API.LogDebug(ClassName, $"TABS:{key}:Removing from cache"); - _knownTabs.Remove(key); + Valid = false; + } + } + + public List GetTabs() + { + lock (_sync) + { + return [.. _indexToElement.Values]; + } + } + + public AutomationElement TryGetTab(int index) + { + lock (_sync) + { + Context.API.LogDebug(ClassName, $"TABS:Checking if tab {index} exists in the cache of {_elementToInfo.Count} size and indices between {_indexToElement.Keys.FirstOrDefault()} and {_indexToElement.Keys.LastOrDefault()}"); + + if (_indexToElement.TryGetValue(index, out var tab)) + { + return tab; + } + return null; + } + } + + public int UpdateTabs(int lastAssignedIndex, List actualTabs, out List removedTabs) + { + lock (_sync) + { + Context.API.LogDebug(ClassName, $"TABS:Start comparing {actualTabs.Count} actual tabs to {_elementToInfo.Count} tabs in the cache; new tabs will start from {lastAssignedIndex+1}"); + + removedTabs = []; + + var tabsToRemove = _elementToInfo.Where(t => !actualTabs.Contains(t.Key)).Select(t => t.Key).ToList(); + var tabsToAdd = actualTabs.Where(t => !_elementToInfo.ContainsKey(t)).ToList(); + var tabsToRevive = _elementToInfo.Where(t => actualTabs.Contains(t.Key) && t.Value.Age > 0).ToList(); + + foreach (var tabToRemove in tabsToRemove) + { + if (_elementToInfo.TryGetValue(tabToRemove, out var info)) + { + if (Destroyed(tabToRemove) || info.Age >= _removeAtAge) + { + Context.API.LogDebug(ClassName, $"TABS:Removing {TryName(tabToRemove)} from cache"); + _elementToInfo.Remove(tabToRemove); + _indexToElement.Remove(info.Index); + removedTabs.Add(tabToRemove); + } + else + { + Context.API.LogDebug(ClassName, $"TABS:Aging {TryName(tabToRemove)} in cache (got age {info.Age + 1}, will be removed at age {_removeAtAge} or on ElementNotAvailableException)"); + _elementToInfo[tabToRemove].Age++; + } + } + } + + foreach (var tabToAdd in tabsToAdd) + { + Context.API.LogDebug(ClassName, $"TABS:Adding {TryName(tabToAdd)} to cache"); + var newIndex = ++lastAssignedIndex; + _elementToInfo[tabToAdd] = new Info(newIndex); + _indexToElement[newIndex] = tabToAdd; + } + + foreach (var tabtoRevive in tabsToRevive) + { + Context.API.LogDebug(ClassName, $"TABS:Reset age of {TryName(tabtoRevive.Key)} as it appeared again"); + _elementToInfo[tabtoRevive.Key].Age = 0; + } + + Valid = true; + return lastAssignedIndex; } } } diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsDebug.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsDebug.cs deleted file mode 100644 index 30bdfa6a1..000000000 --- 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 000000000..77197a26c --- /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 78c82c922..000000000 --- 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 000000000..7f3549e11 --- /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 a8f67ba8d..857ab355f 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 TabsFocusEventDispatcher? _focusHandlerDispatcher; - private AutomationFocusChangedEventHandler? _focusHandler; - private readonly HashSet _browserWindowsTracked = []; - private bool _initialized; + private readonly ConcurrentQueue> _expectedUrls = []; - public void OpenUrlAndTrack(Settings settings, string url) + private TabsEventsDispatcher _eventsDispatcher; + private ConcurrentQueue _structureInvalidations = []; + + private TabsReservationService _service; + + 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; - lock (_sync) - { - windowsToUnsubscribe = [.. _browserWindowsTracked]; - } - - foreach (var wnd in windowsToUnsubscribe) - { - UnsubscribeStructureChangedForWindow(wnd); - } - if (_focusHandler != null) - { - Automation.RemoveAutomationFocusChangedEventHandler(_focusHandler); - _focusHandler = null; - _initialized = false; - } - _focusHandlerDispatcher?.Dispose(); + DisableTracking(); } - public void ExpectUrl(string url) + /// + /// 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) { - _expectedUrls.Enqueue(url); - } - } - - private BrowserTab? GetExistingTab(string url) - { - lock (_sync) - { - if (UrlToBrowserTab.TryGetValue(url, out var existingTab)) + EnsureHavingAllBrowsersWindows(); + EnsureHavingAllBrowsersTabs(); + if (registerToken != null) { - return existingTab; + var token = registerToken(_lastAssignedIndex); + _expectedUrls.Enqueue(Tuple.Create(requestedUrl, token)); } } - return null; } - private void Remove(string url) + private void EnsureHavingAllBrowsersWindows() { - lock (_sync) + // this is done once + // later on list is updated using WindowOpen / WindowClose events + if (!_windowsHandlerInitialized) { - UrlToBrowserTab.Remove(url); + Context.API.LogDebug(ClassName, "TABS:EnsureHavingAllBrowsersWindows initializing ..."); + + var desktop = AutomationElement.RootElement; + Automation.AddAutomationEventHandler(WindowPattern.WindowOpenedEvent, desktop, TreeScope.Children, OnWindowOpen); + + var topLevelWindows = desktop.FindAll(TreeScope.Children, Condition.TrueCondition); + foreach (AutomationElement element in topLevelWindows) + { + HandleProcessStart(element); + } + + _windowsHandlerInitialized = true; } } - private void OnFocusChanged(object sender, AutomationFocusChangedEventArgs e) + 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) { - if (_expectedUrls.Count == 0) - return; - } + bool structureChangesTracked = _browserWindowsTracked.TryGetValue(element, out var trackingInfo); + if (structureChangesTracked && trackingInfo.WindowCloseHandler != null) + { + Automation.RemoveAutomationEventHandler(WindowPattern.WindowClosedEvent, element, trackingInfo.WindowCloseHandler); + } + HandleProcessExit(element); + } + } + + private static Process TryProcess(int processId) + { try { - if (sender is not AutomationElement element) - return; - - var pid = 0; - try - { - pid = element.Current.ProcessId; - } - catch (ElementNotAvailableException) - { - return; - } - - 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, $"TABS:The active browser is {process.ProcessName}"); - - var rootElement = AutomationElement.FromHandle(process.MainWindowHandle); - if (rootElement == null) - return; - - string? urlToBind; - lock (_sync) - { - if (_expectedUrls.Count == 0) - return; - - urlToBind = _expectedUrls.Dequeue(); - } - if (urlToBind is null) - return; - - 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); + return Process.GetProcessById(processId); } - catch (Exception ex) + catch (Exception) { - Context.API.LogException(ClassName, "TABS:Exception", ex); + return null; } } - private void OnStructureChanged(object sender, StructureChangedEventArgs e) + private void HandleProcessStart(AutomationElement element) { - //if (e.StructureChangeType == StructureChangeType.ChildAdded || e.StructureChangeType == StructureChangeType.ChildrenBulkAdded) - //{ - //} - var eventRuntimeId = TabsCache.RuntimeIdToKey(e.GetRuntimeId()); + var processId = element.Current.ProcessId; + using var process = TryProcess(processId); + if (process == null) + return; + + string processName = process.ProcessName.ToLowerInvariant(); + var chromium = chromiumProcessNames.Contains(processName); + var firefox = firefoxProcessNames.Contains(processName); + if (!chromium && !firefox) + return; + + Context.API.LogDebug(ClassName, $"TABS:Found a browser window for {processName}, PID={processId}"); + lock (_sync) + { + bool structureChangesTracked = _browserWindowsTracked.ContainsKey(element); + if (!structureChangesTracked) + { + SubscribeStructureChangedEventHandler(element, process); + } + } + } + + private void HandleProcessExit(AutomationElement element) + { + lock (_sync) + { + bool structureChangesTracked = _browserWindowsTracked.TryGetValue(element, out var trackingInfo); + if (structureChangesTracked) + { + UnsubscribeStructureChangedEventHandler(element, trackingInfo); + } + } + } + + private void SubscribeStructureChangedEventHandler(AutomationElement element, Process process) + { + void structureChangedHandler(object sender, StructureChangedEventArgs e) + { + OnStructureChanged(element, sender, e); + } + + Automation.AddStructureChangedEventHandler(element, TreeScope.Subtree, structureChangedHandler); + + void windowCloseHandler(object src, AutomationEventArgs e) + { + OnWindowClose(element, src, e); + } + + _browserWindowsTracked[element] = new TrackingInfo(element, structureChangedHandler, windowCloseHandler, process.ProcessName, process.MainWindowHandle); + + Automation.AddAutomationEventHandler(WindowPattern.WindowClosedEvent, element, TreeScope.Element, windowCloseHandler); + + Context.API.LogDebug(ClassName, $"TABS:Window {RuntimeIdToKey(element)} SUBSCRIBED for StructureChanged events"); + } + + private void UnsubscribeStructureChangedEventHandler(AutomationElement element, TrackingInfo trackingInfo) + { + Automation.RemoveStructureChangedEventHandler(element, trackingInfo.StructureChangedHandler); + _service.UnregisterTabs(trackingInfo.Cache.GetTabs()); + _browserWindowsTracked.Remove(element); + + Context.API.LogDebug(ClassName, $"TABS:Window {RuntimeIdToKey(element)} UNSUBSCRIBED from StructureChanged events"); + } + + private void EnsureHavingAllBrowsersTabs() + { + Context.API.LogDebug(ClassName, "TABS:EnsureHavingAllBrowsersTabs ..."); + + var unique = _structureInvalidations.ToArray().Distinct().Where(_browserWindowsTracked.ContainsKey); + foreach (var element in unique) + { + _browserWindowsTracked[element].Cache.Invalidate(); + } + + foreach (var pair in _browserWindowsTracked) + { + if (!pair.Value.Cache.Valid) + { + try + { + _lastAssignedIndex = pair.Value.Cache.UpdateTabs(_lastAssignedIndex, [.. FindAllValidTabs(pair.Key)], out var removedTabs); + _service.UnregisterTabs(removedTabs); + } + catch (ElementNotAvailableException) + { + Context.API.LogError(ClassName, "ElementNotAvailableException while updating tabs"); + } + } + } + } + + private void OnStructureChanged(AutomationElement window, object sender, StructureChangedEventArgs e) + { + //Context.API.LogDebug(ClassName, $"TABS:Received {e.StructureChangeType.ToString()} on {sender}"); switch (e.StructureChangeType) { - // 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); - } - - private void UnsubscribeStructureChangedForWindow(AutomationElement wnd) - { - 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); + foreach (var trackingInfo in _browserWindowsTracked.Values) + { + var tab = trackingInfo.Cache.TryGetTab(expectedIndex); + if (tab != null) + { + foundInTrackingInfo = trackingInfo; + return tab; + } + } + foundInTrackingInfo = null; + return null; } } - internal void RegisterTab(string url, AutomationElement rootElement, BrowserTab currentTab) + public void EnableTracking(bool enable) { lock (_sync) { - Context.API.LogDebug(ClassName, $"TABS:{TabsCache.RuntimeIdToKey(currentTab.AutomationElement)}:Registering {url} as tab: {currentTab.Title}"); - UrlToBrowserTab[url] = currentTab; + if (_trackingEnabled == enable) + return; - // required to take the tab into account by Flow Launcher main UI search window - Context.API.ReQuery(); + _trackingEnabled = enable; + if (!_trackingEnabled) + { + DisableTracking(); + } } + } - Automation.AddStructureChangedEventHandler(rootElement, TreeScope.Subtree, OnStructureChanged); - Automation.AddAutomationEventHandler(WindowPattern.WindowClosedEvent, rootElement, TreeScope.Subtree, OnWindowClosed); - + private void DisableTracking() + { lock (_sync) { - _browserWindowsTracked.Add(rootElement); + _eventsDispatcher?.Dispose(); + _eventsDispatcher = null; + + if (_windowsHandlerInitialized) + { + Automation.RemoveAutomationEventHandler(WindowPattern.WindowOpenedEvent, AutomationElement.RootElement, OnWindowOpen); + + foreach (var tracking in _browserWindowsTracked) + { + UnsubscribeStructureChangedEventHandler(tracking.Key, tracking.Value); + } + _browserWindowsTracked.Clear(); + _structureInvalidations.Clear(); + _expectedUrls.Clear(); + + _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 90a3a7510..000000000 --- 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 0fca79409..643c397c8 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 partial class SettingsControl 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 fb561d7ed..d467a8573 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 857ab355f..2c455446c 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 @@ public class TabsTracker : IDisposable { 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 @@ public class TabsTracker : IDisposable 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 class TabsTracker : IDisposable private void DisableTracking() { + lock (_syncInvalidations) + { + _structureInvalidations.Clear(); + } + lock (_sync) { _eventsDispatcher?.Dispose(); @@ -346,7 +361,6 @@ public class TabsTracker : IDisposable 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 | 29 ++++++++++++------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs index 52de5ed5d..432b62e54 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsCache.cs @@ -123,10 +123,10 @@ public class TabsCache _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 7f3549e11..d48a63d62 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsReservationService.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsReservationService.cs @@ -144,20 +144,27 @@ public class TabsReservationService : IDisposable { lock (_sync) { - var currentTab = new BrowserTab + try { - Title = tab.Current.Name, - BrowserName = trackingInfo.ProcessName, - Hwnd = trackingInfo.ProcessMainWindowHandle, - AutomationElement = tab - }; + 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; + 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(); + // 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 d48a63d62..5f125a862 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsReservationService.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsReservationService.cs @@ -122,8 +122,8 @@ public class TabsReservationService : IDisposable _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 2c455446c..18fdd0a52 100644 --- a/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs +++ b/Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs @@ -249,7 +249,7 @@ public class TabsTracker : IDisposable List elementsToInvalidate; lock (_syncInvalidations) { - elementsToInvalidate = [.. _structureInvalidations]; + elementsToInvalidate = _structureInvalidations.Where(_browserWindowsTracked.ContainsKey).ToList(); _structureInvalidations.Clear(); } foreach (var element in elementsToInvalidate) @@ -307,7 +307,7 @@ public class TabsTracker : IDisposable } } - public AutomationElement TryGetTab(int expectedIndex, out TrackingInfo foundInTrackingInfo) + public (AutomationElement, TrackingInfo) TryGetTab(int expectedIndex) { lock (_sync) { @@ -316,12 +316,10 @@ public class TabsTracker : IDisposable 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 0cd3bdbf1..4bb16f35d 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 +