Implement reusing of existing tabs

This commit is contained in:
Andrzej Martyna 2025-12-28 17:32:20 +01:00
parent 9066913710
commit c4549d663d
10 changed files with 578 additions and 9 deletions

View file

@ -103,6 +103,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="BrowserTabs" Version="0.2.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Flow.Launcher.Localization" Version="0.0.6" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />

View file

@ -8,6 +8,7 @@ using System.Threading;
using System.Windows.Controls;
using Flow.Launcher.Plugin.BrowserBookmark.Commands;
using Flow.Launcher.Plugin.BrowserBookmark.Models;
using Flow.Launcher.Plugin.BrowserBookmark.Tabs;
using Flow.Launcher.Plugin.BrowserBookmark.Views;
using Flow.Launcher.Plugin.SharedCommands;
@ -26,7 +27,9 @@ public class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex
private static List<Bookmark> _cachedBookmarks = new();
private static bool _initialized = false;
private readonly TabsTracker tabsTracker = new();
public void Init(PluginInitContext context)
{
Context = context;
@ -58,6 +61,8 @@ public class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex
}
LoadBookmarksIfEnabled();
tabsTracker.Init();
}
private static void LoadBookmarksIfEnabled()
@ -88,11 +93,10 @@ public class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex
// Should top results be returned? (true if no search parameters have been passed)
var topResults = string.IsNullOrEmpty(param);
if (!topResults)
{
// Since we mixed chrome and firefox bookmarks, we should order them again
return _cachedBookmarks
return tabsTracker.InjectExistingTabs(_settings, _cachedBookmarks
.Select(
c => new Result
{
@ -104,19 +108,18 @@ public class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex
Score = BookmarkLoader.MatchProgram(c, param).Score,
Action = _ =>
{
Context.API.OpenUrl(c.Url);
tabsTracker.OpenUrlAndTrack(_settings, c.Url);
return true;
},
ContextData = new BookmarkAttributes { Url = c.Url }
}
)
.Where(r => r.Score > 0)
.ToList();
.ToList());
}
else
{
return _cachedBookmarks
return tabsTracker.InjectExistingTabs(_settings, _cachedBookmarks
.Select(
c => new Result
{
@ -128,13 +131,13 @@ public class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex
Score = 5,
Action = _ =>
{
Context.API.OpenUrl(c.Url);
tabsTracker.OpenUrlAndTrack(_settings, c.Url);
return true;
},
ContextData = new BookmarkAttributes { Url = c.Url }
}
)
.ToList();
.ToList());
}
}

View file

@ -13,6 +13,7 @@ public class Settings : BaseModel
public bool LoadChromeBookmark { get; set; } = true;
public bool LoadFirefoxBookmark { get; set; } = true;
public bool LoadEdgeBookmark { get; set; } = true;
public bool ReuseTabs { get; set; } = false;
public ObservableCollection<CustomBrowser> CustomChromiumBrowsers { get; set; } = new();
}

View file

@ -0,0 +1,92 @@
[
{
"PackageName": "BrowserTabs",
"PackageVersion": "0.2.0",
"PackageUrl": "https://github.com/jjw24/BrowserTabs",
"Copyright": "Jeremy Wu",
"Authors": [ "Jeremy Wu" ],
"Description": "Library for retrieving all opened browser tabs in Chromium-based and Firefox-based browsers",
"LicenseUrl": "https://licenses.nuget.org/Apache-2.0",
"LicenseType": "Apache-2.0",
"Repository": {
"Type": "",
"Url": "https://github.com/jjw24/BrowserTabs",
"Commit": ""
}
},
{
"PackageName": "CommunityToolkit.Mvvm",
"PackageVersion": "8.4.0",
"PackageUrl": "https://github.com/CommunityToolkit/dotnet",
"Copyright": "(c) .NET Foundation and Contributors. All rights reserved.",
"Authors": [ "Microsoft" ],
"Description": "This package includes a .NET MVVM library with helpers such as:\r\n - ObservableObject: a base class for objects implementing the INotifyPropertyChanged interface.\r\n - ObservableRecipient: a base class for observable objects with support for the IMessenger service.\r\n - ObservableValidator: a base class for objects implementing the INotifyDataErrorInfo interface.\r\n - RelayCommand: a simple delegate command implementing the ICommand interface.\r\n - AsyncRelayCommand: a delegate command supporting asynchronous operations and cancellation.\r\n - WeakReferenceMessenger: a messaging system to exchange messages through different loosely-coupled objects.\r\n - StrongReferenceMessenger: a high-performance messaging system that trades weak references for speed.\r\n - Ioc: a helper class to configure dependency injection service containers.",
"LicenseUrl": "https://licenses.nuget.org/MIT",
"LicenseType": "MIT",
"Repository": {
"Type": "git",
"Url": "https://github.com/CommunityToolkit/dotnet",
"Commit": "638b41dad30dffabb123a39aa38eabc7e3721371"
}
},
{
"PackageName": "Flow.Launcher.Localization",
"PackageVersion": "0.0.6",
"PackageUrl": "",
"Copyright": "",
"Authors": [ "Flow-Launcher" ],
"Description": "Localization toolkit for Flow Launcher and its plugins",
"LicenseUrl": "https://licenses.nuget.org/MIT",
"LicenseType": "MIT",
"Repository": {
"Type": "git",
"Url": "https://github.com/Flow-Launcher/Flow.Launcher.Localization",
"Commit": "456bdc7a986487d691a3ae8d36f8bce7b88b9bc7"
}
},
{
"PackageName": "Microsoft.Data.Sqlite",
"PackageVersion": "10.0.0",
"PackageUrl": "https://docs.microsoft.com/dotnet/standard/data/sqlite/",
"Copyright": "© Microsoft Corporation. All rights reserved.",
"Authors": [ "Microsoft" ],
"Description": "Microsoft.Data.Sqlite is a lightweight ADO.NET provider for SQLite.\r\n\r\nCommonly Used Types:\r\nMicrosoft.Data.Sqlite.SqliteCommand\r\nMicrosoft.Data.Sqlite.SqliteConnection\r\nMicrosoft.Data.Sqlite.SqliteConnectionStringBuilder\r\nMicrosoft.Data.Sqlite.SqliteDataReader\r\nMicrosoft.Data.Sqlite.SqliteException\r\nMicrosoft.Data.Sqlite.SqliteFactory\r\nMicrosoft.Data.Sqlite.SqliteParameter\r\nMicrosoft.Data.Sqlite.SqliteTransaction",
"LicenseUrl": "https://licenses.nuget.org/MIT",
"LicenseType": "MIT",
"Repository": {
"Type": "git",
"Url": "https://github.com/dotnet/dotnet",
"Commit": "b0f34d51fccc69fd334253924abd8d6853fad7aa"
}
},
{
"PackageName": "SkiaSharp",
"PackageVersion": "3.119.1",
"PackageUrl": "https://go.microsoft.com/fwlink/?linkid=868515",
"Copyright": "© Microsoft Corporation. All rights reserved.",
"Authors": [ "Microsoft" ],
"Description": "SkiaSharp is a cross-platform 2D graphics API for .NET platforms based on Google's Skia Graphics Library.\r\nIt provides a comprehensive 2D API that can be used across mobile, server and desktop models to render images.",
"LicenseUrl": "https://licenses.nuget.org/MIT",
"LicenseType": "MIT",
"Repository": {
"Type": "git",
"Url": "https://go.microsoft.com/fwlink/?linkid=868515",
"Commit": "cc78b5933d23e6383db5d246e70db915770d55d6"
}
},
{
"PackageName": "Svg.Skia",
"PackageVersion": "3.2.1",
"PackageUrl": "https://github.com/wieslawsoltes/Svg.Skia",
"Copyright": "Copyright © Wiesław Šoltés 2025",
"Authors": [ "Wiesław Šoltés" ],
"Description": "An SVG rendering library.",
"LicenseUrl": "https://licenses.nuget.org/MIT",
"LicenseType": "MIT",
"Repository": {
"Type": "git",
"Url": "https://github.com/wieslawsoltes/Svg.Skia",
"Commit": "0164d01769a8b577f6dcc678f25d4802a06ff8c0"
}
}
]

View file

@ -0,0 +1,26 @@
# Third-party notices
This project uses third-party NuGet packages.
| Reference | Version | License Type | License |
|---------------------------------------------------------------------------------------------|
| BrowserTabs | 0.2.0 | Apache-2.0 | https://licenses.nuget.org/Apache-2.0 |
| CommunityToolkit.Mvvm | 8.4.0 | MIT | https://licenses.nuget.org/MIT |
| Flow.Launcher.Localization | 0.0.6 | MIT | https://licenses.nuget.org/MIT |
| Microsoft.Data.Sqlite | 10.0.0 | MIT | https://licenses.nuget.org/MIT |
| SkiaSharp | 3.119.1 | MIT | https://licenses.nuget.org/MIT |
| Svg.Skia | 3.2.1 | MIT | https://licenses.nuget.org/MIT |
Detailed information (package id, version, license, repository URL) is available in [THIRD_PARTY_NOTICES.json](THIRD_PARTY_NOTICES.json).
# How to generate the list
1. Install `dotnet-project-licenses`
1. Use the tool as below
1. Copy markdown above
1. Rename `licenses.json` to `THIRD_PARTY_NOTICES.json` and format the json
```
dotnet tool install --global dotnet-project-licenses
dotnet-project-licenses --input Flow.Launcher.Plugin.BrowserBookmark.csproj --json
```

View file

@ -0,0 +1,42 @@
# Context
Existing plugins focus on their areas of operation - e.g. Browser Bookmarks on bookmarks only, Browser Tabs on tabs only.
Why not join these two worlds to create synergy?
Especially if one works with **tens or hundreds of bookmarks and open tabs** (I do and I constantly struggle finding the correct tab. It is underestimated mental cost of finding, clicking several times, etc.).
That's where "Reuse tabs" setting in Browser Bookmarks plugin makes sense.
I believe it is in line with why Flow Launcher was created in the first place.
I strongly believe in a higher level concept of **"just take me to THIS place - as fast as possible, as easy as possible"**.
Thus making bridges between plugins may sometimes produce huge value! BTW wouldn't it be nice to allow inter-plugin communication to create this kind of "bridges" more easily?
# How it works
The core is Browser Bookmarks plugin, unchanged by default.
You may enable "Reuse tabs" in the plugin settings.
Then, whenever one opens a bookmark, it also registers a new tab in its cache.
Next, each time the bookmark is triggered again, it just switches to the existing tab instead of launching a new one.
**It takes milliseconds instead of long seconds** (or sometimes close to half a minute in corporate environments where all is slow even if you have a high end laptop - you won't believe it until you live it!).
# Known issues
The extension bases on RuntimeId of AutomationElement from Microsoft UI Automation.
This is a weak spot as browsers are used to regenerate internal structures and even reuse RuntimeId.
It sometimes means that a bookmark activates a wrong tab.
Still **"just take me to THIS place in milliseconds** works almost all of the time so it bring so much value that it is worthwhile to accepts the fact it fails sometimes.
The quickest workaround is:
- close the wrong tab
- rerun opening the bookmark which will create a new tab this time
# Alternatives
Reading URLs of existing tabs was tried. It would make mapping of bookmarks to tabs more reliable.
However due to security reasons it has several limitations:
- different browsers exposes internals differently
- it is not easily accessible (e.g. you cannot make Chrome expose internal details on a dev TCP port from default profile so user would have to take care about special settings).
_"Reuse tabs" settings created initially by [Andrzej Martyna](https://github.com/andrzejmartyna)_

View file

@ -0,0 +1,51 @@
using System.Collections.Generic;
using System.Windows.Automation;
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.
/// </summary>
internal class TabsCache
{
private readonly HashSet<string> _knownTabs = new();
private readonly object sync = new();
private static string RuntimeIdToKey(AutomationElement elem) => elem != null ? string.Join("-", elem.GetRuntimeId()) : "NULL";
public bool Empty()
{
lock (sync)
{
return _knownTabs.Count == 0;
}
}
public void Add(AutomationElement tab)
{
lock (sync)
{
_knownTabs.Add(RuntimeIdToKey(tab));
}
}
public void Add(IEnumerable<AutomationElement> tabs)
{
lock (sync)
{
foreach (var tab in tabs)
{
_knownTabs.Add(RuntimeIdToKey(tab));
}
}
}
public bool Contains(AutomationElement tab)
{
lock (sync)
{
return _knownTabs.Contains(RuntimeIdToKey(tab));
}
}
}

View file

@ -0,0 +1,73 @@
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 DumpElements(AutomationElement parent, string classNameOnly = null, string controlTypeOnly = null, int indent = 0)
{
AutomationElementCollection children;
try
{
children = parent.FindAll(TreeScope.Children, Condition.TrueCondition);
}
catch (ElementNotAvailableException ex)
{
Context.API.LogDebug(ClassName, $"Parent not available: {ex.Message}");
return;
}
foreach (AutomationElement child in children)
{
try
{
var ct = child.Current.ControlType;
var type = ct?.ProgrammaticName?.Replace("ControlType.", "");
var name = child.Current.Name;
var className = child.Current.ClassName;
var isOffscreen = child.Current.IsOffscreen;
var isEnabled = child.Current.IsEnabled;
var rect = child.Current.BoundingRectangle;
var dump = true;
if (!string.IsNullOrEmpty(classNameOnly) && className != classNameOnly)
dump = false;
if (!string.IsNullOrEmpty(controlTypeOnly) && type != controlTypeOnly)
dump = false;
if (dump)
{
Context.API.LogDebug(
ClassName,
$"{new string(' ', indent)}" +
$"Type='{type}', " +
$"ClassName='{className}', " +
$"Name='{name}', " +
$"IsOffscreen={isOffscreen}, " +
$"IsEnabled={isEnabled}, " +
$"BoundingRectangle={rect}"
);
}
DumpElements(child, classNameOnly, controlTypeOnly, indent + 2);
}
catch (ElementNotAvailableException ex)
{
Context.API.LogDebug(ClassName, $"Child not available: {ex.Message}");
}
catch (Exception ex)
{
Context.API.LogException(ClassName, $"Unexpected error", ex);
}
}
}
}

View file

@ -0,0 +1,163 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Windows.Automation;
using BrowserTabs;
using Flow.Launcher.Plugin.BrowserBookmark.Models;
using static Flow.Launcher.Plugin.BrowserBookmark.Main;
namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs;
/// <summary>
/// TabsTracker maps initial URLs into existing browser's tabs.
/// The sequence of events:
/// 1. OpenUrlAndTrack - before lauching an URL it is remembered for later mapping to a browser's tab
/// 2. OnFocusChanged - whenever a browser's window gets focused a new tab discovery is started and result is put into the UrlToBrowserTab map
/// 3. InjectExistingTabs - iterates over BrowserBookmark's query result and replaces OpenUrl with ActivateTab for known, existing tabs
/// </summary>
public class TabsTracker : IDisposable
{
private static readonly string ClassName = nameof(TabsTracker);
private static readonly HashSet<string> chromiumProcessNames = new HashSet<string>(["msedge", "chrome", "brave", "vivaldi", "opera", "chromium"], StringComparer.OrdinalIgnoreCase);
private static readonly HashSet<string> firefoxProcessNames = new HashSet<string>(["firefox"], StringComparer.OrdinalIgnoreCase);
private readonly TabsWalker _walker = new();
private string? _expectedUrl;
private Dictionary<string, BrowserTab> UrlToBrowserTab { get; } = [];
private readonly object _sync = new();
private AutomationFocusChangedEventHandler? _focusHandler;
private bool _initialized;
public void OpenUrlAndTrack(Settings settings, string url)
{
if (settings.ReuseTabs)
{
Context.API.LogDebug(ClassName, $"Opening... {url}");
ExpectUrl(url);
}
Context.API.OpenUrl(url);
}
public List<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))
{
Context.API.LogDebug(ClassName, $"Mapped {bookmarkUrl}");
r.ContextData = existingTab;
r.Action = c =>
{
if (!existingTab.ActivateTab())
{
Context.API.LogError(ClassName, "Failed to activate a tab");
Remove(bookmarkUrl);
OpenUrlAndTrack(settings, bookmarkUrl);
}
return true;
};
}
}
return results;
}
public void Init()
{
if (_initialized)
return;
_focusHandler = OnFocusChanged;
Automation.AddAutomationFocusChangedEventHandler(_focusHandler);
_initialized = true;
}
public void Dispose()
{
if (_focusHandler != null)
Automation.RemoveAutomationFocusChangedEventHandler(_focusHandler);
}
public void ExpectUrl(string url)
{
lock (_sync)
{
if (_expectedUrl != null)
{
Context.API.LogError(ClassName, $"Opening {url} while older is still not resolved ({_expectedUrl}). Forgetting the older.");
}
_expectedUrl = url;
}
}
private void Remove(string url)
{
lock (_sync)
{
UrlToBrowserTab.Remove(url);
}
}
private void OnFocusChanged(object sender, AutomationFocusChangedEventArgs e)
{
string? urlToBind;
lock (_sync)
{
urlToBind = _expectedUrl;
}
if (urlToBind is null)
return;
try
{
Context.API.LogDebug(ClassName, $"Searching for... {urlToBind}");
if (sender is not AutomationElement element)
return;
int pid = element.Current.ProcessId;
Process? process = null;
try { process = Process.GetProcessById(pid); }
catch { /* could disappear */ }
if (process is null)
return;
var chromium = chromiumProcessNames.Contains(process.ProcessName);
var firefox = firefoxProcessNames.Contains(process.ProcessName);
if (!chromium && !firefox)
return; // not a browser
Context.API.LogDebug(ClassName, $"The active browser is {process.ProcessName}");
var rootElement = AutomationElement.FromHandle(process.MainWindowHandle);
if (rootElement == null)
return;
Context.API.LogDebug(ClassName, $"The root element is {rootElement.Current.Name}");
var currentTab = _walker.GetCurrentTabFromWindow(rootElement, process, CancellationToken.None);
if (currentTab != null)
{
lock (_sync)
{
Context.API.LogDebug(ClassName, $"Registering {urlToBind} as tab: {currentTab.Title}");
UrlToBrowserTab[urlToBind] = currentTab;
_expectedUrl = null;
// required to take the tab into account by Flow Launcher main UI search window
Context.API.ReQuery();
}
}
}
catch (Exception ex)
{
Context.API.LogException(ClassName, "Exception", ex);
}
}
}

