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