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:
2026-08-24 15:10:13 +02:00
co-authored by Claude Fable 5
parent cd355c4e21
commit fcd0d62d78
48 changed files with 427 additions and 17014 deletions
+247
View File
@@ -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!.");
+7
View File
@@ -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,
+22 -168
View File
@@ -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();
+20
View File
@@ -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));
+25 -21
View File
@@ -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
+33 -3
View File
@@ -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";
}
+9 -1
View File
@@ -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 —
+13 -2
View File
@@ -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