Significant improvements in handling tabs in multi-browsers, multi-windows circumstances

This commit is contained in:
Andrzej Martyna 2026-01-02 19:18:26 +01:00
parent 6c5695b94f
commit 680de613b8
9 changed files with 669 additions and 580 deletions

View file

@ -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();
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
internal class TabsCache
public class TabsCache
{
private static readonly string ClassName = nameof(TabsCache);
private readonly HashSet<string> _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<AutomationElement, Info> _elementToInfo = [];
private SortedDictionary<int, AutomationElement> _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<AutomationElement> 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<AutomationElement> 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<AutomationElement> 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<AutomationElement> actualTabs, out List<AutomationElement> 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;
}
}
}

View file

@ -1,81 +0,0 @@
using System;
using System.Windows.Automation;
using static Flow.Launcher.Plugin.BrowserBookmark.Main;
namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs;
/// <summary>
/// Just for debugging.
/// Call DumpElements whenever you need to analyze browser's internal structure.
/// </summary>
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);
}
}
}
}

View file

@ -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;
/// <summary>
/// TabsEventsDispatcher handles events in a separate thread.
/// This is to make handlers fast by not to blocking them.
/// </summary>
public sealed class TabsEventsDispatcher : IDisposable
{
private static readonly string ClassName = nameof(TabsEventsDispatcher);
private readonly BlockingCollection<Tuple<string, TabsReservationService.TokenForNewTab>> _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<string, TabsReservationService.TokenForNewTab> 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();
}
}

View file

@ -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;
/// <summary>
/// TabsFocusEventDispatcher handles TabsTracker.OnFocusChanged events in a separate thread.
/// This is to avoid sleeping in Automation.AddAutomationFocusChangedEventHandler.
/// </summary>
internal sealed class TabsFocusEventDispatcher : IDisposable
{
private static readonly string ClassName = nameof(TabsFocusEventDispatcher);
private readonly BlockingCollection<Tuple<string, AutomationElement, int>> _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<string, AutomationElement, int> 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();
}
}

View file

@ -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;
/// <summary>
/// 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).
/// </summary>
public class TabsReservationService : IDisposable
{
private static readonly string ClassName = nameof(TabsReservationService);
private readonly Lock _sync = new();
private readonly Dictionary<int, TokenForNewTabHandling> _tokens = [];
private readonly ConcurrentDictionary<string, BrowserTab> _urlToBrowserTab = [];
private readonly ConcurrentDictionary<AutomationElement, string> _automationElementToUrl = [];
private static TabsTracker _tabsTracker;
public TabsReservationService()
{
_tabsTracker = new TabsTracker(this);
}
/// <summary>
/// TokenForNewTab is kind of a promise that may be replaced for a real tab after it is finally created and available
/// </summary>
public class TokenForNewTab(int index)
{
public int Index { get; init; } = index;
}
/// <summary>
/// TokenForNewTabHandling is a utility class for proper handling of tokens for new tabs (TokenForNewTab)
/// </summary>
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<Result> InjectExistingTabs(Settings settings, List<Result> 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<AutomationElement> 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();
}
}
}
}

View file

@ -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
/// <summary>
/// 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)
/// </summary>
public class TabsTracker : IDisposable
{
private static readonly string ClassName = nameof(TabsTracker);
private static readonly HashSet<string> 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<string> firefoxProcessNames = new(["firefox"], StringComparer.OrdinalIgnoreCase);
private readonly TabsWalker _walker = new();
private readonly Queue<string> _expectedUrls = [];
private Dictionary<string, BrowserTab> UrlToBrowserTab { get; } = [];
private static readonly HashSet<string> 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<AutomationElement, TrackingInfo> _browserWindowsTracked = [];
private TabsFocusEventDispatcher? _focusHandlerDispatcher;
private AutomationFocusChangedEventHandler? _focusHandler;
private readonly HashSet<AutomationElement> _browserWindowsTracked = [];
private bool _initialized;
private readonly ConcurrentQueue<Tuple<string, TokenForNewTab>> _expectedUrls = [];
public void OpenUrlAndTrack(Settings settings, string url)
private TabsEventsDispatcher _eventsDispatcher;
private ConcurrentQueue<AutomationElement> _structureInvalidations = [];
private TabsReservationService _service;
public TabsTracker(TabsReservationService service)
{
if (settings.ReuseTabs)
{
ExpectUrl(url);
}
Context.API.OpenUrl(url);
_service = service;
}
public List<Result> InjectExistingTabs(Settings settings, List<Result> 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<AutomationElement> 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()
/// <summary>
/// TrackingInfo keeps context of a single browser window
/// </summary>
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<AutomationElement> 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)
/// <summary>
/// Makes snapshot of all browsers windows and all their tabs.
/// Optionally it may register a token.
/// </summary>
public void MakeSnapshot(Func<int, TabsReservationService.TokenForNewTab> 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;
}
}
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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<AutomationElement> 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}");
}
}
}

View file

@ -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));
}
}