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; + } +}