diff --git a/README.md b/README.md
index d6cf441..dce194a 100644
--- a/README.md
+++ b/README.md
@@ -129,10 +129,20 @@ Beim ersten Start entsteht `%LOCALAPPDATA%\SwyxTray\websocket.json`:
"Token": "E59BF4…",
"AllowedOrigins": [],
"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
`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.
@@ -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
Snapshots, keine Anruf-Ereignisse, keine Telefonie-Kommandos (sie werden mit
`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:
**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}
```
+**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),
gilt der Auftrag als gescheitert; die darauf aufbauenden Kommandos des
-Hauptports (`focus`, `tabs`, `opentab`, `closetab`) enden dann mit
-`ok: false`. Vom Plugin aus sind nur `tabresult` und `ping` zulässig. Ein
-„Tab aktivieren“ kennt der Kanal bewusst nicht — beim `focus`-Kommando holt
-SwyxTray einen vorhandenen Tab als Komposition nach vorn: `list`, passenden
-Tab per `close` schließen und die URL per `open` neu öffnen.
+Hauptports (`focus`, `tabs`, `opentab`, `closetab`, `activatetab`) enden dann
+mit `ok: false`. Vom Plugin aus sind nur `tabresult` und `ping` zulässig.
+Ein älteres Plugin, das `activate` noch nicht kennt, antwortet darauf mit
+`ok: false` — `focus` fällt dann auf den alten Weg zurück (Tab per `close`
+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
-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
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` →
*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 |
| `opentab` | `url` | öffnet über das Plugin einen neuen Firefox-Tab |
| `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` |
| `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) |
@@ -256,21 +293,24 @@ Zustandsmeldungen.
`focus` (seit Protokollversion 3, ab Version 5 ausschließlich über das
**Firefox-Plugin**, siehe unten): war ein passender Tab schon offen — sein
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
-geöffnet (`"focused": false`). Ist kein Plugin verbunden oder antwortet es
-nicht, endet das Kommando mit `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.
+aktiviert (`"focused": true`; seit Version 9 ohne Neuladen, bei einem
+älteren Plugin ohne `activate` ersatzweise wie früher geschlossen und die
+`url` neu geöffnet), sonst nur neu geöffnet (`"focused": false`). Ist kein
+Plugin verbunden oder antwortet es nicht, endet das Kommando mit
+`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
→ {"id":9,"cmd":"focus","title":"Kundenakte Muster GmbH","url":"https://crm.example.local/kunden/4711"}
← {"type":"result","id":9,"ok":true,"focused":false}
```
-`tabs`, `opentab`, `closetab` (seit Protokollversion 6): die drei Aufträge des
-Tab-Verwaltungskanals stehen damit auch Webseiten offen — SwyxTray reicht sie
-unverändert an das Firefox-Plugin durch. Ist kein Plugin verbunden oder
-antwortet es nicht binnen fünf Sekunden, endet das Kommando mit `ok: false`.
+`tabs`, `opentab`, `closetab` (seit Protokollversion 6), `activatetab` (seit
+Version 9): die Aufträge des Tab-Verwaltungskanals stehen damit auch
+Webseiten offen — SwyxTray reicht sie unverändert an das Firefox-Plugin
+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
`http(s)`-Adressen. Die `tabId` für `closetab` stammt aus einer vorherigen
`tabs`-Antwort; nach Öffnen oder Schließen verschickt die App **keine** neue
diff --git a/src/SwyxTray/FirefoxWindow.cs b/src/SwyxTray/FirefoxWindow.cs
new file mode 100644
index 0000000..7fa4b5d
--- /dev/null
+++ b/src/SwyxTray/FirefoxWindow.cs
@@ -0,0 +1,200 @@
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace SwyxTray;
+
+///
+/// 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
+/// begruendet.
+///
+internal static class FirefoxWindow
+{
+ /// Klassenname aller Firefox-Hauptfenster.
+ private const string MozillaWindowClass = "MozillaWindowClass";
+
+ private const int SwRestore = 9;
+ private const byte VkMenu = 0x12;
+ private const uint KeyeventfKeyup = 0x2;
+
+ ///
+ /// Firefox braucht nach dem Aktivieren des Tabs einen Moment, bis der
+ /// Fenstertitel den Tab-Titel traegt — darum mehrere kurze Versuche.
+ ///
+ private const int FindAttempts = 3;
+ private static readonly TimeSpan FindRetryDelay = TimeSpan.FromMilliseconds(150);
+
+ ///
+ /// Sucht das Firefox-Fenster, dessen Titel
+ /// 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. false, wenn kein Fenster gefunden
+ /// wurde oder Windows den Wechsel endgueltig verweigert hat.
+ ///
+ 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);
+ }
+
+ ///
+ /// Das erste sichtbare Firefox-Hauptfenster, dessen Titel den Suchtext
+ /// enthaelt; ohne Suchtext das erste sichtbare Firefox-Hauptfenster.
+ ///
+ 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);
+}
diff --git a/src/SwyxTray/TrayApplicationContext.cs b/src/SwyxTray/TrayApplicationContext.cs
index 9f88d1c..c8fbe35 100644
--- a/src/SwyxTray/TrayApplicationContext.cs
+++ b/src/SwyxTray/TrayApplicationContext.cs
@@ -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()
diff --git a/src/SwyxTray/Web/CommandExecutor.cs b/src/SwyxTray/Web/CommandExecutor.cs
index b56354f..0dd8bda 100644
--- a/src/SwyxTray/Web/CommandExecutor.cs
+++ b/src/SwyxTray/Web/CommandExecutor.cs
@@ -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