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