mirror of
https://github.com/Flow-Launcher/Flow.Launcher.git
synced 2026-03-11 08:54:32 +00:00
Significant improvements in handling tabs in multi-browsers, multi-windows circumstances
This commit is contained in:
parent
6c5695b94f
commit
680de613b8
9 changed files with 669 additions and 580 deletions
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue