Compare commits

...
2 Commits
Author SHA1 Message Date
SvenandClaude Fable 5 ce9e6816f8 Einstellungen-Dialog: Verzoegerung fuer das Nach-vorn-Holen von Firefox
Bei einem eingehenden Ruf wartet SwyxTray jetzt eine konfigurierbare
Zeit (Standard 1000 ms), bevor der Firefox-Tab aktiviert und das
Fenster in den Vordergrund geholt wird. Der Wert steht als
IncomingCallFocusDelayMs in der websocket.json und laesst sich ueber
den neuen Menuepunkt Einstellungen im Tray-Menue aendern; die
Aenderung wirkt sofort und wird zurueckgeschrieben.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:06:22 +02:00
SvenandClaude Fable 5 748687ce1c 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>
2026-08-28 14:33:51 +02:00
9 changed files with 555 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);
}
+68
View File
@@ -0,0 +1,68 @@
namespace SwyxTray;
/// <summary>
/// Kleiner modaler Dialog hinter dem Menuepunkt "Einstellungen": stellt die
/// Wartezeit ein, bis Firefox bei einem eingehenden Ruf nach vorn geholt wird
/// (<see cref="Web.WebSocketConfig.IncomingCallFocusDelayMs"/>). Der Aufrufer
/// liest nach OK den Wert aus <see cref="FocusDelayMs"/> und speichert selbst.
/// </summary>
internal sealed class SettingsDialog : Form
{
private const int MaxDelayMs = 60_000;
private readonly NumericUpDown _delayInput;
/// <summary>Die eingestellte Verzoegerung in Millisekunden.</summary>
public int FocusDelayMs => (int)_delayInput.Value;
public SettingsDialog(int focusDelayMs)
{
Text = "SwyxTray — Einstellungen";
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
ShowInTaskbar = false;
StartPosition = FormStartPosition.CenterScreen;
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(570, 174);
var label = new Label
{
Text = "Verzoegerung, bis Firefox bei einem eingehenden Ruf\n" +
"nach vorn geholt wird (Millisekunden):",
Location = new Point(18, 18),
AutoSize = true
};
_delayInput = new NumericUpDown
{
Minimum = 0,
Maximum = MaxDelayMs,
Increment = 100,
ThousandsSeparator = true,
Location = new Point(18, 81),
Width = 150,
Value = Math.Clamp(focusDelayMs, 0, MaxDelayMs)
};
var ok = new Button
{
Text = "OK",
DialogResult = DialogResult.OK,
Location = new Point(273, 123),
Size = new Size(135, 35)
};
var cancel = new Button
{
Text = "Abbrechen",
DialogResult = DialogResult.Cancel,
Location = new Point(417, 123),
Size = new Size(135, 35)
};
AcceptButton = ok;
CancelButton = cancel;
Controls.AddRange(new Control[] { label, _delayInput, ok, cancel });
}
}
+63
View File
@@ -41,6 +41,8 @@ internal sealed class TrayApplicationContext : ApplicationContext
private readonly ToolStripMenuItem _copyEndpointItem; private readonly ToolStripMenuItem _copyEndpointItem;
private LocalWebSocketServer? _server; private LocalWebSocketServer? _server;
private WebSocketConfig? _webSocketConfig;
private bool _settingsDialogOpen;
private bool _disposed; private bool _disposed;
public TrayApplicationContext() public TrayApplicationContext()
@@ -69,6 +71,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
new ToolStripSeparator(), new ToolStripSeparator(),
new ToolStripMenuItem("Neu verbinden", null, (_, _) => Reconnect()), new ToolStripMenuItem("Neu verbinden", null, (_, _) => Reconnect()),
new ToolStripMenuItem("Protokoll oeffnen", null, (_, _) => OpenLog()), new ToolStripMenuItem("Protokoll oeffnen", null, (_, _) => OpenLog()),
new ToolStripMenuItem("Einstellungen", null, (_, _) => OpenSettings()),
new ToolStripSeparator(), new ToolStripSeparator(),
new ToolStripMenuItem("Beenden", null, (_, _) => ExitApplication()) new ToolStripMenuItem("Beenden", null, (_, _) => ExitApplication())
}); });
@@ -129,6 +132,8 @@ internal sealed class TrayApplicationContext : ApplicationContext
return; return;
} }
_webSocketConfig = config;
if (UiDispatcher.Capture() is not { } dispatcher) if (UiDispatcher.Capture() is not { } dispatcher)
{ {
Log.Error("Kein UI-Kontext vorhanden — der WebSocket-Zugang bleibt aus."); Log.Error("Kein UI-Kontext vorhanden — der WebSocket-Zugang bleibt aus.");
@@ -270,6 +275,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 +284,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 +292,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()
@@ -298,6 +313,54 @@ internal sealed class TrayApplicationContext : ApplicationContext
_notifyIcon.ShowBalloonTip(5_000, s.StatusText, body, ToolTipIcon.Info); _notifyIcon.ShowBalloonTip(5_000, s.StatusText, body, ToolTipIcon.Info);
} }
/// <summary>
/// Zeigt den Einstellungen-Dialog. Der laufende Server benutzt dieselbe
/// Konfigurations-Instanz, deshalb wirkt eine Aenderung sofort — ohne
/// Neustart. Gespeichert wird in die websocket.json, damit der Wert den
/// naechsten Start ueberlebt.
/// </summary>
private void OpenSettings()
{
// ShowDialog laesst das Tray-Menue weiter bedienbar — ein zweiter
// Klick soll keinen zweiten Dialog oeffnen.
if (_settingsDialogOpen)
{
return;
}
var config = _webSocketConfig ?? WebSocketConfig.Load();
if (config is null)
{
_notifyIcon.ShowBalloonTip(5_000, "Einstellungen",
"Die Konfiguration konnte nicht geladen werden (siehe Protokoll).",
ToolTipIcon.Warning);
return;
}
_webSocketConfig = config;
_settingsDialogOpen = true;
try
{
using var dialog = new SettingsDialog(config.IncomingCallFocusDelayMs);
if (dialog.ShowDialog() != DialogResult.OK
|| dialog.FocusDelayMs == config.IncomingCallFocusDelayMs)
{
return;
}
config.IncomingCallFocusDelayMs = dialog.FocusDelayMs;
if (config.Save())
{
Log.Info("Einstellungen gespeichert: Verzoegerung fuer das Nach-vorn-Holen " +
$"von Firefox jetzt {config.IncomingCallFocusDelayMs} ms.");
}
}
finally
{
_settingsDialogOpen = false;
}
}
private void Reconnect() private void Reconnect()
{ {
Log.Info("Neuverbindung durch Benutzer angefordert."); Log.Info("Neuverbindung durch Benutzer angefordert.");
+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.
+71
View File
@@ -315,6 +315,77 @@ 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"/>): die
/// konfigurierte Zeit warten (<see
/// cref="WebSocketConfig.IncomingCallFocusDelayMs"/>), dann 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
{
// Kurz warten, damit der Ruf erst sichtbar klingelt, bevor
// Firefox dem Nutzer den Fokus wegnimmt. Die Dauer kommt aus der
// Konfiguration (Einstellungen-Dialog im Tray-Menue).
if (_config.IncomingCallFocusDelayMs > 0)
{
await Task.Delay(_config.IncomingCallFocusDelayMs, _shutdown.Token).ConfigureAwait(false);
}
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)
{ {
+51 -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,24 @@ 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>
/// Wartezeit in Millisekunden zwischen Klingelbeginn und dem Nach-vorn-
/// Holen des Firefox-Fensters — der Ruf soll erst sichtbar klingeln,
/// bevor der Fokus wechselt. 0 = sofort. Einstellbar auch ueber den
/// Menuepunkt "Einstellungen" im Tray-Menue.
/// </summary>
public int IncomingCallFocusDelayMs { get; set; } = 1000;
/// <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
@@ -148,6 +173,26 @@ internal sealed class WebSocketConfig
} }
} }
/// <summary>
/// Schreibt die Konfiguration zurueck in die Datei — etwa nach einer
/// Aenderung ueber den Einstellungen-Dialog. Kommentare, die jemand von
/// Hand in die JSON-Datei geschrieben hat, gehen dabei verloren.
/// </summary>
public bool Save()
{
try
{
Directory.CreateDirectory(Log.Directory);
File.WriteAllText(FilePath, JsonSerializer.Serialize(this, WriteOptions), Encoding.UTF8);
return true;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Error($"WebSocket-Konfiguration {FilePath} konnte nicht gespeichert werden.", ex);
return false;
}
}
private bool Validate() private bool Validate()
{ {
if (Port is < 1 or > 65535) if (Port is < 1 or > 65535)
@@ -168,6 +213,11 @@ internal sealed class WebSocketConfig
MaxConnections = 1; MaxConnections = 1;
} }
if (IncomingCallFocusDelayMs < 0)
{
IncomingCallFocusDelayMs = 0;
}
// Solange die Pruefung abgeschaltet ist, darf ein fehlendes oder kurzes // Solange die Pruefung abgeschaltet ist, darf ein fehlendes oder kurzes
// Token den Start nicht verhindern. // Token den Start nicht verhindern.
if (Token.Length < 16 && !SecurityDisabled) if (Token.Length < 16 && !SecurityDisabled)