Compare commits

...

42 commits

Author SHA1 Message Date
Andrzej Martyna
d497b87a8b
Merge a31f9d5064 into 12c4b4d711 2026-03-08 08:34:26 -05:00
Jeremy Wu
12c4b4d711
Merge pull request #4313 from Flow-Launcher/InitProgressbarAnimation_Null
Some checks are pending
Build / build (push) Waiting to run
Move progress bar animation init to Loaded event to fix possible null exception of ProgressBar
2026-03-08 21:30:02 +11:00
Jack Ye
1bdc1164f8
Merge pull request #4322 from Flow-Launcher/FixSelectionBrush
Use DynamicResource for TextBox SelectionBrush
2026-03-08 17:56:16 +08:00
Jack251970
71944ab6cd Use DynamicResource for TextBox SelectionBrush
Changed SelectionBrush in TextBox style from StaticResource to DynamicResource for SystemAccentColorLight1Brush. This enables automatic updates to the selection color when the resource changes at runtime, improving theme responsiveness.
2026-03-04 18:42:19 +08:00
Jack251970
a2f224225e Move progress bar animation init to Loaded event
InitProgressbarAnimation is now triggered by the progress bar's Loaded event instead of during main window initialization. This ensures the animation starts only after the progress bar is fully loaded, improving reliability and preventing potential UI timing issues.
2026-03-02 18:20:09 +08:00
Jack Ye
a31f9d5064
Merge branch 'dev' into feat/reuse-tabs-in-bookmarks-plugin 2026-02-26 21:01:41 +08:00
Andrzej Martyna
84ef5aa9b6
Remove 'experimental' label from reuse tabs option
Now it works well so it is no longer experimental.  
Maybe let's keep it "false" by default for some time.  
It will make it harder to spread among users but IMO it's safer.
2026-01-05 13:39:00 +01:00
Andrzej Martyna
37c733789e fixed: fixed KeyNotFoundException and improvement 2026-01-03 10:29:38 +01:00
Andrzej Martyna
c75cb1acec Resolved: a few PR issues reported by coderabbitai 2026-01-02 20:00:04 +01:00
Andrzej Martyna
f9018368ab improve _structureInvalidations handling 2026-01-02 19:55:16 +01:00
Andrzej Martyna
a30180c8b4 doc: README.md updated as known issue was resolved 2026-01-02 19:46:14 +01:00
Andrzej Martyna
680de613b8 Significant improvements in handling tabs in multi-browsers, multi-windows circumstances 2026-01-02 19:24:45 +01:00
Andrzej Martyna
6c5695b94f Unify logging messages 2026-01-02 19:24:32 +01:00
Jack251970
9fadec36c7 Remove null check from _tabsTracker.Dispose() call
The null-conditional operator was removed from _tabsTracker.Dispose()
in the Dispose() method, assuming _tabsTracker is always non-null
when Dispose() is called.
2025-12-30 16:25:10 +08:00
Jack251970
42a91fb3a0 Refactor tab tracking for thread safety and nullability
Replaced object locks with a custom Lock class for clearer thread synchronization in TabsCache and TabsTracker. Enabled nullable reference types in TabsTracker and updated method signatures and variables to use nullable types where appropriate. Modernized collection initializations and refactored methods for improved readability. Cleaned up using directives. These changes enhance thread safety, code clarity, and nullability handling in browser tab management.
2025-12-30 16:23:43 +08:00
Andrzej Martyna
36b7799f2e small tweaks 2025-12-30 09:12:08 +01:00
Andrzej Martyna
a472725209 Resolved: Thread-safety concern: _browserWindowsTracked modified without synchronization. 2025-12-30 09:12:08 +01:00
Andrzej Martyna
cdb2d9e6dc Take Thread.Sleep out of OnFocusChanged 2025-12-30 09:12:07 +01:00
Jack Ye
75a03d0320
Fix typos
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-12-30 16:00:55 +08:00
Andrzej Martyna
736e3764e1 Resolved: several issues reported in PR 2025-12-29 23:53:15 +01:00
Andrzej Martyna
9d27bcf589 feat: added cleaning cache on tabs and windows closing 2025-12-29 23:53:15 +01:00
Jack Ye
d1e5b70759
Fix typos
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-29 18:50:14 +08:00
Jack Ye
a6acdd4c1a
Fix typos
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-29 18:45:34 +08:00
Jack Ye
791ff47c2f
Fix typos
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-29 18:05:54 +08:00
Andrzej Martyna
0f7ef77512 typo 2025-12-29 09:59:09 +01:00
Andrzej Martyna
1bac95dde4
Resolved: Use the wrapper property for consistent binding. by coderabbitai
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-12-29 09:54:04 +01:00
Andrzej Martyna
1f45c02bf1 Added TODO.md for ideas to improve 2025-12-29 09:38:18 +01:00
Andrzej Martyna
876cbfd006 Resolved: a few issues reported by coderabbitai 2025-12-29 09:13:35 +01:00
Andrzej Martyna
b390645718 Resolved: Revert changes in Non-English language. They will be updated automatically in Crowdin 2025-12-29 08:42:14 +01:00
Andrzej Martyna
7890102384 Resolved: Incorrect ClassName - copy-paste error. 2025-12-29 00:46:22 +01:00
Andrzej Martyna
f8c17807fa Resolved: Populate empty PackageUrl and Copyright fields for Flow.Launcher.Localization. 2025-12-29 00:39:58 +01:00
Andrzej Martyna
951ef814fa Resolved: Populate or clarify empty Type and Commit fields for BrowserTabs. 2025-12-29 00:34:11 +01:00
Andrzej Martyna
944e3d74a3 Resolved: Minor grammar and style corrections. 2025-12-28 23:50:58 +01:00
Andrzej Martyna
f2dd4d97f0 Resolved: Fix grammar: use 'its' instead of 'it's'. 2025-12-28 23:49:28 +01:00
Andrzej Martyna
ecfe8c302d Resolved: TabsTracker is not disposed - potential resource leak. 2025-12-28 23:46:38 +01:00
Andrzej Martyna
382dd791ff Update THIRD_PARTY_NOTICES 2025-12-28 23:41:16 +01:00
Andrzej Martyna
9a2e3408db remove self-marketing 2025-12-28 18:28:51 +01:00
Andrzej Martyna
c8bf643c28
Merge branch 'dev' into feat/reuse-tabs-in-bookmarks-plugin 2025-12-28 18:08:34 +01:00
Andrzej Martyna
df900f5919 small tweaks 2025-12-28 18:06:32 +01:00
Andrzej Martyna
20f898ce2e Additional attribution 2025-12-28 17:47:06 +01:00
Andrzej Martyna
02248eb971 Add ReuseTabs to Settings 2025-12-28 17:34:10 +01:00
Andrzej Martyna
c4549d663d Implement reusing of existing tabs 2025-12-28 17:32:20 +01:00
16 changed files with 1005 additions and 13 deletions

View file

@ -342,6 +342,7 @@
Margin="12 0 12 0"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Loaded="ProgressBar_Loaded"
StrokeThickness="2"
Visibility="{Binding ProgressBarVisibility, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
X1="-100"

View file

@ -199,9 +199,6 @@ namespace Flow.Launcher
ThemeManager.Current.ApplicationTheme = ApplicationTheme.Dark;
}
// Initialize position
InitProgressbarAnimation();
// Force update position
UpdatePosition();
@ -354,6 +351,11 @@ namespace Flow.Launcher
}
}
private void ProgressBar_Loaded(object sender, RoutedEventArgs e)
{
InitProgressbarAnimation();
}
private async void OnClosing(object sender, CancelEventArgs e)
{
if (!CanClose)

View file

@ -52,7 +52,7 @@
<Setter Property="Padding" Value="0 0 50 0" />
<Setter Property="Foreground" Value="{DynamicResource Color05B}" />
<Setter Property="CaretBrush" Value="{DynamicResource Color05B}" />
<Setter Property="SelectionBrush" Value="{StaticResource SystemAccentColorLight1Brush}" />
<Setter Property="SelectionBrush" Value="{DynamicResource SystemAccentColorLight1Brush}" />
<Setter Property="FontSize" Value="16" />
<Setter Property="Height" Value="42" />
</Style>

View file

@ -107,6 +107,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.3" />

View file

@ -31,5 +31,6 @@
<system:String x:Key="flowlauncher_plugin_browserbookmark_guideMessage01">If you are not using Chrome, Firefox or Edge, or you are using their portable version, you need to add bookmarks data directory and select correct browser engine to make this plugin work.</system:String>
<system:String x:Key="flowlauncher_plugin_browserbookmark_guideMessage02">For example: Brave's engine is Chromium; and its default bookmarks data location is: "%LOCALAPPDATA%\BraveSoftware\Brave-Browser\UserData". For Firefox engine, the bookmarks directory is the userdata folder contains the places.sqlite file.</system:String>
<system:String x:Key="flowlauncher_plugin_browserbookmark_enable_favicons">Load favicons (can be time consuming during startup)</system:String>
<system:String x:Key="flowlauncher_plugin_browserbookmark_reuse_tabs">Reuse existing tabs</system:String>
</ResourceDictionary>
</ResourceDictionary>

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 static readonly TabsReservationService _tabsReservationService = new();
public void Init(PluginInitContext context)
{
Context = context;
@ -58,6 +61,13 @@ public class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex
}
LoadBookmarksIfEnabled();
SetReuseTabs(_settings.ReuseTabs);
}
public static void SetReuseTabs(bool reuseTabs)
{
_tabsReservationService.EnableTracking(reuseTabs);
_settings.ReuseTabs = reuseTabs;
}
private static void LoadBookmarksIfEnabled()
@ -92,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 _cachedBookmarks
return _tabsReservationService.InjectExistingTabs(_settings, _cachedBookmarks
.Select(
c => new Result
{
@ -104,19 +114,18 @@ public class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex
Score = BookmarkLoader.MatchProgram(c, param).Score,
Action = _ =>
{
Context.API.OpenUrl(c.Url);
_tabsReservationService.OpenUrlAndTrack(_settings, c.Url);
return true;
},
ContextData = new BookmarkAttributes { Url = c.Url }
}
)
.Where(r => r.Score > 0)
.ToList();
.ToList());
}
else
{
return _cachedBookmarks
return _tabsReservationService.InjectExistingTabs(_settings, _cachedBookmarks
.Select(
c => new Result
{
@ -128,13 +137,13 @@ public class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex
Score = 5,
Action = _ =>
{
Context.API.OpenUrl(c.Url);
_tabsReservationService.OpenUrlAndTrack(_settings, c.Url);
return true;
},
ContextData = new BookmarkAttributes { Url = c.Url }
}
)
.ToList();
.ToList());
}
}
@ -261,6 +270,7 @@ public class Main : ISettingProvider, IPlugin, IReloadable, IPluginI18n, IContex
public void Dispose()
{
_tabsReservationService.Dispose();
DisposeFileWatchers();
}

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,104 @@
[
{
"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": "git",
"Url": "https://github.com/jjw24/BrowserTabs",
"Commit": "8d81f8f686e82ddceb3e1a8a49e698cec56b5e3d"
}
},
{
"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": "https://github.com/Flow-Launcher/Flow.Launcher.Localization",
"Copyright": "Flow-Launcher",
"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.1",
"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": "fad253f51b461736dfd3cd9c15977bb7493becef"
}
},
{
"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,32 @@
# 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.1 | 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).
# Additional credits
Initially there was a plan to integrate [Browser Bookmarks plugin](https://github.com/Flow-Launcher/Flow.Launcher/tree/dev/Plugins/Flow.Launcher.Plugin.BrowserBookmark) and [Browser Tabs plugin](https://github.com/Flow-Launcher/Flow.Launcher.Plugin.BrowserTabs) but it looks like inter-plugin communication or integration of plugins is not possible.
Finally Browser Tabs plugin wasn't used but **its code had great impact on this final solution**.
# 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
```
cd Plugins\Flow.Launcher.Plugin.BrowserBookmark
dotnet tool install --global dotnet-project-licenses
dotnet-project-licenses --input Flow.Launcher.Plugin.BrowserBookmark.csproj --json
```

View file

@ -0,0 +1,27 @@
# 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!).
# 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 expose 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).

View file

@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Windows.Automation;
using static Flow.Launcher.Plugin.BrowserBookmark.Main;
namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs;
/// <summary>
/// TabsCache keeps record of all known browser's tabs in a single web browser window.
/// </summary>
public class TabsCache
{
private static readonly string ClassName = nameof(TabsCache);
// 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)
{
public int Index { get; init; } = index;
public int Age { get; set; } = 0;
}
private static string TryName(AutomationElement element)
{
try
{
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 true;
}
return false;
}
public void Invalidate()
{
lock (_sync)
{
Valid = false;
}
}
public List<AutomationElement> GetTabs()
{
lock (_sync)
{
return [.. _indexToElement.Values];
}
}
public AutomationElement TryGetTab(int index)
{
lock (_sync)
{
Context.API.LogDebug(ClassName, $"TABS:Checking if tab {index} exists in the cache of {_elementToInfo.Count} size and indices between {_indexToElement.Keys.FirstOrDefault()} and {_indexToElement.Keys.LastOrDefault()}");
if (_indexToElement.TryGetValue(index, out var tab))
{
return tab;
}
return null;
}
}
public int UpdateTabs(int lastAssignedIndex, List<AutomationElement> actualTabs, out List<AutomationElement> removedTabs)
{
lock (_sync)
{
Context.API.LogDebug(ClassName, $"TABS:Start comparing {actualTabs.Count} actual tabs to {_elementToInfo.Count} tabs in the cache; new tabs will start from {lastAssignedIndex+1}");
removedTabs = [];
var tabsToRemove = _elementToInfo.Where(t => !actualTabs.Contains(t.Key)).Select(t => t.Key).ToList();
var tabsToAdd = actualTabs.Where(t => !_elementToInfo.ContainsKey(t)).ToList();
var tabsToRevive = _elementToInfo.Where(t => actualTabs.Contains(t.Key) && t.Value.Age > 0).ToList();
foreach (var tabToRemove in tabsToRemove)
{
if (_elementToInfo.TryGetValue(tabToRemove, out var info))
{
if (Destroyed(tabToRemove) || info.Age >= _removeAtAge)
{
Context.API.LogDebug(ClassName, $"TABS:Removing {TryName(tabToRemove)} from cache");
_elementToInfo.Remove(tabToRemove);
_indexToElement.Remove(info.Index);
removedTabs.Add(tabToRemove);
}
else
{
Context.API.LogDebug(ClassName, $"TABS:Aging {TryName(tabToRemove)} in cache (got age {info.Age + 1}, will be removed at age {_removeAtAge} or on ElementNotAvailableException)");
_elementToInfo[tabToRemove].Age++;
}
}
}
foreach (var tabToAdd in tabsToAdd)
{
Context.API.LogDebug(ClassName, $"TABS:Adding {TryName(tabToAdd)} to cache");
var newIndex = ++lastAssignedIndex;
_elementToInfo[tabToAdd] = new Info(newIndex);
_indexToElement[newIndex] = tabToAdd;
}
foreach (var tabToRevive in tabsToRevive)
{
Context.API.LogDebug(ClassName, $"TABS:Reset age of {TryName(tabToRevive.Key)} as it appeared again");
_elementToInfo[tabToRevive.Key].Age = 0;
}
Valid = true;
return lastAssignedIndex;
}
}
}

View file

@ -0,0 +1,89 @@
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Threading;
using Flow.Launcher.Plugin.BrowserBookmark.Tabs;
using static Flow.Launcher.Plugin.BrowserBookmark.Main;
/// <summary>
/// TabsEventsDispatcher handles events in a separate thread.
/// This is to make handlers fast by not to blocking them.
/// </summary>
public sealed class TabsEventsDispatcher : IDisposable
{
private static readonly string ClassName = nameof(TabsEventsDispatcher);
private readonly BlockingCollection<Tuple<string, TabsReservationService.TokenForNewTab>> _queue = [];
private readonly Thread _worker;
private readonly CancellationTokenSource _cts = new();
private readonly TabsReservationService _reservationService;
private readonly TimeSpan _tabRetryTimeout = TimeSpan.FromSeconds(10);
private readonly TimeSpan _tabRetryInterval = TimeSpan.FromMilliseconds(250);
public TabsEventsDispatcher(TabsTracker tracker, TabsReservationService reservationService)
{
_reservationService = reservationService;
_worker = new Thread(WorkerLoop)
{
IsBackground = true,
Name = "TabsEventsDispatcher"
};
_worker.Start();
}
public void Enqueue(string url, TabsReservationService.TokenForNewTab token)
{
if (!_queue.IsAddingCompleted)
_queue.Add(Tuple.Create(url, token));
}
void WorkerLoop()
{
try
{
foreach (var tuple in _queue.GetConsumingEnumerable(_cts.Token))
{
HandleUrl(tuple);
}
}
catch (OperationCanceledException)
{
// shutting down
}
}
void HandleUrl(Tuple<string, TabsReservationService.TokenForNewTab> tuple)
{
var url = tuple.Item1;
var tokenForNewTab = tuple.Item2;
var sw = Stopwatch.StartNew();
var count = 1;
while (sw.Elapsed < _tabRetryTimeout && !_cts.Token.IsCancellationRequested)
{
var element = _reservationService.TryToResolveToken(tokenForNewTab, out var trackingInfo);
if (element != null)
{
_reservationService.RegisterTab(url, trackingInfo, element);
return;
}
Context.API.LogDebug(ClassName, $"TABS:No new tab found on try {count++}. Will sleep for {_tabRetryInterval.TotalMilliseconds} ms.");
Thread.Sleep(_tabRetryInterval);
}
Context.API.LogError(ClassName, "TABS:Timeout waiting for a new tab - assuming events are guaranteed and handled well this situation should not happen");
}
public void Dispose()
{
_cts.Cancel();
_queue.CompleteAdding();
_worker.Join();
_cts.Dispose();
_queue.Dispose();
}
}

View file

@ -0,0 +1,203 @@
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, info) = _tabsTracker.TryGetTab(expectedIndex);
trackingInfo = info;
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)
{
try
{
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();
}
catch (ElementNotAvailableException)
{
Context.API.LogDebug(ClassName, $"TABS:Tab became unavailable before registration for {url}");
}
}
}
public void UnregisterTabs(IEnumerable<AutomationElement> elements)
{
lock (_sync)
{
foreach (var element in elements)
{
if (_automationElementToUrl.TryGetValue(element, out var url))
{
_urlToBrowserTab.Remove(url, out _);
_automationElementToUrl.Remove(element, out _);
}
}
}
}
public void Dispose()
{
_tabsTracker.Dispose();
}
public void EnableTracking(bool reuseTabs)
{
_tabsTracker.EnableTracking(reuseTabs);
if (reuseTabs)
{
lock (_sync)
{
_urlToBrowserTab.Clear();
_automationElementToUrl.Clear();
}
}
}
}

View file

@ -0,0 +1,368 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Windows.Automation;
using static Flow.Launcher.Plugin.BrowserBookmark.Main;
using static Flow.Launcher.Plugin.BrowserBookmark.Tabs.TabsReservationService;
namespace Flow.Launcher.Plugin.BrowserBookmark.Tabs;
/// <summary>
/// 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);
// 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 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 readonly ConcurrentQueue<Tuple<string, TokenForNewTab>> _expectedUrls = [];
private TabsEventsDispatcher _eventsDispatcher;
private readonly Lock _syncInvalidations = new();
private HashSet<AutomationElement> _structureInvalidations = [];
private TabsReservationService _service;
public TabsTracker(TabsReservationService service)
{
_service = service;
}
public static string RuntimeIdToKey(AutomationElement elem)
{
try
{
return elem != null ? string.Join("-", elem.GetRuntimeId()) : null;
}
catch (ElementNotAvailableException)
{
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))
{
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;
}
}
/// <summary>
/// TrackingInfo keeps context of a single browser window
/// </summary>
public class TrackingInfo(AutomationElement rootElement, StructureChangedEventHandler structureChangedHandler, AutomationEventHandler windowCloseHandler, string processName, nint processMainWindowHandle)
{
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()
{
DisableTracking();
}
/// <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)
{
EnsureHavingAllBrowsersWindows();
EnsureHavingAllBrowsersTabs();
if (registerToken != null)
{
var token = registerToken(_lastAssignedIndex);
_expectedUrls.Enqueue(Tuple.Create(requestedUrl, token));
}
}
}
private void EnsureHavingAllBrowsersWindows()
{
// this is done once
// later on list is updated using WindowOpen / WindowClose events
if (!_windowsHandlerInitialized)
{
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 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)
{
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
{
return Process.GetProcessById(processId);
}
catch (Exception)
{
return null;
}
}
private void HandleProcessStart(AutomationElement element)
{
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 ...");
List<AutomationElement> elementsToInvalidate;
lock (_syncInvalidations)
{
elementsToInvalidate = _structureInvalidations.Where(_browserWindowsTracked.ContainsKey).ToList();
_structureInvalidations.Clear();
}
foreach (var element in elementsToInvalidate)
{
_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)
{
case StructureChangeType.ChildAdded:
case StructureChangeType.ChildrenBulkAdded:
case StructureChangeType.ChildrenInvalidated:
case StructureChangeType.ChildrenReordered:
case StructureChangeType.ChildRemoved:
case StructureChangeType.ChildrenBulkRemoved:
lock (_syncInvalidations)
{
_structureInvalidations.Add(window);
}
break;
}
switch (e.StructureChangeType)
{
case StructureChangeType.ChildAdded:
case StructureChangeType.ChildrenBulkAdded:
while (_expectedUrls.TryDequeue(out var tuple))
{
lock (_sync)
{
_eventsDispatcher ??= new(this, _service);
_eventsDispatcher.Enqueue(tuple.Item1, tuple.Item2);
}
}
break;
}
}
public (AutomationElement, TrackingInfo) TryGetTab(int expectedIndex)
{
lock (_sync)
{
foreach (var trackingInfo in _browserWindowsTracked.Values)
{
var tab = trackingInfo.Cache.TryGetTab(expectedIndex);
if (tab != null)
{
return (tab, trackingInfo);
}
}
return (null, null);
}
}
public void EnableTracking(bool enable)
{
lock (_sync)
{
if (_trackingEnabled == enable)
return;
_trackingEnabled = enable;
if (!_trackingEnabled)
{
DisableTracking();
}
}
}
private void DisableTracking()
{
lock (_syncInvalidations)
{
_structureInvalidations.Clear();
}
lock (_sync)
{
_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();
_expectedUrls.Clear();
_windowsHandlerInitialized = false;
}
}
}
}

View file

@ -13,6 +13,7 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel
Grid.Row="0"
@ -99,5 +100,12 @@
VerticalAlignment="Center"
Content="{DynamicResource flowlauncher_plugin_browserbookmark_enable_favicons}"
IsChecked="{Binding Settings.EnableFavicons}" />
<CheckBox
Grid.Row="3"
Margin="{StaticResource SettingPanelItemTopBottomMargin}"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Content="{DynamicResource flowlauncher_plugin_browserbookmark_reuse_tabs}"
IsChecked="{Binding ReuseTabs}" />
</Grid>
</UserControl>

View file

@ -48,6 +48,15 @@ public partial class SettingsControl
}
}
public bool ReuseTabs
{
get => Settings.ReuseTabs;
set
{
_ = Task.Run(() => Main.SetReuseTabs(value));
}
}
public bool OpenInNewBrowserWindow
{
get => Settings.OpenInNewBrowserWindow;