mirror of
https://github.com/Flow-Launcher/Flow.Launcher.git
synced 2026-03-11 08:54:32 +00:00
Implement reusing of existing tabs
This commit is contained in:
parent
9066913710
commit
c4549d663d
10 changed files with 578 additions and 9 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
```
|
||||
42
Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md
Normal file
42
Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/README.md
Normal 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)_
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
163
Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs
Normal file
163
Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsTracker.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
117
Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs
Normal file
117
Plugins/Flow.Launcher.Plugin.BrowserBookmark/Tabs/TabsWalker.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue