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…",
"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
+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 LocalWebSocketServer? _server;
private WebSocketConfig? _webSocketConfig;
private bool _settingsDialogOpen;
private bool _disposed;
public TrayApplicationContext()
@@ -69,6 +71,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
new ToolStripSeparator(),
new ToolStripMenuItem("Neu verbinden", null, (_, _) => Reconnect()),
new ToolStripMenuItem("Protokoll oeffnen", null, (_, _) => OpenLog()),
new ToolStripMenuItem("Einstellungen", null, (_, _) => OpenSettings()),
new ToolStripSeparator(),
new ToolStripMenuItem("Beenden", null, (_, _) => ExitApplication())
});
@@ -129,6 +132,8 @@ internal sealed class TrayApplicationContext : ApplicationContext
return;
}
_webSocketConfig = config;
if (UiDispatcher.Capture() is not { } dispatcher)
{
Log.Error("Kein UI-Kontext vorhanden — der WebSocket-Zugang bleibt aus.");
@@ -270,6 +275,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 +284,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 +292,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()
@@ -298,6 +313,54 @@ internal sealed class TrayApplicationContext : ApplicationContext
_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()
{
Log.Info("Neuverbindung durch Benutzer angefordert.");
+27 -4
View File
@@ -95,6 +95,10 @@ internal sealed class CommandExecutor
await CloseTabAsync(command, cancellationToken).ConfigureAwait(false);
return new ResultMessage(command.Id, true);
case "activatetab":
await ActivateTabAsync(command, cancellationToken).ConfigureAwait(false);
return new ResultMessage(command.Id, true);
case "reconnect":
await RunAsync<object?>(() => { _client.Reconnect(); return null; }, cancellationToken)
.ConfigureAwait(false);
@@ -133,10 +137,6 @@ internal sealed class CommandExecutor
var url = ValidateUrl(command.Url);
var title = command.Title?.Trim();
// Ein "Aktivieren" kennt der Tab-Kanal nicht — nach vorn kommt ein
// vorhandener Tab als Komposition der drei Operationen: Liste holen,
// den passenden Tab schliessen und die URL neu oeffnen (ein neuer Tab
// ist in Firefox von selbst vorn).
if (await _tabs.TryListTabsAsync(cancellationToken).ConfigureAwait(false) is not { } openTabs)
{
throw new InvalidOperationException(
@@ -150,6 +150,14 @@ internal sealed class CommandExecutor
if (match?.Id is { } tabId)
{
if (await _tabs.TryActivateTabAsync(tabId, cancellationToken).ConfigureAwait(false))
{
return true; // Tab nach vorn geholt, ohne ihn neu zu laden.
}
// Aeltere Plugins kennen 'activate' nicht — dann wie frueher als
// Komposition: den Tab schliessen und die URL neu oeffnen (ein
// neuer Tab ist in Firefox von selbst vorn).
await _tabs.TryCloseTabAsync(tabId, cancellationToken).ConfigureAwait(false);
}
@@ -187,6 +195,21 @@ internal sealed class CommandExecutor
}
}
private async Task ActivateTabAsync(ClientCommand command, CancellationToken cancellationToken)
{
if (command.TabId is not { } tabId)
{
throw new ArgumentException("Feld 'tabId' fehlt.");
}
if (!await _tabs.TryActivateTabAsync(tabId, cancellationToken).ConfigureAwait(false))
{
throw new InvalidOperationException(
"Kein Firefox-Plugin verbunden oder es hat den Tab nicht aktiviert " +
"(kennt es die Aktion 'activate' schon?).");
}
}
/// <summary>
/// Solange die Zugangspruefungen abgeschaltet sind, darf jeder im Netz
/// Tabs oeffnen lassen — deshalb nur absolute http(s)-Adressen.
+71
View File
@@ -315,6 +315,77 @@ internal sealed class LocalWebSocketServer : IDisposable
/// <summary>Verteilt nur den Zustand, ohne Anruf-Ereignisse abzuleiten.</summary>
public void Broadcast(SwyxSnapshot snapshot) => Broadcast(snapshot, snapshot);
/// <summary>
/// Holt bei einem eingehenden Ruf den konfigurierten Firefox-Tab nach
/// vorn (<see cref="WebSocketConfig.IncomingCallTabTitle"/>): 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>
/// Verteilt die neu eingelesenen Adressdaten an alle Verbundenen — nur an
/// 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)
.ConfigureAwait(false))?.Ok == true;
/// <summary>
/// Holt den Tab nach vorn, ohne ihn neu zu laden. <c>false</c> auch bei
/// einem aelteren Plugin, das die Aktion <c>activate</c> noch nicht kennt
/// — es antwortet dann mit <c>ok: false</c>.
/// </summary>
public async Task<bool> TryActivateTabAsync(int tabId, CancellationToken cancellationToken)
=> (await RequestAsync(id => new TabRequestMessage(id, "activate", TabId: tabId), cancellationToken)
.ConfigureAwait(false))?.Ok == true;
private async Task<ClientCommand?> RequestAsync(
Func<int, TabRequestMessage> request, CancellationToken cancellationToken)
{
+9 -7
View File
@@ -50,8 +50,9 @@ internal sealed class ClientCommand
public bool? Ok { get; set; }
/// <summary>
/// Bei <c>closetab</c> (Hauptport): der zu schliessende Tab. Bei
/// <c>tabresult</c> auf <c>open</c> (Plugin-Port): Id des neuen Tabs.
/// Bei <c>closetab</c> und <c>activatetab</c> (Hauptport): der gemeinte
/// Tab. Bei <c>tabresult</c> auf <c>open</c> (Plugin-Port): Id des neuen
/// Tabs.
/// </summary>
public int? TabId { get; set; }
@@ -115,12 +116,13 @@ internal sealed record ResultMessage(
}
/// <summary>
/// Auftrag an das Firefox-Plugin, nur auf dem Plugin-Port. Drei Aktionen:
/// Auftrag an das Firefox-Plugin, nur auf dem Plugin-Port. Vier Aktionen:
/// <c>list</c> (offene Tabs melden), <c>open</c> (neuen Tab mit <c>Url</c>
/// oeffnen), <c>close</c> (Tab <c>TabId</c> schliessen). Das Plugin antwortet
/// jeweils mit <c>{"cmd":"tabresult","id":…,"ok":…}</c> — bei <c>list</c>
/// zusaetzlich mit <c>tabs</c>, bei <c>open</c> mit <c>tabId</c>. Die Id
/// stammt hier vom Server, nicht von einer Webseite.
/// oeffnen), <c>close</c> (Tab <c>TabId</c> schliessen), <c>activate</c>
/// (Tab <c>TabId</c> nach vorn holen, ohne ihn neu zu laden). Das Plugin
/// antwortet jeweils mit <c>{"cmd":"tabresult","id":…,"ok":…}</c> — bei
/// <c>list</c> zusaetzlich mit <c>tabs</c>, bei <c>open</c> mit <c>tabId</c>.
/// Die Id stammt hier vom Server, nicht von einer Webseite.
/// </summary>
internal sealed record TabRequestMessage(int Id, string Action, string? Url = null, int? TabId = null)
{
+51 -1
View File
@@ -36,8 +36,15 @@ internal sealed class WebSocketConfig
/// jedem Neueinlesen (Start, Neuverbindung, alle 60 Minuten). Dazu das
/// Kommando <c>addresses</c>, mit dem eine Seite den Cache jederzeit
/// selbst abrufen kann.
/// Version 9: Tab-Aktion <c>activate</c> auf dem Plugin-Port (Tab nach
/// vorn holen, ohne ihn neu zu laden), als <c>activatetab</c> auch auf dem
/// Hauptport. <c>focus</c> aktiviert einen gefundenen Tab jetzt, statt ihn
/// zu schliessen und neu zu oeffnen (Ersatzweg fuer aeltere Plugins
/// bleibt). Dazu <see cref="IncomingCallTabTitle"/>: bei einem eingehenden
/// Ruf holt SwyxTray den Firefox-Tab mit diesem Titel selbsttaetig nach
/// vorn.
/// </summary>
public const int ProtocolVersion = 8;
public const int ProtocolVersion = 9;
/// <summary>
/// ZUR ZEIT ABGESCHALTET: Token-, Origin- und Host-Pruefung entfallen —
@@ -99,6 +106,24 @@ internal sealed class WebSocketConfig
public int MaxConnections { get; set; } = 8;
/// <summary>
/// Beginnt eine Leitung zu klingeln, holt SwyxTray den ersten Firefox-Tab,
/// dessen Titel diesen Text enthaelt (ohne Beachtung der Gross-/
/// Kleinschreibung), ueber das Plugin nach vorn — ohne ihn neu zu laden,
/// damit der Web-Client seine WebSocket-Verbindung behaelt. Leer = aus.
/// Ohne Plugin oder ohne passenden Tab passiert nichts; es wird bewusst
/// kein neuer Tab geoeffnet.
/// </summary>
public string IncomingCallTabTitle { get; set; } = "SwyxWeb";
/// <summary>
/// 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>
/// 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
@@ -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()
{
if (Port is < 1 or > 65535)
@@ -168,6 +213,11 @@ internal sealed class WebSocketConfig
MaxConnections = 1;
}
if (IncomingCallFocusDelayMs < 0)
{
IncomingCallFocusDelayMs = 0;
}
// Solange die Pruefung abgeschaltet ist, darf ein fehlendes oder kurzes
// Token den Start nicht verhindern.
if (Token.Length < 16 && !SecurityDisabled)