Adressdaten aus dem Swyx-Client; core-Modul und Firefox-Plugin ausgelagert
SwyxTray liest jetzt die Telefonbuecher (global und persoenlich) des SwyxIt!-Clients in einen Adress-Cache, bietet Kontaktsuche ueber FulltextSearchInContactsEx und Nummernaufloesung ueber DispResolveNumber; alles ueber den WebSocket-Zugang abrufbar. Das Spring-Boot-Modul core/ und das SwyxFFPlugin werden als eigene Projekte gepflegt und aus diesem Repository entfernt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using System.Collections;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using IpPbx.CLMgrLib;
|
||||
@@ -36,8 +37,23 @@ internal sealed class SwyxClient : IDisposable
|
||||
/// <summary>Wird nur ausgeloest, wenn sich der Zustand tatsaechlich geaendert hat.</summary>
|
||||
public event Action<SwyxSnapshot, SwyxSnapshot>? SnapshotChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Wird nach jedem erfolgreichen Neueinlesen der Adressdaten ausgeloest —
|
||||
/// auch ohne inhaltliche Aenderung, damit die Abnehmer den frischen Stand
|
||||
/// zugestellt bekommen.
|
||||
/// </summary>
|
||||
public event Action<IReadOnlyList<SwyxContact>>? AddressesChanged;
|
||||
|
||||
public SwyxSnapshot Current { get; private set; } = SwyxSnapshot.Offline;
|
||||
|
||||
/// <summary>
|
||||
/// Die Adressdaten aus dem Swyx-Client (globales und persoenliches
|
||||
/// Telefonbuch), gelesen beim Verbindungsaufbau und danach zyklisch.
|
||||
/// Bleibt bei Lesefehlern auf dem letzten erfolgreichen Stand — ein
|
||||
/// veralteter Cache ist besser als ein leerer.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SwyxContact> Addresses { get; private set; } = Array.Empty<SwyxContact>();
|
||||
|
||||
public bool IsConnected => _disp is not null;
|
||||
|
||||
/// <summary>
|
||||
@@ -91,6 +107,10 @@ internal sealed class SwyxClient : IDisposable
|
||||
|
||||
Log.Info($"Mit CLMgr verbunden (UserId {_userId}, Benutzer '{disp.DispGetCurrentUser}', " +
|
||||
$"Server '{disp.DispGetCurrentServer}', {disp.DispNumberOfLines} Leitungen).");
|
||||
|
||||
// Adressdaten direkt nach dem Verbindungsaufbau einlesen — damit
|
||||
// deckt derselbe Weg den Programmstart und jede Neuverbindung ab.
|
||||
RefreshAddresses();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is COMException or UnauthorizedAccessException)
|
||||
@@ -347,6 +367,233 @@ internal sealed class SwyxClient : IDisposable
|
||||
return index + 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Obergrenze fuer eine Kontaktantwort. Die Suche des Clients kennt keine
|
||||
/// eigene Grenze; ohne Deckel koennte eine breite Anfrage ("e") tausende
|
||||
/// Eintraege in eine WebSocket-Nachricht giessen.
|
||||
/// </summary>
|
||||
private const int MaxContactResults = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Liest die Adressdaten (globales und persoenliches Telefonbuch) neu aus
|
||||
/// dem Swyx-Client und aktualisiert den Cache. Wirft nicht: bei einem
|
||||
/// Lesefehler bleibt der letzte erfolgreiche Stand erhalten, gemeldet wird
|
||||
/// der Fehler im Protokoll. Nach jedem erfolgreichen Lesen feuert
|
||||
/// <see cref="AddressesChanged"/>.
|
||||
/// </summary>
|
||||
public void RefreshAddresses()
|
||||
{
|
||||
var disp = _disp;
|
||||
if (disp is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<SwyxContact> addresses;
|
||||
try
|
||||
{
|
||||
addresses = ReadAddresses(disp);
|
||||
}
|
||||
catch (Exception ex) when (ex is COMException or InvalidCastException
|
||||
or Microsoft.CSharp.RuntimeBinder.RuntimeBinderException)
|
||||
{
|
||||
Log.Error("Adressdaten konnten nicht gelesen werden; der Cache behaelt " +
|
||||
$"seinen letzten Stand ({Addresses.Count} Eintraege).", ex);
|
||||
return;
|
||||
}
|
||||
|
||||
var changed = !addresses.SequenceEqual(Addresses);
|
||||
Addresses = addresses;
|
||||
Log.Info($"Adressdaten gelesen: {addresses.Count} Eintraege" +
|
||||
(changed ? "." : " (unveraendert)."));
|
||||
AddressesChanged?.Invoke(Addresses);
|
||||
}
|
||||
|
||||
private static List<SwyxContact> ReadAddresses(IClientLineMgrDisp disp)
|
||||
{
|
||||
var addresses = new List<SwyxContact>();
|
||||
|
||||
if (disp.DispClientConfig is not IClientConfig config)
|
||||
{
|
||||
Log.Debug("DispClientConfig liefert keine IClientConfig — keine Adressdaten.");
|
||||
return addresses;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
AppendPhoneBook(config.PbxPhoneBookEnumerator, addresses, "Globales Telefonbuch", hasDescription: true);
|
||||
AppendPhoneBook(config.UserPhoneBookEnumerator, addresses, "Persoenliches Telefonbuch", hasDescription: false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FinalReleaseComObject(config);
|
||||
}
|
||||
|
||||
// Stabile Reihenfolge: der Vergleich mit dem Cache und die Anzeige
|
||||
// sollen nicht davon abhaengen, in welcher Folge CLMgr liefert.
|
||||
addresses.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
|
||||
return addresses;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sammelt die Eintraege eines Telefonbuchs ein. Versteckte Eintraege
|
||||
/// (<c>Hide</c>) bleiben aussen vor; sie zeigt auch SwyxIt! nicht an.
|
||||
///
|
||||
/// CLMgr liefert die Telefonbuecher als reine IDispatch-Objekte: weder die
|
||||
/// Sammlung noch ihre Eintraege implementieren die typisierten
|
||||
/// Interop-Schnittstellen (<c>IDispCollection</c>,
|
||||
/// <c>IPbxPhoneBookEntryDisp</c>) — ein Cast darauf scheitert still und
|
||||
/// ergibt 0 Eintraege. Aufzaehlbar ist die Sammlung aber ueber
|
||||
/// <c>DISPID_NEWENUM</c> (<see cref="IEnumerable"/>), und die Eigenschaften
|
||||
/// der Eintraege sind per Late-Binding erreichbar. Gegen SwyxIt! 14.21
|
||||
/// geprueft: 404 Eintraege im globalen Telefonbuch.
|
||||
/// </summary>
|
||||
/// <param name="source">Quelle fuer <see cref="SwyxContact.Description"/>,
|
||||
/// falls der Eintrag keine eigene Beschreibung hat.</param>
|
||||
/// <param name="hasDescription">Nur das globale Telefonbuch fuehrt eine
|
||||
/// <c>Description</c>; beim persoenlichen wuerde der Zugriff scheitern.</param>
|
||||
private static void AppendPhoneBook(object? raw, List<SwyxContact> addresses, string source, bool hasDescription)
|
||||
{
|
||||
if (raw is null)
|
||||
{
|
||||
Log.Debug($"{source}: CLMgr liefert keine Sammlung.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (raw is not IEnumerable collection)
|
||||
{
|
||||
Log.Error($"{source}: Sammlung ist nicht aufzaehlbar ({raw.GetType().Name}).");
|
||||
ReleaseIfCom(raw);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var item in collection)
|
||||
{
|
||||
if (item is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
dynamic entry = item;
|
||||
if ((int)entry.Hide != 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string name = entry.Name as string ?? string.Empty;
|
||||
string number = entry.Number as string ?? string.Empty;
|
||||
string description = hasDescription ? (entry.Description as string ?? string.Empty) : string.Empty;
|
||||
|
||||
addresses.Add(new SwyxContact(
|
||||
Name: name,
|
||||
Number: number,
|
||||
Description: string.IsNullOrWhiteSpace(description) ? source : description));
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseIfCom(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseIfCom(raw);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReleaseIfCom(object obj)
|
||||
{
|
||||
if (Marshal.IsComObject(obj))
|
||||
{
|
||||
Marshal.FinalReleaseComObject(obj);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Durchsucht die Adressdaten ueber den Swyx-Client: globales und
|
||||
/// persoenliches Telefonbuch sowie die im Client eingerichteten
|
||||
/// Kontakt-Plugins, jeweils auch nach Rufnummern. Die Suche selbst macht
|
||||
/// der Client — hier wird nur das Ergebnis eingesammelt.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SwyxContact> SearchContacts(string? query)
|
||||
{
|
||||
var disp = RequireConnection();
|
||||
|
||||
var term = (query ?? string.Empty).Trim();
|
||||
if (term.Length is 0 or > 128)
|
||||
{
|
||||
throw new ArgumentException("Feld 'query' fehlt oder ist zu lang (max. 128 Zeichen).");
|
||||
}
|
||||
|
||||
disp.FulltextSearchInContactsEx(term,
|
||||
bSearchInPhonebook: 1, bSearchInPlugins: 1, bSearchInNumbers: 1,
|
||||
out var raw);
|
||||
|
||||
if (raw is not INameNumberSearchResultCollection collection)
|
||||
{
|
||||
return Array.Empty<SwyxContact>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var contacts = new List<SwyxContact>(Math.Min(collection.Count, MaxContactResults));
|
||||
|
||||
foreach (var item in collection)
|
||||
{
|
||||
if (item is not INameNumberSearchResult result)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (contacts.Count >= MaxContactResults)
|
||||
{
|
||||
Log.Info($"Kontaktsuche '{term}': mehr als {MaxContactResults} Treffer, " +
|
||||
"Antwort wird abgeschnitten.");
|
||||
break;
|
||||
}
|
||||
|
||||
contacts.Add(new SwyxContact(
|
||||
Name: result.Name ?? string.Empty,
|
||||
Number: result.Number ?? string.Empty,
|
||||
Description: result.Description ?? string.Empty));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FinalReleaseComObject(result);
|
||||
}
|
||||
}
|
||||
|
||||
Log.Debug($"Kontaktsuche '{term}': {contacts.Count} Treffer.");
|
||||
return contacts;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FinalReleaseComObject(collection);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loest eine Rufnummer ueber die Adressdaten des Clients in einen Namen
|
||||
/// auf — derselbe Weg, ueber den auch SwyxIt! Anrufer beschriftet.
|
||||
/// Liefert eine leere Zeichenkette, wenn der Client nichts findet.
|
||||
/// </summary>
|
||||
public string ResolveNumber(string? number)
|
||||
{
|
||||
var disp = RequireConnection();
|
||||
var dialstring = SanitizeNumber(number);
|
||||
var resolved = disp.DispResolveNumber(dialstring) ?? string.Empty;
|
||||
|
||||
// Kennt der Client die Nummer nicht, gibt er sie unveraendert zurueck —
|
||||
// das ist kein Name und wird deshalb als "nichts gefunden" gemeldet.
|
||||
return resolved == dialstring ? string.Empty : resolved;
|
||||
}
|
||||
|
||||
private IClientLineMgrDisp RequireConnection()
|
||||
=> _disp ?? throw new InvalidOperationException("Keine Verbindung zu SwyxIt!.");
|
||||
|
||||
|
||||
@@ -52,6 +52,13 @@ public sealed record SwyxLineInfo(
|
||||
public bool IsBusy => !State.IsIdle();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Adressdatensatz aus der Kontaktsuche des Clients. <c>Description</c>
|
||||
/// nennt die Quelle bzw. Zusatzangaben, so wie der Client sie liefert
|
||||
/// (z. B. Telefonbuch- oder Plugin-Herkunft).
|
||||
/// </summary>
|
||||
public sealed record SwyxContact(string Name, string Number, string Description);
|
||||
|
||||
/// <summary>Momentaufnahme des gesamten Clients.</summary>
|
||||
public sealed record SwyxSnapshot(
|
||||
bool IsConnected,
|
||||
|
||||
@@ -23,21 +23,22 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
/// </summary>
|
||||
private static readonly TimeSpan PollingOnlyInterval = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Zyklus fuer das Neueinlesen der Adressdaten aus dem Swyx-Client. Der
|
||||
/// erste Abruf laeuft direkt beim Verbindungsaufbau (siehe
|
||||
/// <see cref="SwyxClient"/>); dieser Zyklus haelt den Cache aktuell.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan AddressRefreshInterval = TimeSpan.FromMinutes(60);
|
||||
|
||||
private readonly NotifyIcon _notifyIcon;
|
||||
private readonly SwyxClient _client;
|
||||
private readonly System.Windows.Forms.Timer _safetyTimer;
|
||||
private readonly System.Windows.Forms.Timer _addressTimer;
|
||||
private readonly ToolStripMenuItem _statusItem;
|
||||
private readonly ToolStripMenuItem _linesItem;
|
||||
private readonly ToolStripSeparator _linesSeparator;
|
||||
private readonly ToolStripMenuItem _webSocketItem;
|
||||
private readonly ToolStripMenuItem _copyEndpointItem;
|
||||
private readonly ToolStripMenuItem _readTabsItem;
|
||||
private readonly ToolStripMenuItem _openTabItem;
|
||||
private readonly ToolStripMenuItem _closeTabItem;
|
||||
|
||||
/// <summary>Ziel der Menuepunkte "Tab oeffnen" und "Tab schliessen".</summary>
|
||||
private const string AssecutorUrl = "https://www.assecutor.de";
|
||||
private const string AssecutorTitle = "Assecutor Data Service GmbH";
|
||||
|
||||
private LocalWebSocketServer? _server;
|
||||
private bool _disposed;
|
||||
@@ -58,13 +59,6 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
(_, _) => OpenWithShell(WebSocketConfig.FilePath))
|
||||
});
|
||||
|
||||
_readTabsItem = new ToolStripMenuItem(
|
||||
"Lese Tabs", null, (s, _) => RunTabAction((ToolStripMenuItem)s!, ReadTabsAsync)) { Enabled = false };
|
||||
_openTabItem = new ToolStripMenuItem(
|
||||
"Tab oeffnen", null, (s, _) => RunTabAction((ToolStripMenuItem)s!, OpenAssecutorTabAsync)) { Enabled = false };
|
||||
_closeTabItem = new ToolStripMenuItem(
|
||||
"Tab schliessen", null, (s, _) => RunTabAction((ToolStripMenuItem)s!, CloseAssecutorTabAsync)) { Enabled = false };
|
||||
|
||||
var menu = new ContextMenuStrip();
|
||||
menu.Items.AddRange(new ToolStripItem[]
|
||||
{
|
||||
@@ -72,9 +66,6 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
_linesItem,
|
||||
_linesSeparator,
|
||||
_webSocketItem,
|
||||
_readTabsItem,
|
||||
_openTabItem,
|
||||
_closeTabItem,
|
||||
new ToolStripSeparator(),
|
||||
new ToolStripMenuItem("Neu verbinden", null, (_, _) => Reconnect()),
|
||||
new ToolStripMenuItem("Protokoll oeffnen", null, (_, _) => OpenLog()),
|
||||
@@ -93,6 +84,14 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
|
||||
_client = new SwyxClient();
|
||||
_client.SnapshotChanged += OnSnapshotChanged;
|
||||
_client.AddressesChanged += OnAddressesChanged;
|
||||
|
||||
_addressTimer = new System.Windows.Forms.Timer
|
||||
{
|
||||
Interval = (int)AddressRefreshInterval.TotalMilliseconds
|
||||
};
|
||||
_addressTimer.Tick += (_, _) => _client.RefreshAddresses();
|
||||
_addressTimer.Start();
|
||||
|
||||
_safetyTimer = new System.Windows.Forms.Timer { Interval = (int)SafetyInterval.TotalMilliseconds };
|
||||
_safetyTimer.Tick += (_, _) =>
|
||||
@@ -168,157 +167,6 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
: "WebSocket: aus (siehe Protokoll)";
|
||||
|
||||
_copyEndpointItem.Enabled = _server is { IsRunning: true };
|
||||
_readTabsItem.Enabled = _openTabItem.Enabled = _closeTabItem.Enabled =
|
||||
_server is { IsRunning: true, PluginPort: > 0 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gemeinsamer Rahmen der Tab-Menuepunkte: waehrend der Auftrag beim
|
||||
/// Firefox-Plugin laeuft, ist der Punkt gesperrt — sonst stiesse ein
|
||||
/// zweiter Klick eine zweite Anfrage an. async void ist hier richtig:
|
||||
/// ein Menue-Handler hat keinen Aufrufer, der auf ein Task warten koennte.
|
||||
/// </summary>
|
||||
private async void RunTabAction(ToolStripMenuItem item, Func<LocalWebSocketServer, Task> action)
|
||||
{
|
||||
if (_server is not { IsRunning: true } server)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
item.Enabled = false;
|
||||
try
|
||||
{
|
||||
await action(server);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
item.Enabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holt die offenen Tabs vom Firefox-Plugin (ueber den Plugin-Port) und
|
||||
/// zeigt ihre Titel als Sprechblase; die vollstaendige Liste samt Adressen
|
||||
/// steht im Protokoll.
|
||||
/// </summary>
|
||||
private async Task ReadTabsAsync(LocalWebSocketServer server)
|
||||
{
|
||||
var tabs = await server.TryListTabsAsync(CancellationToken.None);
|
||||
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (tabs is null)
|
||||
{
|
||||
_notifyIcon.ShowBalloonTip(5_000, "Lese Tabs",
|
||||
"Kein Firefox-Plugin verbunden oder keine Antwort.", ToolTipIcon.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tabs.Count == 0)
|
||||
{
|
||||
_notifyIcon.ShowBalloonTip(5_000, "Lese Tabs", "Keine Tabs offen.", ToolTipIcon.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Info($"Firefox meldet {tabs.Count} offene(n) Tab(s): " + string.Join(" | ",
|
||||
tabs.Select(t => $"{(t.Active == true ? "*" : "")}{t.Title} <{t.Url}>")));
|
||||
|
||||
// Eine Sprechblase fasst hoechstens 255 Zeichen — die ersten Titel
|
||||
// genuegen, der Rest steht im Protokoll.
|
||||
const int maxShown = 5;
|
||||
var lines = tabs.Take(maxShown)
|
||||
.Select(t => Truncate((t.Active == true ? "▶ " : "• ") + (t.Title ?? "(ohne Titel)"), 40))
|
||||
.ToList();
|
||||
if (tabs.Count > maxShown)
|
||||
{
|
||||
lines.Add($"… und {tabs.Count - maxShown} weitere (siehe Protokoll)");
|
||||
}
|
||||
|
||||
_notifyIcon.ShowBalloonTip(10_000, $"{tabs.Count} offene(r) Firefox-Tab(s)",
|
||||
Truncate(string.Join(Environment.NewLine, lines), 255), ToolTipIcon.Info);
|
||||
}
|
||||
|
||||
/// <summary>Oeffnet ueber das Plugin einen Tab mit der Assecutor-Seite.</summary>
|
||||
private async Task OpenAssecutorTabAsync(LocalWebSocketServer server)
|
||||
{
|
||||
var tabId = await server.TryOpenTabAsync(AssecutorUrl, CancellationToken.None);
|
||||
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (tabId is null)
|
||||
{
|
||||
_notifyIcon.ShowBalloonTip(5_000, "Tab oeffnen",
|
||||
"Kein Firefox-Plugin verbunden oder keine Antwort.", ToolTipIcon.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Info($"Firefox-Tab {tabId} mit {AssecutorUrl} geoeffnet.");
|
||||
_notifyIcon.ShowBalloonTip(5_000, "Tab oeffnen", $"{AssecutorUrl} geoeffnet.", ToolTipIcon.Info);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schliesst die Tabs mit dem Assecutor-Titel — wie beim focus-Kommando
|
||||
/// als Komposition aus den Grundauftraegen des Kanals: erst <c>list</c>,
|
||||
/// dann je Treffer ein <c>close</c> mit der Tab-Id. So laesst sich auch
|
||||
/// unterscheiden, ob das Plugin fehlt oder nur kein Tab passt.
|
||||
/// </summary>
|
||||
private async Task CloseAssecutorTabAsync(LocalWebSocketServer server)
|
||||
{
|
||||
var tabs = await server.TryListTabsAsync(CancellationToken.None);
|
||||
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (tabs is null)
|
||||
{
|
||||
_notifyIcon.ShowBalloonTip(5_000, "Tab schliessen",
|
||||
"Kein Firefox-Plugin verbunden oder keine Antwort.", ToolTipIcon.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var matching = tabs
|
||||
.Where(t => t.Id is not null
|
||||
&& t.Title?.Contains(AssecutorTitle, StringComparison.OrdinalIgnoreCase) == true)
|
||||
.ToList();
|
||||
|
||||
if (matching.Count == 0)
|
||||
{
|
||||
_notifyIcon.ShowBalloonTip(5_000, "Tab schliessen",
|
||||
$"Kein Tab \"{AssecutorTitle}\" offen.", ToolTipIcon.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var closed = 0;
|
||||
foreach (var tab in matching)
|
||||
{
|
||||
if (await server.TryCloseTabAsync(tab.Id!.Value, CancellationToken.None))
|
||||
{
|
||||
closed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Info($"Firefox-Tab(s) \"{AssecutorTitle}\": {closed} von {matching.Count} geschlossen.");
|
||||
_notifyIcon.ShowBalloonTip(5_000, "Tab schliessen",
|
||||
closed == matching.Count
|
||||
? $"Tab \"{AssecutorTitle}\" geschlossen."
|
||||
: $"Nur {closed} von {matching.Count} Tabs geschlossen (siehe Protokoll).",
|
||||
closed == matching.Count ? ToolTipIcon.Info : ToolTipIcon.Warning);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -370,6 +218,9 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
_server?.Broadcast(previous, current);
|
||||
}
|
||||
|
||||
private void OnAddressesChanged(IReadOnlyList<SwyxContact> addresses)
|
||||
=> _server?.BroadcastAddresses(addresses);
|
||||
|
||||
private void UpdateIcon(SwyxSnapshot s)
|
||||
{
|
||||
_notifyIcon.Icon = TrayIcons.For(s.Overall);
|
||||
@@ -490,8 +341,11 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
_disposed = true;
|
||||
_safetyTimer.Stop();
|
||||
_safetyTimer.Dispose();
|
||||
_addressTimer.Stop();
|
||||
_addressTimer.Dispose();
|
||||
_server?.Dispose();
|
||||
_client.SnapshotChanged -= OnSnapshotChanged;
|
||||
_client.AddressesChanged -= OnAddressesChanged;
|
||||
_client.Dispose();
|
||||
_notifyIcon.Visible = false;
|
||||
_notifyIcon.ContextMenuStrip?.Dispose();
|
||||
|
||||
@@ -30,6 +30,10 @@ internal sealed class CommandExecutor
|
||||
public Task<SwyxSnapshot> GetSnapshotAsync(CancellationToken cancellationToken)
|
||||
=> RunAsync(() => _client.Current, cancellationToken);
|
||||
|
||||
/// <summary>Die zwischengespeicherten Adressdaten aus dem Swyx-Client.</summary>
|
||||
public Task<IReadOnlyList<SwyxContact>> GetAddressesAsync(CancellationToken cancellationToken)
|
||||
=> RunAsync(() => _client.Addresses, cancellationToken);
|
||||
|
||||
public async Task<ResultMessage> ExecuteAsync(ClientCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -59,6 +63,22 @@ internal sealed class CommandExecutor
|
||||
Line: await RunAsync(() => _client.Hold(command.Line), cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
|
||||
case "addresses":
|
||||
return new ResultMessage(command.Id, true,
|
||||
Addresses: (await GetAddressesAsync(cancellationToken).ConfigureAwait(false))
|
||||
.Select(ContactMessage.From).ToList());
|
||||
|
||||
case "contacts":
|
||||
return new ResultMessage(command.Id, true,
|
||||
Contacts: (await RunAsync(() => _client.SearchContacts(command.Query), cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
.Select(ContactMessage.From).ToList());
|
||||
|
||||
case "resolve":
|
||||
return new ResultMessage(command.Id, true,
|
||||
Name: await RunAsync(() => _client.ResolveNumber(command.Number), cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
|
||||
case "focus":
|
||||
return new ResultMessage(command.Id, true,
|
||||
Focused: await FocusAsync(command, cancellationToken).ConfigureAwait(false));
|
||||
|
||||
@@ -59,27 +59,6 @@ internal sealed class LocalWebSocketServer : IDisposable
|
||||
|
||||
public bool IsRemote => _config.AllowRemoteAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Fragt die offenen Tabs beim Firefox-Plugin ab. <c>null</c>, wenn kein
|
||||
/// Plugin verbunden ist, keine Antwort kommt oder es den Auftrag ablehnt.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<TabInfo>?> TryListTabsAsync(CancellationToken cancellationToken)
|
||||
=> _tabChannel.TryListTabsAsync(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Oeffnet ueber das Firefox-Plugin einen neuen Tab mit der URL.
|
||||
/// <c>null</c> bei fehlendem Plugin, Zeitablauf oder Ablehnung.
|
||||
/// </summary>
|
||||
public Task<int?> TryOpenTabAsync(string url, CancellationToken cancellationToken)
|
||||
=> _tabChannel.TryOpenTabAsync(url, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Schliesst ueber das Firefox-Plugin den Tab mit dieser Id.
|
||||
/// <c>true</c> nur bei bestaetigtem Erfolg.
|
||||
/// </summary>
|
||||
public Task<bool> TryCloseTabAsync(int tabId, CancellationToken cancellationToken)
|
||||
=> _tabChannel.TryCloseTabAsync(tabId, cancellationToken);
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (!_config.Enabled)
|
||||
@@ -336,6 +315,31 @@ internal sealed class LocalWebSocketServer : IDisposable
|
||||
/// <summary>Verteilt nur den Zustand, ohne Anruf-Ereignisse abzuleiten.</summary>
|
||||
public void Broadcast(SwyxSnapshot snapshot) => Broadcast(snapshot, snapshot);
|
||||
|
||||
/// <summary>
|
||||
/// Verteilt die neu eingelesenen Adressdaten an alle Verbundenen — nur an
|
||||
/// den Hauptport, wie die Zustandsmeldungen. Neue Verbindungen bekommen
|
||||
/// den Cache ohnehin direkt nach dem snapshot zugestellt.
|
||||
/// </summary>
|
||||
public void BroadcastAddresses(IReadOnlyList<SwyxContact> addresses)
|
||||
{
|
||||
if (_sessions.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var message = Protocol.Serialize(AddressesMessage.From(addresses));
|
||||
|
||||
foreach (var session in _sessions.Values)
|
||||
{
|
||||
if (session.IsPlugin)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_ = session.TrySendManyAsync([message], _shutdown.Token);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verteilt einen Zustand samt der daraus abgeleiteten Anruf-Ereignisse an
|
||||
/// alle Verbundenen — erst der snapshot, dann die Ereignisse, damit der
|
||||
|
||||
@@ -29,9 +29,12 @@ internal sealed class ClientCommand
|
||||
|
||||
public string? Cmd { get; set; }
|
||||
|
||||
/// <summary>Nur bei <c>call</c>.</summary>
|
||||
/// <summary>Bei <c>call</c> und <c>resolve</c>.</summary>
|
||||
public string? Number { get; set; }
|
||||
|
||||
/// <summary>Nur bei <c>contacts</c>: der Suchbegriff (Name oder Nummer).</summary>
|
||||
public string? Query { get; set; }
|
||||
|
||||
/// <summary>1-basierte Leitung; fehlt sie, waehlt die App selbst.</summary>
|
||||
public int? Line { get; set; }
|
||||
|
||||
@@ -71,15 +74,42 @@ internal sealed record HelloMessage(string App, string Version, int Protocol, in
|
||||
public string Type => "hello";
|
||||
}
|
||||
|
||||
/// <summary>Ein Adressdatensatz in der Antwort auf <c>contacts</c> und in
|
||||
/// der <c>addresses</c>-Nachricht.</summary>
|
||||
internal sealed record ContactMessage(string Name, string Number, string Description)
|
||||
{
|
||||
public static ContactMessage From(SwyxContact contact)
|
||||
=> new(contact.Name, contact.Number, contact.Description);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die zwischengespeicherten Adressdaten aus dem Swyx-Client (globales und
|
||||
/// persoenliches Telefonbuch). Wird nach dem <c>snapshot</c> beim Verbinden,
|
||||
/// auf <c>status</c> und nach jedem Neueinlesen mit Aenderung gesendet — die
|
||||
/// Seite muss die Daten also nie selbst anfordern.
|
||||
/// </summary>
|
||||
internal sealed record AddressesMessage(IReadOnlyList<ContactMessage> Addresses)
|
||||
{
|
||||
public string Type => "addresses";
|
||||
|
||||
public static AddressesMessage From(IEnumerable<SwyxContact> addresses)
|
||||
=> new(addresses.Select(ContactMessage.From).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Antwort auf genau ein Kommando. <c>Focused</c> wird nur bei <c>focus</c>
|
||||
/// gesetzt: <c>true</c> = Fenster in den Vordergrund geholt, <c>false</c> =
|
||||
/// stattdessen den Browser mit der URL gestartet. <c>Tabs</c> steht nur in
|
||||
/// der Antwort auf <c>tabs</c>, <c>TabId</c> nur in der auf <c>opentab</c>.
|
||||
/// der Antwort auf <c>tabs</c>, <c>TabId</c> nur in der auf <c>opentab</c>,
|
||||
/// <c>Contacts</c> nur in der auf <c>contacts</c>, <c>Name</c> nur in der
|
||||
/// auf <c>resolve</c> (leer, wenn der Client die Nummer nicht kennt),
|
||||
/// <c>Addresses</c> nur in der auf <c>addresses</c> (der komplette Cache).
|
||||
/// </summary>
|
||||
internal sealed record ResultMessage(
|
||||
int? Id, bool Ok, string? Error = null, int? Line = null, bool? Focused = null,
|
||||
int? TabId = null, IReadOnlyList<TabInfo>? Tabs = null)
|
||||
int? TabId = null, IReadOnlyList<TabInfo>? Tabs = null,
|
||||
IReadOnlyList<ContactMessage>? Contacts = null, string? Name = null,
|
||||
IReadOnlyList<ContactMessage>? Addresses = null)
|
||||
{
|
||||
public string Type => "result";
|
||||
}
|
||||
|
||||
@@ -28,8 +28,16 @@ internal sealed class WebSocketConfig
|
||||
/// <c>open</c> und <c>close</c> aufgeteilt.
|
||||
/// Version 6: die Tab-Verwaltung steht als <c>tabs</c>, <c>opentab</c>
|
||||
/// und <c>closetab</c> auch Webseiten auf dem Hauptport offen.
|
||||
/// Version 7: Adressdaten aus dem Swyx-Client — <c>contacts</c>
|
||||
/// (Kontaktsuche in Telefonbuechern und Kontakt-Plugins) und
|
||||
/// <c>resolve</c> (Rufnummer zu Name).
|
||||
/// Version 8: <c>addresses</c>-Nachricht mit den zwischengespeicherten
|
||||
/// Telefonbuecher-Eintraegen — beim Verbinden, auf <c>status</c> und nach
|
||||
/// jedem Neueinlesen (Start, Neuverbindung, alle 60 Minuten). Dazu das
|
||||
/// Kommando <c>addresses</c>, mit dem eine Seite den Cache jederzeit
|
||||
/// selbst abrufen kann.
|
||||
/// </summary>
|
||||
public const int ProtocolVersion = 6;
|
||||
public const int ProtocolVersion = 8;
|
||||
|
||||
/// <summary>
|
||||
/// ZUR ZEIT ABGESCHALTET: Token-, Origin- und Host-Pruefung entfallen —
|
||||
|
||||
@@ -54,6 +54,7 @@ internal sealed class WebSocketSession : IDisposable
|
||||
if (!IsPlugin)
|
||||
{
|
||||
await SendSnapshotAsync(cancellationToken).ConfigureAwait(false);
|
||||
await SendAddressesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var buffer = new byte[8 * 1024];
|
||||
@@ -128,11 +129,13 @@ internal sealed class WebSocketSession : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
// 'status' beantwortet der Server mit dem vollen Zustand; die Quittung
|
||||
// danach haelt den Ablauf fuer den Client einheitlich.
|
||||
// 'status' beantwortet der Server mit dem vollen Zustand samt der
|
||||
// zwischengespeicherten Adressdaten; die Quittung danach haelt den
|
||||
// Ablauf fuer den Client einheitlich.
|
||||
if (command.Cmd.Equals("status", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await SendSnapshotAsync(cancellationToken).ConfigureAwait(false);
|
||||
await SendAddressesAsync(cancellationToken).ConfigureAwait(false);
|
||||
await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, true)), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return;
|
||||
@@ -181,6 +184,14 @@ internal sealed class WebSocketSession : IDisposable
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Stellt der Seite die zwischengespeicherten Adressdaten zu.</summary>
|
||||
private async Task SendAddressesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var addresses = await _executor.GetAddressesAsync(cancellationToken).ConfigureAwait(false);
|
||||
await SendAsync(Protocol.Serialize(AddressesMessage.From(addresses)), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auf einem WebSocket darf immer nur ein Sendevorgang gleichzeitig laufen.
|
||||
/// Da Zustandsmeldungen aus dem UI-Thread und Antworten aus dem
|
||||
|
||||
Reference in New Issue
Block a user