View file

@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Windows.Automation;
using BrowserTabs;
using static Flow.Launcher.Plugin.BrowserBookmark.Main;
namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs;
/// <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(TabsTracker);
private readonly TimeSpan _tabRetryTimeout = TimeSpan.FromSeconds(4);
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;
}
// on Chrome, there are kind of technical tabs that should be ignored
var className = tab.Current.ClassName;
if (className.Contains("bolt-tab", StringComparison.OrdinalIgnoreCase))
{
Context.API.LogDebug(ClassName, $"Skipping name='{name}', className='{className}'");
continue;
}
yield return tab;
}
}
private static BrowserTab InitiateTab(Process process, AutomationElement tab) => new()
{
Title = tab.Current.Name,
BrowserName = process.ProcessName,
Hwnd = process.MainWindowHandle,
AutomationElement = tab
};
public BrowserTab GetCurrentTabFromWindow(AutomationElement mainWindow, Process process, CancellationToken cancellationToken)
{
try
{
var sw = Stopwatch.StartNew();
var count = 1;
while (sw.Elapsed < _tabRetryTimeout && !cancellationToken.IsCancellationRequested)
{
Context.API.LogDebug(ClassName, $"Start searching for a new tab... Try no {count++}");
var tabs = FindAllValidTabs(mainWindow).ToList();
if (tabs.Count == 0)
{
Context.API.LogDebug(ClassName, "No valid tabs found");
Thread.Sleep(_tabRetryInterval);
continue;
}
if (_cache.Empty())
{
Context.API.LogDebug(ClassName, "First time filling the cache");
_cache.Add(tabs);
// Let's take the last one and assume this is the one that was created recently
// This is the best known approach as of today
// There might be some browsers' settings that change this behavior but weren't tested nor considered yet
//TODO: research browsers' settings and check if it may break current assumption of just taking the last tab
return InitiateTab(process, tabs.Last());
}
Context.API.LogDebug(ClassName, $"Found tabs: {tabs.Count}");
//TabsDebug.DumpElements(mainWindow, null, "Tab");
// searching from the end and looking for a tab not in the cache
for (var i = tabs.Count - 1; i >= 0; i--)
{
var tab = tabs[i];
if (!_cache.Contains(tab))
{
Context.API.LogDebug(ClassName, $"FOUND NEW TAB: name={tab.Current.Name}, className={tab.Current.ClassName}");
_cache.Add(tab);
return InitiateTab(process, tab);
}
}
Context.API.LogDebug(ClassName, "No new tab found");
Thread.Sleep(_tabRetryInterval);
}
Context.API.LogDebug(ClassName, "Timeout waiting for new tab");
}
catch (ElementNotAvailableException ex)
{
Context.API.LogException(ClassName, "Element not available", ex);
}
catch (Exception ex)
{
Context.API.LogException(ClassName, "Error getting current tab from window", ex);
}
return null;
}
}