Eingehender Ruf: Firefox-Tab aktivieren und Fenster nativ nach vorn holen

Protokollversion 9: neue Plugin-Aktion activate und der Eintrag
IncomingCallTabTitle in websocket.json. Beginnt eine Leitung zu klingeln,
holt SwyxTray den ersten Firefox-Tab mit passendem Titel ueber das Plugin
nach vorn, ohne ihn neu zu laden.

Das Plugin allein scheitert dabei am Foreground-Lock von Windows: es kann
den Tab aktivieren, das Fenster bleibt aber hinter der gerade fokussierten
Anwendung. FirefoxWindow uebernimmt deshalb den nativen Teil - EnumWindows
auf MozillaWindowClass, minimierte Fenster wiederherstellen, dann
SetForegroundWindow mit AttachThreadInput, notfalls ALT-Tastendruck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 14:33:51 +02:00
co-authored by Claude Fable 5
parent 6d03d31a95
commit 748687ce1c
8 changed files with 391 additions and 29 deletions
+200
View File
@@ -0,0 +1,200 @@
using System.Runtime.InteropServices;
using System.Text;
namespace SwyxTray;
/// <summary>
/// Holt das Firefox-Fenster auf Betriebssystem-Ebene in den Vordergrund.
///
/// Das Firefox-Plugin aktiviert den Tab und setzt focused:true — gegen den
/// Foreground-Lock von Windows kommt es aus dem Browser heraus aber nicht an:
/// liegt der Eingabefokus gerade in einer anderen Anwendung, blinkt nur das
/// Taskleisten-Symbol. Eine native Anwendung darf mehr: mit AttachThreadInput
/// an den Thread des aktuellen Vordergrundfensters gilt SetForegroundWindow
/// als Aufruf "aus dem Vordergrund" und zieht das Fenster tatsaechlich hoch.
///
/// Bewusst DllImport statt LibraryImport — wie in <see cref="NativeMethods"/>
/// begruendet.
/// </summary>
internal static class FirefoxWindow
{
/// <summary>Klassenname aller Firefox-Hauptfenster.</summary>
private const string MozillaWindowClass = "MozillaWindowClass";
private const int SwRestore = 9;
private const byte VkMenu = 0x12;
private const uint KeyeventfKeyup = 0x2;
/// <summary>
/// Firefox braucht nach dem Aktivieren des Tabs einen Moment, bis der
/// Fenstertitel den Tab-Titel traegt — darum mehrere kurze Versuche.
/// </summary>
private const int FindAttempts = 3;
private static readonly TimeSpan FindRetryDelay = TimeSpan.FromMilliseconds(150);
/// <summary>
/// Sucht das Firefox-Fenster, dessen Titel <paramref name="tabTitle"/>
/// enthaelt (der aktive Tab bestimmt den Fenstertitel), und holt es in den
/// Vordergrund. Ohne Titel-Treffer nimmt der letzte Versuch das erste
/// sichtbare Firefox-Fenster. <c>false</c>, wenn kein Fenster gefunden
/// wurde oder Windows den Wechsel endgueltig verweigert hat.
/// </summary>
public static bool TryBringToFront(string tabTitle)
{
var hwnd = IntPtr.Zero;
for (var attempt = 0; attempt < FindAttempts && hwnd == IntPtr.Zero; attempt++)
{
if (attempt > 0)
{
Thread.Sleep(FindRetryDelay);
}
hwnd = FindWindowByTitle(tabTitle);
}
if (hwnd == IntPtr.Zero)
{
hwnd = FindWindowByTitle(null);
}
return hwnd != IntPtr.Zero && ForceForeground(hwnd);
}
/// <summary>
/// Das erste sichtbare Firefox-Hauptfenster, dessen Titel den Suchtext
/// enthaelt; ohne Suchtext das erste sichtbare Firefox-Hauptfenster.
/// </summary>
private static IntPtr FindWindowByTitle(string? containsTitle)
{
var found = IntPtr.Zero;
var className = new StringBuilder(64);
var windowTitle = new StringBuilder(512);
EnumWindows((hwnd, _) =>
{
if (!IsWindowVisible(hwnd))
{
return true;
}
className.Clear();
if (GetClassName(hwnd, className, className.Capacity) == 0
|| !string.Equals(className.ToString(), MozillaWindowClass, StringComparison.Ordinal))
{
return true;
}
if (containsTitle is not null)
{
windowTitle.Clear();
GetWindowText(hwnd, windowTitle, windowTitle.Capacity);
if (!windowTitle.ToString().Contains(containsTitle, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
found = hwnd;
return false; // Treffer — Aufzaehlung beenden.
}, IntPtr.Zero);
return found;
}
private static bool ForceForeground(IntPtr hwnd)
{
if (IsIconic(hwnd))
{
ShowWindow(hwnd, SwRestore);
}
if (GetForegroundWindow() == hwnd)
{
return true;
}
// An den Eingabe-Thread des aktuellen Vordergrundfensters anheften:
// solange die Threads verbunden sind, behandelt Windows unseren
// SetForegroundWindow-Aufruf wie einen aus dem Vordergrundprozess.
var foregroundThread = GetWindowThreadProcessId(GetForegroundWindow(), out _);
var ownThread = GetCurrentThreadId();
var attached = foregroundThread != 0
&& foregroundThread != ownThread
&& AttachThreadInput(ownThread, foregroundThread, true);
try
{
SetForegroundWindow(hwnd);
BringWindowToTop(hwnd);
}
finally
{
if (attached)
{
AttachThreadInput(ownThread, foregroundThread, false);
}
}
if (GetForegroundWindow() == hwnd)
{
return true;
}
// Zweite Stufe: ein folgenloser ALT-Tastendruck setzt das interne
// "letzte Eingabe"-Kriterium — danach laesst Windows den Wechsel zu.
keybd_event(VkMenu, 0, 0, UIntPtr.Zero);
keybd_event(VkMenu, 0, KeyeventfKeyup, UIntPtr.Zero);
SetForegroundWindow(hwnd);
return GetForegroundWindow() == hwnd;
}
private delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool IsWindowVisible(IntPtr hwnd);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int GetClassName(IntPtr hwnd, StringBuilder lpClassName, int nMaxCount);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int GetWindowText(IntPtr hwnd, StringBuilder lpString, int nMaxCount);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool IsIconic(IntPtr hwnd);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool ShowWindow(IntPtr hwnd, int nCmdShow);
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetForegroundWindow(IntPtr hwnd);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool BringWindowToTop(IntPtr hwnd);
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint processId);
[DllImport("kernel32.dll")]
private static extern uint GetCurrentThreadId();
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, [MarshalAs(UnmanagedType.Bool)] bool fAttach);
[DllImport("user32.dll")]
private static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo);
}
+10
View File
@@ -270,6 +270,8 @@ internal sealed class TrayApplicationContext : ApplicationContext
.Select(l => l.Index)
.ToHashSet();
var anyNewRinging = false;
foreach (var line in current.Lines.Where(l => l.State.IsRinging()))
{
if (previouslyRinging.Contains(line.Index))
@@ -277,6 +279,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
continue;
}
anyNewRinging = true;
Log.Info($"Eingehender Ruf auf Leitung {line.Index + 1}: {line.PeerDisplayLong}");
_notifyIcon.ShowBalloonTip(
10_000,
@@ -284,6 +287,13 @@ internal sealed class TrayApplicationContext : ApplicationContext
$"{line.PeerDisplayLong}\nLeitung {line.Index + 1}",
ToolTipIcon.Info);
}
if (anyNewRinging)
{
// Einmal je Ereignis, nicht je Leitung — mehr als nach vorn holen
// laesst sich der Tab ohnehin nicht.
_server?.ActivateIncomingCallTab();
}
}
private void ShowStatusBalloon()
+27 -4
View File
@@ -95,6 +95,10 @@ internal sealed class CommandExecutor
await CloseTabAsync(command, cancellationToken).ConfigureAwait(false);
return new ResultMessage(command.Id, true);
case "activatetab":
await ActivateTabAsync(command, cancellationToken).ConfigureAwait(false);
return new ResultMessage(command.Id, true);
case "reconnect":
await RunAsync<object?>(() => { _client.Reconnect(); return null; }, cancellationToken)
.ConfigureAwait(false);
@@ -133,10 +137,6 @@ internal sealed class CommandExecutor
var url = ValidateUrl(command.Url);
var title = command.Title?.Trim();
// Ein "Aktivieren" kennt der Tab-Kanal nicht — nach vorn kommt ein
// vorhandener Tab als Komposition der drei Operationen: Liste holen,
// den passenden Tab schliessen und die URL neu oeffnen (ein neuer Tab
// ist in Firefox von selbst vorn).
if (await _tabs.TryListTabsAsync(cancellationToken).ConfigureAwait(false) is not { } openTabs)
{
throw new InvalidOperationException(
@@ -150,6 +150,14 @@ internal sealed class CommandExecutor
if (match?.Id is { } tabId)
{
if (await _tabs.TryActivateTabAsync(tabId, cancellationToken).ConfigureAwait(false))
{
return true; // Tab nach vorn geholt, ohne ihn neu zu laden.
}
// Aeltere Plugins kennen 'activate' nicht — dann wie frueher als
// Komposition: den Tab schliessen und die URL neu oeffnen (ein
// neuer Tab ist in Firefox von selbst vorn).
await _tabs.TryCloseTabAsync(tabId, cancellationToken).ConfigureAwait(false);
}
@@ -187,6 +195,21 @@ internal sealed class CommandExecutor
}
}
private async Task ActivateTabAsync(ClientCommand command, CancellationToken cancellationToken)
{
if (command.TabId is not { } tabId)
{
throw new ArgumentException("Feld 'tabId' fehlt.");
}
if (!await _tabs.TryActivateTabAsync(tabId, cancellationToken).ConfigureAwait(false))
{
throw new InvalidOperationException(
"Kein Firefox-Plugin verbunden oder es hat den Tab nicht aktiviert " +
"(kennt es die Aktion 'activate' schon?).");
}
}
/// <summary>
/// Solange die Zugangspruefungen abgeschaltet sind, darf jeder im Netz
/// Tabs oeffnen lassen — deshalb nur absolute http(s)-Adressen.
+61
View File
@@ -315,6 +315,67 @@ internal sealed class LocalWebSocketServer : IDisposable
/// <summary>Verteilt nur den Zustand, ohne Anruf-Ereignisse abzuleiten.</summary>
public void Broadcast(SwyxSnapshot snapshot) => Broadcast(snapshot, snapshot);
/// <summary>
/// Holt bei einem eingehenden Ruf den konfigurierten Firefox-Tab nach
/// vorn (<see cref="WebSocketConfig.IncomingCallTabTitle"/>): Tabs ueber
/// das Plugin auslesen, den ersten mit passendem Titel aktivieren. Laeuft
/// im Hintergrund, damit der UI-Thread nicht auf das Plugin wartet;
/// scheitert still bis auf das Protokoll — ein fehlender Tab oder ein
/// fehlendes Plugin ist kein Fehler des Anrufs.
/// </summary>
public void ActivateIncomingCallTab()
{
if (_config.IncomingCallTabTitle?.Trim() is not { Length: > 0 } title)
{
return;
}
_ = Task.Run(() => ActivateTabByTitleAsync(title));
}
private async Task ActivateTabByTitleAsync(string title)
{
try
{
if (await _tabChannel.TryListTabsAsync(_shutdown.Token).ConfigureAwait(false) is not { } tabs)
{
Log.Debug($"Eingehender Ruf: kein Firefox-Plugin verbunden — Tab \"{title}\" bleibt, wo er ist.");
return;
}
var match = tabs.FirstOrDefault(t =>
t.Title?.Contains(title, StringComparison.OrdinalIgnoreCase) ?? false);
if (match?.Id is not { } tabId)
{
Log.Debug($"Eingehender Ruf: kein offener Firefox-Tab mit \"{title}\" im Titel.");
return;
}
if (await _tabChannel.TryActivateTabAsync(tabId, _shutdown.Token).ConfigureAwait(false))
{
// Das Plugin kann nur den Tab aktivieren — gegen den
// Foreground-Lock von Windows hilft erst der native Griff.
var inFront = FirefoxWindow.TryBringToFront(match.Title ?? title);
Log.Info($"Eingehender Ruf: Firefox-Tab \"{match.Title}\" nach vorn geholt" +
(inFront ? " (Fenster im Vordergrund)." :
" — das Fenster liess sich nicht in den Vordergrund holen."));
}
else
{
Log.Info($"Eingehender Ruf: das Firefox-Plugin hat das Aktivieren von Tab {tabId} " +
"abgelehnt — kennt es die Aktion 'activate' noch nicht?");
}
}
catch (OperationCanceledException)
{
// Beenden — nichts zu melden.
}
catch (Exception ex)
{
Log.Error("Aktivieren des Firefox-Tabs beim eingehenden Ruf fehlgeschlagen.", ex);
}
}
/// <summary>
/// Verteilt die neu eingelesenen Adressdaten an alle Verbundenen — nur an
/// den Hauptport, wie die Zustandsmeldungen. Neue Verbindungen bekommen
+9
View File
@@ -53,6 +53,15 @@ internal sealed class PluginTabChannel
=> (await RequestAsync(id => new TabRequestMessage(id, "close", TabId: tabId), cancellationToken)
.ConfigureAwait(false))?.Ok == true;
/// <summary>
/// Holt den Tab nach vorn, ohne ihn neu zu laden. <c>false</c> auch bei
/// einem aelteren Plugin, das die Aktion <c>activate</c> noch nicht kennt
/// — es antwortet dann mit <c>ok: false</c>.
/// </summary>
public async Task<bool> TryActivateTabAsync(int tabId, CancellationToken cancellationToken)
=> (await RequestAsync(id => new TabRequestMessage(id, "activate", TabId: tabId), cancellationToken)
.ConfigureAwait(false))?.Ok == true;
private async Task<ClientCommand?> RequestAsync(
Func<int, TabRequestMessage> request, CancellationToken cancellationToken)
{
+9 -7
View File
@@ -50,8 +50,9 @@ internal sealed class ClientCommand
public bool? Ok { get; set; }
/// <summary>
/// Bei <c>closetab</c> (Hauptport): der zu schliessende Tab. Bei
/// <c>tabresult</c> auf <c>open</c> (Plugin-Port): Id des neuen Tabs.
/// Bei <c>closetab</c> und <c>activatetab</c> (Hauptport): der gemeinte
/// Tab. Bei <c>tabresult</c> auf <c>open</c> (Plugin-Port): Id des neuen
/// Tabs.
/// </summary>
public int? TabId { get; set; }
@@ -115,12 +116,13 @@ internal sealed record ResultMessage(
}
/// <summary>
/// Auftrag an das Firefox-Plugin, nur auf dem Plugin-Port. Drei Aktionen:
/// Auftrag an das Firefox-Plugin, nur auf dem Plugin-Port. Vier Aktionen:
/// <c>list</c> (offene Tabs melden), <c>open</c> (neuen Tab mit <c>Url</c>
/// oeffnen), <c>close</c> (Tab <c>TabId</c> schliessen). Das Plugin antwortet
/// jeweils mit <c>{"cmd":"tabresult","id":…,"ok":…}</c> — bei <c>list</c>
/// zusaetzlich mit <c>tabs</c>, bei <c>open</c> mit <c>tabId</c>. Die Id
/// stammt hier vom Server, nicht von einer Webseite.
/// oeffnen), <c>close</c> (Tab <c>TabId</c> schliessen), <c>activate</c>
/// (Tab <c>TabId</c> nach vorn holen, ohne ihn neu zu laden). Das Plugin
/// antwortet jeweils mit <c>{"cmd":"tabresult","id":…,"ok":…}</c> — bei
/// <c>list</c> zusaetzlich mit <c>tabs</c>, bei <c>open</c> mit <c>tabId</c>.
/// Die Id stammt hier vom Server, nicht von einer Webseite.
/// </summary>
internal sealed record TabRequestMessage(int Id, string Action, string? Url = null, int? TabId = null)
{
+18 -1
View File
@@ -36,8 +36,15 @@ internal sealed class WebSocketConfig
/// jedem Neueinlesen (Start, Neuverbindung, alle 60 Minuten). Dazu das
/// Kommando <c>addresses</c>, mit dem eine Seite den Cache jederzeit
/// selbst abrufen kann.
/// Version 9: Tab-Aktion <c>activate</c> auf dem Plugin-Port (Tab nach
/// vorn holen, ohne ihn neu zu laden), als <c>activatetab</c> auch auf dem
/// Hauptport. <c>focus</c> aktiviert einen gefundenen Tab jetzt, statt ihn
/// zu schliessen und neu zu oeffnen (Ersatzweg fuer aeltere Plugins
/// bleibt). Dazu <see cref="IncomingCallTabTitle"/>: bei einem eingehenden
/// Ruf holt SwyxTray den Firefox-Tab mit diesem Titel selbsttaetig nach
/// vorn.
/// </summary>
public const int ProtocolVersion = 8;
public const int ProtocolVersion = 9;
/// <summary>
/// ZUR ZEIT ABGESCHALTET: Token-, Origin- und Host-Pruefung entfallen —
@@ -99,6 +106,16 @@ internal sealed class WebSocketConfig
public int MaxConnections { get; set; } = 8;
/// <summary>
/// Beginnt eine Leitung zu klingeln, holt SwyxTray den ersten Firefox-Tab,
/// dessen Titel diesen Text enthaelt (ohne Beachtung der Gross-/
/// Kleinschreibung), ueber das Plugin nach vorn — ohne ihn neu zu laden,
/// damit der Web-Client seine WebSocket-Verbindung behaelt. Leer = aus.
/// Ohne Plugin oder ohne passenden Tab passiert nichts; es wird bewusst
/// kein neuer Tab geoeffnet.
/// </summary>
public string IncomingCallTabTitle { get; set; } = "SwyxWeb";
/// <summary>
/// Voreingestellt hoert die App nur auf 127.0.0.1 und ::1. Auf <c>true</c>
/// bindet sie an alle Schnittstellen und ist damit aus dem lokalen Netz