SwyxTray und Firefox-Plugin in das Repository aufnehmen

Windows-Tray-Anwendung (CLMgr-Anbindung an SwyxIt!) samt WebSocket-Zugang,
Firefox-Erweiterung und Browser-Beispielclient.
This commit is contained in:
2026-08-21 11:33:52 +02:00
commit 78b17a1350
28 changed files with 4396 additions and 0 deletions
+209
View File
@@ -0,0 +1,209 @@
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>Nur bei <c>call</c>.</summary>
public string? Number { 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>
/// 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>.
/// </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)
{
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());
}