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>
240 lines
8.7 KiB
C#
240 lines
8.7 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using SwyxTray.Swyx;
|
|
|
|
namespace SwyxTray.Web;
|
|
|
|
/// <summary>
|
|
/// Die ueber den WebSocket ausgetauschten Nachrichten. Alles ist UTF-8-JSON,
|
|
/// eine Nachricht je WebSocket-Frame.
|
|
///
|
|
/// Leitungen werden nach aussen 1-basiert gezaehlt ("Leitung 1"), so wie sie im
|
|
/// Kontextmenue und in SwyxIt! erscheinen — intern sind sie 0-basiert.
|
|
/// </summary>
|
|
internal static class Protocol
|
|
{
|
|
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
|
|
{
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
};
|
|
|
|
public static string Serialize<T>(T message) => JsonSerializer.Serialize(message, Json);
|
|
}
|
|
|
|
/// <summary>Nachricht der Webseite an die App.</summary>
|
|
internal sealed class ClientCommand
|
|
{
|
|
/// <summary>Frei waehlbar; wird in der Antwort zurueckgegeben.</summary>
|
|
public int? Id { get; set; }
|
|
|
|
public string? Cmd { get; set; }
|
|
|
|
/// <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; }
|
|
|
|
/// <summary>Nur bei <c>focus</c>: gesuchter Fenstertitel.</summary>
|
|
public string? Title { get; set; }
|
|
|
|
/// <summary>
|
|
/// Bei <c>focus</c> und <c>opentab</c>: die zu oeffnende Adresse.
|
|
/// </summary>
|
|
public string? Url { get; set; }
|
|
|
|
/// <summary>Nur bei <c>tabresult</c> (Plugin-Port): Auftrag ausgefuehrt?</summary>
|
|
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.
|
|
/// </summary>
|
|
public int? TabId { get; set; }
|
|
|
|
/// <summary>Nur bei <c>tabresult</c> auf <c>list</c>: die offenen Tabs.</summary>
|
|
public List<TabInfo>? Tabs { get; set; }
|
|
}
|
|
|
|
/// <summary>Ein offener Tab, wie ihn das Plugin bei <c>list</c> meldet.</summary>
|
|
internal sealed class TabInfo
|
|
{
|
|
public int? Id { get; set; }
|
|
public string? Title { get; set; }
|
|
public string? Url { get; set; }
|
|
public bool? Active { get; set; }
|
|
}
|
|
|
|
/// <summary>Begruessung unmittelbar nach dem Verbindungsaufbau.</summary>
|
|
internal sealed record HelloMessage(string App, string Version, int Protocol, int Session)
|
|
{
|
|
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>,
|
|
/// <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,
|
|
IReadOnlyList<ContactMessage>? Contacts = null, string? Name = null,
|
|
IReadOnlyList<ContactMessage>? Addresses = null)
|
|
{
|
|
public string Type => "result";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Auftrag an das Firefox-Plugin, nur auf dem Plugin-Port. Drei 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.
|
|
/// </summary>
|
|
internal sealed record TabRequestMessage(int Id, string Action, string? Url = null, int? TabId = null)
|
|
{
|
|
public string Type => "tab";
|
|
}
|
|
|
|
internal sealed record LineMessage(
|
|
int Line,
|
|
string State,
|
|
int StateCode,
|
|
string StateText,
|
|
string Peer,
|
|
string PeerNumber,
|
|
string PeerName,
|
|
bool Busy,
|
|
bool Selected);
|
|
|
|
/// <summary>
|
|
/// Anruf-Ereignis, abgeleitet aus dem Vergleich zweier aufeinanderfolgender
|
|
/// Zustaende. Wird zusaetzlich zum snapshot gesendet, damit eine Seite auf
|
|
/// eingehende Rufe reagieren kann, ohne selbst Zustaende zu vergleichen.
|
|
///
|
|
/// <c>Event</c> ist <c>incoming</c> (Leitung beginnt zu klingeln),
|
|
/// <c>outgoing</c> (Wahl beginnt), <c>connected</c> (Gespraech steht) oder
|
|
/// <c>ended</c> (Leitung wieder frei). Bei <c>connected</c> steht in
|
|
/// <c>Direction</c>, ob der Ruf ein- oder ausgehend war, sofern erkennbar;
|
|
/// bei <c>ended</c> stammen die Peer-Angaben aus dem letzten belegten Zustand.
|
|
/// </summary>
|
|
internal sealed record CallEventMessage(
|
|
string Event,
|
|
int Line,
|
|
string Peer,
|
|
string PeerNumber,
|
|
string PeerName,
|
|
string? Direction = null)
|
|
{
|
|
public string Type => "call";
|
|
|
|
public static IReadOnlyList<CallEventMessage> Diff(SwyxSnapshot previous, SwyxSnapshot current)
|
|
{
|
|
var events = new List<CallEventMessage>();
|
|
var before = previous.Lines.ToDictionary(l => l.Index);
|
|
|
|
foreach (var line in current.Lines)
|
|
{
|
|
before.TryGetValue(line.Index, out var prev);
|
|
var wasRinging = prev?.State.IsRinging() ?? false;
|
|
var wasDialing = prev?.State.IsDialing() ?? false;
|
|
var wasTalking = prev is not null && (prev.State.IsActive() || prev.State.IsOnHold());
|
|
|
|
if (line.State.IsRinging() && !wasRinging)
|
|
{
|
|
events.Add(From("incoming", line));
|
|
}
|
|
else if (line.State.IsDialing() && !wasDialing && !wasRinging && !wasTalking)
|
|
{
|
|
events.Add(From("outgoing", line));
|
|
}
|
|
else if (line.State.IsActive() && !wasTalking)
|
|
{
|
|
// Halten und Zurueckholen ist kein neues Gespraech; der Wechsel
|
|
// aus Klingeln oder Wahl dagegen schon.
|
|
events.Add(From("connected", line,
|
|
wasRinging ? "incoming" : wasDialing ? "outgoing" : null));
|
|
}
|
|
}
|
|
|
|
var after = current.Lines.ToDictionary(l => l.Index);
|
|
foreach (var prev in previous.Lines.Where(l => l.IsBusy))
|
|
{
|
|
if (!after.TryGetValue(prev.Index, out var line) || !line.IsBusy)
|
|
{
|
|
events.Add(From("ended", prev));
|
|
}
|
|
}
|
|
|
|
return events;
|
|
}
|
|
|
|
private static CallEventMessage From(string evt, SwyxLineInfo line, string? direction = null)
|
|
=> new(evt, line.Index + 1, line.PeerDisplayLong, line.PeerNumber, line.PeerName, direction);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Vollstaendiger Zustand. Wird nach dem hello, bei jeder Aenderung und auf
|
|
/// <c>status</c> gesendet — die Webseite muss also nie einen Zustand mitfuehren.
|
|
/// </summary>
|
|
internal sealed record SnapshotMessage(
|
|
bool Connected,
|
|
bool ServerUp,
|
|
string Overall,
|
|
string StatusText,
|
|
string User,
|
|
string Server,
|
|
IReadOnlyList<LineMessage> Lines)
|
|
{
|
|
public string Type => "snapshot";
|
|
|
|
public static SnapshotMessage From(SwyxSnapshot snapshot) => new(
|
|
Connected: snapshot.IsConnected,
|
|
ServerUp: snapshot.IsServerUp,
|
|
Overall: snapshot.Overall.ToString(),
|
|
StatusText: snapshot.StatusText,
|
|
User: snapshot.UserName,
|
|
Server: snapshot.ServerName,
|
|
Lines: snapshot.Lines.Select(l => new LineMessage(
|
|
Line: l.Index + 1,
|
|
State: l.State.ToString(),
|
|
StateCode: (int)l.State,
|
|
StateText: l.State.ToDisplayText(),
|
|
Peer: l.PeerDisplayLong,
|
|
PeerNumber: l.PeerNumber,
|
|
PeerName: l.PeerName,
|
|
Busy: l.IsBusy,
|
|
Selected: l.IsSelected)).ToList());
|
|
}
|