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:
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user