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
+57 -17
View File
@@ -129,10 +129,20 @@ Beim ersten Start entsteht `%LOCALAPPDATA%\SwyxTray\websocket.json`:
"Token": "E59BF4…", "Token": "E59BF4…",
"AllowedOrigins": [], "AllowedOrigins": [],
"MaxConnections": 8, "MaxConnections": 8,
"AllowRemoteAccess": false "AllowRemoteAccess": false,
"IncomingCallTabTitle": "SwyxWeb"
} }
``` ```
`IncomingCallTabTitle` (seit Protokollversion 9, fehlt der Eintrag, gilt
`"SwyxWeb"`): Beginnt eine Leitung zu klingeln, holt SwyxTray den ersten
Firefox-Tab, dessen Titel diesen Text enthält (Groß-/Kleinschreibung ohne
Belang, trifft also z. B. „SwyxWeb · STADTBOTE"), über das Plugin nach vorn —
ohne ihn neu zu laden, damit der Web-Client seine WebSocket-Verbindung behält.
Ist kein Plugin verbunden oder kein passender Tab offen, passiert nichts; es
wird bewusst kein neuer Tab geöffnet. Ein leerer Eintrag (`""`) schaltet das
Verhalten ab.
Solange die Prüfungen abgeschaltet sind (siehe unten), sind `Token` und Solange die Prüfungen abgeschaltet sind (siehe unten), sind `Token` und
`AllowedOrigins` ohne Wirkung — für einen ersten Test ist an dieser Datei nichts `AllowedOrigins` ohne Wirkung — für einen ersten Test ist an dieser Datei nichts
zu tun. Der Rest dieses Abschnitts gilt für den eingeschalteten Zustand. zu tun. Der Rest dieses Abschnitts gilt für den eingeschalteten Zustand.
@@ -170,7 +180,7 @@ ab; in einer bestehenden Datei ohne diesen Eintrag gilt automatisch 17655).
Auf diesem Port läuft **ausschließlich die Tab-Verwaltung** — keine Auf diesem Port läuft **ausschließlich die Tab-Verwaltung** — keine
Snapshots, keine Anruf-Ereignisse, keine Telefonie-Kommandos (sie werden mit Snapshots, keine Anruf-Ereignisse, keine Telefonie-Kommandos (sie werden mit
`ok: false` abgewiesen). Nach dem `hello` ist die Richtung umgekehrt zum `ok: false` abgewiesen). Nach dem `hello` ist die Richtung umgekehrt zum
Hauptport: **SwyxTray fragt, das Plugin antwortet.** Es gibt drei Aufträge, Hauptport: **SwyxTray fragt, das Plugin antwortet.** Es gibt vier Aufträge,
jeder wird mit einem `tabresult` samt unveränderter `id` beantwortet: jeder wird mit einem `tabresult` samt unveränderter `id` beantwortet:
**Offene Tabs auslesen:** **Offene Tabs auslesen:**
@@ -196,18 +206,44 @@ jeder wird mit einem `tabresult` samt unveränderter `id` beantwortet:
vom Plugin: {"cmd":"tabresult","id":3,"ok":true} vom Plugin: {"cmd":"tabresult","id":3,"ok":true}
``` ```
**Tab aktivieren** (seit Protokollversion 9 — nach vorn holen, ohne ihn neu
zu laden; das Plugin setzt das mit `browser.tabs.update(tabId,
{active: true})` samt `browser.windows.update(windowId, {focused: true})`
um):
```json
an das Plugin: {"type":"tab","id":4,"action":"activate","tabId":42}
vom Plugin: {"cmd":"tabresult","id":4,"ok":true}
```
Antwortet das Plugin binnen fünf Sekunden nicht (oder ist keines verbunden), Antwortet das Plugin binnen fünf Sekunden nicht (oder ist keines verbunden),
gilt der Auftrag als gescheitert; die darauf aufbauenden Kommandos des gilt der Auftrag als gescheitert; die darauf aufbauenden Kommandos des
Hauptports (`focus`, `tabs`, `opentab`, `closetab`) enden dann mit Hauptports (`focus`, `tabs`, `opentab`, `closetab`, `activatetab`) enden dann
`ok: false`. Vom Plugin aus sind nur `tabresult` und `ping` zulässig. Ein mit `ok: false`. Vom Plugin aus sind nur `tabresult` und `ping` zulässig.
„Tab aktivieren“ kennt der Kanal bewusst nicht — beim `focus`-Kommando holt Ein älteres Plugin, das `activate` noch nicht kennt, antwortet darauf mit
SwyxTray einen vorhandenen Tab als Komposition nach vorn: `list`, passenden `ok: false``focus` fällt dann auf den alten Weg zurück (Tab per `close`
Tab per `close` schließen und die URL per `open` neu öffnen. schließen und die URL per `open` neu öffnen), das Aktivieren beim
eingehenden Ruf (`IncomingCallTabTitle`, siehe oben) unterbleibt und das
Protokoll vermerkt es.
Das Plugin selbst (SwyxFFPlugin) wird als **eigenes Projekt** gepflegt und ist Das Plugin selbst (SwyxFFPlugin) wird als **eigenes Projekt** gepflegt und ist
nicht Teil dieses Repositories. Sein `background.js` setzt genau diese drei nicht Teil dieses Repositories. Sein `background.js` setzt genau diese
Aufträge um und verbindet sich selbsttätig neu (1 s Abstand, je Fehlversuch Aufträge um und verbindet sich selbsttätig neu (1 s Abstand, je Fehlversuch
verdoppelt bis 30 s), falls SwyxTray noch nicht oder nicht mehr läuft. verdoppelt bis 30 s), falls SwyxTray noch nicht oder nicht mehr läuft.
Für `activate` (seit Protokollversion 9) braucht das Plugin einen weiteren
Zweig in seinem `handleMessage`:
```js
} else if (m.action === "activate") {
if (m.tabId == null) {
reply({ ok: false });
} else {
const tab = await browser.tabs.update(m.tabId, { active: true });
await browser.windows.update(tab.windowId, { focused: true });
reply({ ok: true });
}
}
```
Installation zum Erproben: `about:debugging#/runtime/this-firefox` Installation zum Erproben: `about:debugging#/runtime/this-firefox`
*Temporäres Add-on laden…* → die `manifest.json` des Plugin-Projekts wählen. *Temporäres Add-on laden…* → die `manifest.json` des Plugin-Projekts wählen.
@@ -229,6 +265,7 @@ wobei die UUID je Installation zufällig vergeben wird — sie steht in
| `tabs` | — | liest die offenen Firefox-Tabs über das Plugin aus | | `tabs` | — | liest die offenen Firefox-Tabs über das Plugin aus |
| `opentab` | `url` | öffnet über das Plugin einen neuen Firefox-Tab | | `opentab` | `url` | öffnet über das Plugin einen neuen Firefox-Tab |
| `closetab` | `tabId` | schließt über das Plugin den Tab mit dieser Id | | `closetab` | `tabId` | schließt über das Plugin den Tab mit dieser Id |
| `activatetab` | `tabId` | holt über das Plugin den Tab mit dieser Id nach vorn, ohne ihn neu zu laden |
| `addresses` | — | liefert die zwischengespeicherten Adressdaten (Telefonbücher), Antwort im Feld `addresses` | | `addresses` | — | liefert die zwischengespeicherten Adressdaten (Telefonbücher), Antwort im Feld `addresses` |
| `contacts` | `query` | durchsucht die Adressdaten des Swyx-Clients (Telefonbücher und Kontakt-Plugins), Antwort im Feld `contacts` | | `contacts` | `query` | durchsucht die Adressdaten des Swyx-Clients (Telefonbücher und Kontakt-Plugins), Antwort im Feld `contacts` |
| `resolve` | `number` | löst eine Rufnummer über die Adressdaten in einen Namen auf, Antwort im Feld `name` (leer = unbekannt) | | `resolve` | `number` | löst eine Rufnummer über die Adressdaten in einen Namen auf, Antwort im Feld `name` (leer = unbekannt) |
@@ -256,21 +293,24 @@ Zustandsmeldungen.
`focus` (seit Protokollversion 3, ab Version 5 ausschließlich über das `focus` (seit Protokollversion 3, ab Version 5 ausschließlich über das
**Firefox-Plugin**, siehe unten): war ein passender Tab schon offen — sein **Firefox-Plugin**, siehe unten): war ein passender Tab schon offen — sein
Titel enthält den `title` oder seine Adresse ist die `url` —, wird er Titel enthält den `title` oder seine Adresse ist die `url` —, wird er
geschlossen und die `url` neu geöffnet (`"focused": true`), sonst nur neu aktiviert (`"focused": true`; seit Version 9 ohne Neuladen, bei einem
geöffnet (`"focused": false`). Ist kein Plugin verbunden oder antwortet es älteren Plugin ohne `activate` ersatzweise wie früher geschlossen und die
nicht, endet das Kommando mit `ok: false` — einen anderen Weg, eine Seite `url` neu geöffnet), sonst nur neu geöffnet (`"focused": false`). Ist kein
anzuzeigen, gibt es nicht. Die `url` ist Pflicht, `title` dient nur dem Plugin verbunden oder antwortet es nicht, endet das Kommando mit
Tab-Abgleich; erlaubt sind nur absolute `http(s)`-Adressen. `ok: false` — einen anderen Weg, eine Seite anzuzeigen, gibt es nicht. Die
`url` ist Pflicht, `title` dient nur dem Tab-Abgleich; erlaubt sind nur
absolute `http(s)`-Adressen.
```json ```json
{"id":9,"cmd":"focus","title":"Kundenakte Muster GmbH","url":"https://crm.example.local/kunden/4711"} {"id":9,"cmd":"focus","title":"Kundenakte Muster GmbH","url":"https://crm.example.local/kunden/4711"}
{"type":"result","id":9,"ok":true,"focused":false} {"type":"result","id":9,"ok":true,"focused":false}
``` ```
`tabs`, `opentab`, `closetab` (seit Protokollversion 6): die drei Aufträge des `tabs`, `opentab`, `closetab` (seit Protokollversion 6), `activatetab` (seit
Tab-Verwaltungskanals stehen damit auch Webseiten offen — SwyxTray reicht sie Version 9): die Aufträge des Tab-Verwaltungskanals stehen damit auch
unverändert an das Firefox-Plugin durch. Ist kein Plugin verbunden oder Webseiten offen — SwyxTray reicht sie unverändert an das Firefox-Plugin
antwortet es nicht binnen fünf Sekunden, endet das Kommando mit `ok: false`. durch. Ist kein Plugin verbunden oder antwortet es nicht binnen fünf
Sekunden, endet das Kommando mit `ok: false`.
Für `opentab` gilt dieselbe Regel wie bei `focus`: nur absolute Für `opentab` gilt dieselbe Regel wie bei `focus`: nur absolute
`http(s)`-Adressen. Die `tabId` für `closetab` stammt aus einer vorherigen `http(s)`-Adressen. Die `tabId` für `closetab` stammt aus einer vorherigen
`tabs`-Antwort; nach Öffnen oder Schließen verschickt die App **keine** neue `tabs`-Antwort; nach Öffnen oder Schließen verschickt die App **keine** neue
+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) .Select(l => l.Index)
.ToHashSet(); .ToHashSet();
var anyNewRinging = false;
foreach (var line in current.Lines.Where(l => l.State.IsRinging())) foreach (var line in current.Lines.Where(l => l.State.IsRinging()))
{ {
if (previouslyRinging.Contains(line.Index)) if (previouslyRinging.Contains(line.Index))
@@ -277,6 +279,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
continue; continue;
} }
anyNewRinging = true;
Log.Info($"Eingehender Ruf auf Leitung {line.Index + 1}: {line.PeerDisplayLong}"); Log.Info($"Eingehender Ruf auf Leitung {line.Index + 1}: {line.PeerDisplayLong}");
_notifyIcon.ShowBalloonTip( _notifyIcon.ShowBalloonTip(
10_000, 10_000,
@@ -284,6 +287,13 @@ internal sealed class TrayApplicationContext : ApplicationContext
$"{line.PeerDisplayLong}\nLeitung {line.Index + 1}", $"{line.PeerDisplayLong}\nLeitung {line.Index + 1}",
ToolTipIcon.Info); 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() private void ShowStatusBalloon()
+27 -4
View File
@@ -95,6 +95,10 @@ internal sealed class CommandExecutor
await CloseTabAsync(command, cancellationToken).ConfigureAwait(false); await CloseTabAsync(command, cancellationToken).ConfigureAwait(false);
return new ResultMessage(command.Id, true); return new ResultMessage(command.Id, true);
case "activatetab":
await ActivateTabAsync(command, cancellationToken).ConfigureAwait(false);
return new ResultMessage(command.Id, true);
case "reconnect": case "reconnect":
await RunAsync<object?>(() => { _client.Reconnect(); return null; }, cancellationToken) await RunAsync<object?>(() => { _client.Reconnect(); return null; }, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
@@ -133,10 +137,6 @@ internal sealed class CommandExecutor
var url = ValidateUrl(command.Url); var url = ValidateUrl(command.Url);
var title = command.Title?.Trim(); 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) if (await _tabs.TryListTabsAsync(cancellationToken).ConfigureAwait(false) is not { } openTabs)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
@@ -150,6 +150,14 @@ internal sealed class CommandExecutor
if (match?.Id is { } tabId) 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); 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> /// <summary>
/// Solange die Zugangspruefungen abgeschaltet sind, darf jeder im Netz /// Solange die Zugangspruefungen abgeschaltet sind, darf jeder im Netz
/// Tabs oeffnen lassen — deshalb nur absolute http(s)-Adressen. /// 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> /// <summary>Verteilt nur den Zustand, ohne Anruf-Ereignisse abzuleiten.</summary>
public void Broadcast(SwyxSnapshot snapshot) => Broadcast(snapshot, snapshot); 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> /// <summary>
/// Verteilt die neu eingelesenen Adressdaten an alle Verbundenen — nur an /// Verteilt die neu eingelesenen Adressdaten an alle Verbundenen — nur an
/// den Hauptport, wie die Zustandsmeldungen. Neue Verbindungen bekommen /// 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) => (await RequestAsync(id => new TabRequestMessage(id, "close", TabId: tabId), cancellationToken)
.ConfigureAwait(false))?.Ok == true; .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( private async Task<ClientCommand?> RequestAsync(
Func<int, TabRequestMessage> request, CancellationToken cancellationToken) Func<int, TabRequestMessage> request, CancellationToken cancellationToken)
{ {
+9 -7
View File
@@ -50,8 +50,9 @@ internal sealed class ClientCommand
public bool? Ok { get; set; } public bool? Ok { get; set; }
/// <summary> /// <summary>
/// Bei <c>closetab</c> (Hauptport): der zu schliessende Tab. Bei /// Bei <c>closetab</c> und <c>activatetab</c> (Hauptport): der gemeinte
/// <c>tabresult</c> auf <c>open</c> (Plugin-Port): Id des neuen Tabs. /// Tab. Bei <c>tabresult</c> auf <c>open</c> (Plugin-Port): Id des neuen
/// Tabs.
/// </summary> /// </summary>
public int? TabId { get; set; } public int? TabId { get; set; }
@@ -115,12 +116,13 @@ internal sealed record ResultMessage(
} }
/// <summary> /// <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> /// <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 /// oeffnen), <c>close</c> (Tab <c>TabId</c> schliessen), <c>activate</c>
/// jeweils mit <c>{"cmd":"tabresult","id":…,"ok":…}</c> — bei <c>list</c> /// (Tab <c>TabId</c> nach vorn holen, ohne ihn neu zu laden). Das Plugin
/// zusaetzlich mit <c>tabs</c>, bei <c>open</c> mit <c>tabId</c>. Die Id /// antwortet jeweils mit <c>{"cmd":"tabresult","id":…,"ok":…}</c> — bei
/// stammt hier vom Server, nicht von einer Webseite. /// <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> /// </summary>
internal sealed record TabRequestMessage(int Id, string Action, string? Url = null, int? TabId = null) 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 /// jedem Neueinlesen (Start, Neuverbindung, alle 60 Minuten). Dazu das
/// Kommando <c>addresses</c>, mit dem eine Seite den Cache jederzeit /// Kommando <c>addresses</c>, mit dem eine Seite den Cache jederzeit
/// selbst abrufen kann. /// 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> /// </summary>
public const int ProtocolVersion = 8; public const int ProtocolVersion = 9;
/// <summary> /// <summary>
/// ZUR ZEIT ABGESCHALTET: Token-, Origin- und Host-Pruefung entfallen — /// ZUR ZEIT ABGESCHALTET: Token-, Origin- und Host-Pruefung entfallen —
@@ -99,6 +106,16 @@ internal sealed class WebSocketConfig
public int MaxConnections { get; set; } = 8; 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> /// <summary>
/// Voreingestellt hoert die App nur auf 127.0.0.1 und ::1. Auf <c>true</c> /// 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 /// bindet sie an alle Schnittstellen und ist damit aus dem lokalen Netz