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>
670 lines
32 KiB
Markdown
670 lines
32 KiB
Markdown
# SwyxTray
|
|
|
|
Windows-Anwendung **ohne Fenster**, die nur ein Symbol im Infobereich der
|
|
Taskleiste (unten rechts) anzeigt und sich per Swyx Client SDK mit dem lokal
|
|
laufenden SwyxIt!-Client verbindet.
|
|
|
|
## Was die App tut
|
|
|
|
- Verbindet sich beim Start mit dem **Client Line Manager** (`CLMgr.exe`) der
|
|
aktuellen Windows-Sitzung — es wird kein zweiter Client gestartet, sondern der
|
|
bereits laufende mitbenutzt.
|
|
- Zeigt den Leitungszustand über die Farbe des Tray-Symbols:
|
|
|
|
| Farbe | Zustand |
|
|
|-----------|----------------------------------|
|
|
| grau | SwyxIt! nicht verbunden |
|
|
| rot | SwyxServer nicht erreichbar |
|
|
| grün | bereit, keine belegte Leitung |
|
|
| blau | eingehender Ruf |
|
|
| türkis | Verbindungsaufbau (ausgehend) |
|
|
| orange | Gespräch aktiv |
|
|
| violett | Gespräch gehalten |
|
|
|
|
- Blendet bei eingehenden Rufen eine Sprechblase mit Name und Rufnummer ein.
|
|
- Stellt einen **WebSocket-Zugang auf 127.0.0.1** bereit, über den eine im
|
|
Browser laufende Seite wählen, annehmen und auflegen kann und im Gegenzug
|
|
jede Zustandsänderung zugestellt bekommt (siehe unten).
|
|
- Kontextmenü (Rechtsklick): Status, belegte Leitungen, *WebSocket-Zugang*,
|
|
*Neu verbinden*, *Protokoll öffnen*, *Beenden*. Doppelklick zeigt den
|
|
Status als Sprechblase.
|
|
|
|
## Verwendetes SDK
|
|
|
|
Offizielles NuGet-Paket von Enreach:
|
|
|
|
```xml
|
|
<PackageReference Include="Swyx.Client.ClmgrAPI" Version="14.21.0" />
|
|
```
|
|
|
|
Das Paket enthält die Interop-Wrapper der ClientLineManager-COM-API
|
|
(`IpPbx.CLMgrLib`) und wählt über `$(Platform)` automatisch `Interop.CLMgr.dll`
|
|
(x86) bzw. `Interop64.CLMgr.dll` (x64). **Deshalb muss `Platform` gesetzt sein
|
|
— bei `AnyCPU` bricht der Build mit einem Fehler des Pakets ab.**
|
|
|
|
Die Version 14.21.0 passt zur hier installierten SwyxIt!-Version 14.21.4345.0.
|
|
Bei einem Client-Update die Paketversion mitziehen.
|
|
|
|
Genutzte Schnittstellen:
|
|
|
|
- `IClientLineMgrDisp` — `DispInit`, `DispRegisterUser`, `DispNumberOfLines`,
|
|
`DispGetLine`, `DispSelectedLineNumber`, `DispIsServerUp`,
|
|
`DispGetCurrentUser`, `DispGetCurrentServer`, `DispReleaseUser`,
|
|
`FulltextSearchInContactsEx`, `DispResolveNumber`, `DispClientConfig`
|
|
- `IClientLineDisp` — `DispState`, `DispPeerNumber`, `DispPeerName`
|
|
- `IClientConfig` — `PbxPhoneBookEnumerator`, `UserPhoneBookEnumerator`
|
|
(globales und persönliches Telefonbuch für den Adress-Cache)
|
|
- `INameNumberSearchResult(Collection)` — Ergebnistyp der Kontaktsuche.
|
|
Die Telefonbuch-Sammlungen und ihre Einträge kommen dagegen **nicht** als
|
|
`IDispCollection`/`IPbxPhoneBookEntryDisp` an, sondern als reine
|
|
IDispatch-Objekte — sie werden per `IEnumerable` und Late-Binding gelesen
|
|
(siehe „Stand der Prüfung")
|
|
- `IClientLineMgrEventsPub_Event` — `PubOnLineMgrNotification(msg, param)`
|
|
- `CLMgrLineStates` — die Leitungszustände als typisierte Aufzählung
|
|
|
|
## Aufbau
|
|
|
|
| Datei | Zweck |
|
|
|---|---|
|
|
| [Program.cs](src/SwyxTray/Program.cs) | Einstieg, STA-Thread, Einzelinstanz-Sperre, Fehlerprotokollierung |
|
|
| [TrayApplicationContext.cs](src/SwyxTray/TrayApplicationContext.cs) | `NotifyIcon`, Kontextmenü, Sprechblasen |
|
|
| [TrayIcons.cs](src/SwyxTray/TrayIcons.cs) | erzeugt die Symbole zur Laufzeit (keine .ico-Dateien nötig) |
|
|
| [Swyx/SwyxClient.cs](src/SwyxTray/Swyx/SwyxClient.cs) | CLMgr-Verbindung, Ereignisbehandlung, Zustandsabfrage |
|
|
| [Swyx/SwyxModels.cs](src/SwyxTray/Swyx/SwyxModels.cs) | `SwyxSnapshot`, `SwyxLineInfo`, Gesamtzustand |
|
|
| [Swyx/LineStateExtensions.cs](src/SwyxTray/Swyx/LineStateExtensions.cs) | Einordnung und deutscher Klartext der `CLMgrLineStates` |
|
|
| [Swyx/PubCLMgrMessages.cs](src/SwyxTray/Swyx/PubCLMgrMessages.cs) | Meldungsnummern aus `CLMgrPubTypes.h` |
|
|
| [Web/LocalWebSocketServer.cs](src/SwyxTray/Web/LocalWebSocketServer.cs) | Listener auf 127.0.0.1 und ::1, Verbindungsverwaltung, Verteilen der Zustände |
|
|
| [Web/WebSocketHandshake.cs](src/SwyxTray/Web/WebSocketHandshake.cs) | HTTP-Upgrade, Prüfung von Token, Origin und Host |
|
|
| [Web/WebSocketSession.cs](src/SwyxTray/Web/WebSocketSession.cs) | eine Verbindung: Empfangsschleife, serialisiertes Senden |
|
|
| [Web/CommandExecutor.cs](src/SwyxTray/Web/CommandExecutor.cs) | Kommandos → `SwyxClient`, mit Zeitgrenze |
|
|
| [Web/UiDispatcher.cs](src/SwyxTray/Web/UiDispatcher.cs) | Sprung aus den Netzwerk-Threads in den UI-Thread |
|
|
| [Web/Protocol.cs](src/SwyxTray/Web/Protocol.cs) | die JSON-Nachrichten |
|
|
| [Web/WebSocketConfig.cs](src/SwyxTray/Web/WebSocketConfig.cs) | Port, Token, erlaubte Origins |
|
|
| [examples/browser-client.html](examples/browser-client.html) | Beispielseite zum Erproben des Zugangs |
|
|
| [Log.cs](src/SwyxTray/Log.cs) | Dateiprotokoll unter `%LOCALAPPDATA%\SwyxTray\swyxtray.log` |
|
|
|
|
### Zwei Entwurfsentscheidungen
|
|
|
|
**Ereignisgesteuert statt Polling.** `PubOnLineMgrNotification` liefert jede
|
|
Zustandsänderung sofort. Bei *jeder* Meldung wird der komplette Zustand neu
|
|
gelesen — das kostet nur wenige COM-Zugriffe und macht die Anzeige unabhängig
|
|
davon, ob eine Meldungsnummer richtig zugeordnet ist. `PubCLMgrMessages` steckt
|
|
nämlich nur im C-Header des SDK, nicht in der TypeLib. Zusätzlich läuft alle
|
|
5 Sekunden ein Sicherheitszyklus, der einen beendeten CLMgr erkennt und die
|
|
Verbindung wiederherstellt.
|
|
|
|
**Alles im UI-Thread.** CLMgr ist ein STA-COM-Server; der WinForms-Thread ist
|
|
STA und hat bereits eine Nachrichtenschleife. Dadurch werden die COM-Ereignisse
|
|
automatisch dorthin gemarshallt — kein manuelles Marshalling, keine Sperren.
|
|
|
|
### `CoInitializeSecurity` ist nicht optional
|
|
|
|
`CLMgr.exe` ist ein eigener Prozess. Beim Anmelden der Ereignissenke ruft er in
|
|
diesen Prozess **zurück** — und ohne eigene Sicherheitsvorgabe weist COM diesen
|
|
Rückruf mit `E_ACCESSDENIED` ab. [NativeMethods.InitializeComSecurity](src/SwyxTray/NativeMethods.cs)
|
|
setzt deshalb vor dem ersten COM-Aufruf `RPC_C_AUTHN_LEVEL_NONE` /
|
|
`RPC_C_IMP_LEVEL_IDENTIFY`. Das betrifft nur diesen Prozess, der außer der
|
|
Ereignissenke nichts nach außen anbietet. Wer es enger fassen will, probiert
|
|
`RPC_C_AUTHN_LEVEL_CONNECT` (2) — das genügt, solange CLMgr in derselben Sitzung
|
|
unter demselben Konto läuft.
|
|
|
|
Schlägt das Anmelden trotzdem fehl, ist das **kein** Abbruchgrund: die App bleibt
|
|
verbunden und schaltet den Abfragezyklus von 5 s auf 1 s herunter.
|
|
|
|
## Zugriff aus dem Browser (WebSocket)
|
|
|
|
Die App hört auf `ws://127.0.0.1:17654/` und spricht dort JSON — eine
|
|
Nachricht je Frame. Über dieselbe Verbindung laufen beide Richtungen: die Seite
|
|
schickt Kommandos, die App schickt unaufgefordert jede Zustandsänderung.
|
|
|
|
### Einrichten
|
|
|
|
Beim ersten Start entsteht `%LOCALAPPDATA%\SwyxTray\websocket.json`:
|
|
|
|
```json
|
|
{
|
|
"Enabled": true,
|
|
"Port": 17654,
|
|
"PluginPort": 17655,
|
|
"Token": "E59BF4…",
|
|
"AllowedOrigins": [],
|
|
"MaxConnections": 8,
|
|
"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.
|
|
|
|
Das Token wird zufällig erzeugt, die Liste der Origins bleibt leer — **ohne
|
|
Eintrag wird jede Verbindung aus einem Browser abgewiesen.** Das ist Absicht,
|
|
denn ein offener Port ohne diese Schranke hieße: jede beliebige Seite, die
|
|
jemand im Browser öffnet, kann Anrufe auslösen. Einzutragen ist der Ursprung
|
|
der aufrufenden Seite, also z. B. `https://crm.example.local` — ohne Pfad. Nach
|
|
einer Änderung muss die App neu gestartet werden.
|
|
|
|
*WebSocket-Zugang → Verbindungsdaten kopieren* im Kontextmenü legt Adresse und
|
|
Token in die Zwischenablage.
|
|
|
|
### Verbinden
|
|
|
|
```js
|
|
const ws = new WebSocket("ws://127.0.0.1:17654/?token=…", ["swyxtray.v1"]);
|
|
```
|
|
|
|
Das Token darf statt in der URL auch als zweites Unterprotokoll stehen
|
|
(`["swyxtray.v1", "token.…"]`) — nützlich, wenn das Geheimnis nicht in
|
|
Adresszeilen und Verlaufslisten auftauchen soll. Die WebSocket-API des Browsers
|
|
kennt keine eigenen Header, deshalb diese beiden Wege.
|
|
|
|
Ein vollständiges Beispiel mit Wiederverbindung steht in
|
|
[examples/browser-client.html](examples/browser-client.html).
|
|
|
|
### Firefox-Plugin (SwyxFFPlugin): der Tab-Verwaltungskanal
|
|
|
|
Für das Firefox-Plugin hört die App zusätzlich auf einem **zweiten Port** —
|
|
voreingestellt **17655** (`PluginPort` in `websocket.json`, `0` schaltet ihn
|
|
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 vier Aufträge,
|
|
jeder wird mit einem `tabresult` samt unveränderter `id` beantwortet:
|
|
|
|
**Offene Tabs auslesen:**
|
|
|
|
```json
|
|
→ an das Plugin: {"type":"tab","id":1,"action":"list"}
|
|
← vom Plugin: {"cmd":"tabresult","id":1,"ok":true,
|
|
"tabs":[{"id":42,"title":"Kundenakte Muster GmbH",
|
|
"url":"https://crm.example.local/kunden/4711","active":false}]}
|
|
```
|
|
|
|
**Tab mit einer URL öffnen** (der neue Tab ist in Firefox von selbst vorn):
|
|
|
|
```json
|
|
→ an das Plugin: {"type":"tab","id":2,"action":"open","url":"https://crm.example.local/kunden/4711"}
|
|
← vom Plugin: {"cmd":"tabresult","id":2,"ok":true,"tabId":43}
|
|
```
|
|
|
|
**Tab schließen:**
|
|
|
|
```json
|
|
→ an das Plugin: {"type":"tab","id":3,"action":"close","tabId":42}
|
|
← 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`, `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
|
|
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.
|
|
|
|
Sind die Prüfungen wieder eingeschaltet, braucht die Adresse zusätzlich das
|
|
Token (`ws://127.0.0.1:17655/?token=…`), und in `AllowedOrigins` muss der
|
|
Origin des Plugins stehen. Firefox sendet dafür `moz-extension://<UUID>`,
|
|
wobei die UUID je Installation zufällig vergeben wird — sie steht in
|
|
`about:debugging` unter „Interne UUID“ der Erweiterung.
|
|
|
|
### Kommandos (Seite → App)
|
|
|
|
| Kommando | Felder | Wirkung |
|
|
|---|---|---|
|
|
| `call` | `number` | wählt auf einer freien Leitung |
|
|
| `answer` | `line` (optional) | nimmt an; ohne Angabe die klingelnde Leitung |
|
|
| `hangup` | `line` (optional) | legt auf; ohne Angabe die belegte Leitung |
|
|
| `hold` | `line` (optional) | stellt das Gespräch in die Warteschleife |
|
|
| `focus` | `url`, `title` (optional) | aktiviert oder öffnet den passenden Firefox-Tab über das Plugin |
|
|
| `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) |
|
|
| `status` | — | fordert den vollen Zustand samt der zwischengespeicherten Adressdaten an |
|
|
| `reconnect` | — | verwirft die CLMgr-Verbindung und baut sie neu auf |
|
|
| `ping` | — | Lebenszeichen |
|
|
|
|
`id` ist frei wählbar und steht in der Antwort wieder da — anders als bei HTTP
|
|
gibt es keine eingebaute Zuordnung von Anfrage und Antwort.
|
|
|
|
```json
|
|
→ {"id":7,"cmd":"call","number":"+49 30 1234567"}
|
|
← {"type":"result","id":7,"ok":true,"line":2}
|
|
```
|
|
|
|
Leitungen werden nach außen **1-basiert** gezählt, so wie sie im Kontextmenü und
|
|
in SwyxIt! erscheinen. Rufnummern dürfen Leerzeichen, Bindestriche, Punkte und
|
|
Klammern enthalten; alles andere außer Ziffern, führendem `+`, `*` und `#` wird
|
|
abgewiesen, statt es an CLMgr weiterzureichen.
|
|
|
|
Ein `ok: true` auf `call` heißt nur, dass CLMgr den Auftrag angenommen hat. Das
|
|
Wählen läuft asynchron — ob der Ruf zustande kommt, steht erst in den folgenden
|
|
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
|
|
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), `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
|
|
Tab-Liste von selbst — wer sie aktuell braucht, fragt mit `tabs` nach:
|
|
|
|
```json
|
|
→ {"id":10,"cmd":"tabs"}
|
|
← {"type":"result","id":10,"ok":true,
|
|
"tabs":[{"id":42,"title":"Kundenakte Muster GmbH",
|
|
"url":"https://crm.example.local/kunden/4711","active":false}]}
|
|
|
|
→ {"id":11,"cmd":"opentab","url":"https://crm.example.local/kunden/4711"}
|
|
← {"type":"result","id":11,"ok":true,"tabId":43}
|
|
|
|
→ {"id":12,"cmd":"closetab","tabId":43}
|
|
← {"type":"result","id":12,"ok":true}
|
|
```
|
|
|
|
### Meldungen (App → Seite)
|
|
|
|
Direkt nach dem Verbinden kommen `hello`, ein `snapshot` und die
|
|
zwischengespeicherten Adressdaten als `addresses`, danach ein `snapshot` bei
|
|
**jeder** Zustandsänderung. Die Seite muss also nie pollen und nie selbst
|
|
Zustand mitführen:
|
|
|
|
```json
|
|
{"type":"snapshot","connected":true,"serverUp":true,"overall":"Ringing",
|
|
"statusText":"Eingehender Ruf","user":"…","server":"…",
|
|
"lines":[{"line":1,"state":"LSRinging","stateCode":3,"stateText":"klingelt",
|
|
"peer":"Muster GmbH (+493012345)","peerNumber":"+493012345",
|
|
"peerName":"Muster GmbH","busy":true,"selected":true}]}
|
|
```
|
|
|
|
`overall` ist derselbe Gesamtzustand, den auch die Farbe des Tray-Symbols zeigt.
|
|
|
|
**Adressdaten** (seit Protokollversion 8): Die App liest beim Verbindungsaufbau
|
|
zu SwyxIt! — also beim Start und bei jeder Neuverbindung — sowie danach **alle
|
|
60 Minuten** das globale und das persönliche Telefonbuch aus dem Swyx-Client
|
|
und hält sie im Speicher vor. Jede verbundene Seite bekommt den Stand als
|
|
`addresses`-Nachricht zugestellt: direkt nach dem `snapshot` beim Verbinden,
|
|
auf ein `status`-Kommando und unaufgefordert **nach jedem Neueinlesen**. Wer
|
|
den Cache selbst abfragen will, sendet das Kommando `addresses` und erhält
|
|
die Einträge im gleichnamigen Feld der Antwort. Bei einem Lesefehler behält
|
|
der Cache den letzten erfolgreichen Stand.
|
|
|
|
```json
|
|
{"type":"addresses","addresses":[
|
|
{"name":"Muster GmbH","number":"+493012345","description":"Globales Telefonbuch"},
|
|
{"name":"Schmidt, Anna","number":"123","description":"Persoenliches Telefonbuch"}]}
|
|
```
|
|
|
|
Zusätzlich zum `snapshot` meldet die App **Anruf-Ereignisse** als eigene
|
|
Nachricht vom Typ `call` — damit muss eine Seite keine zwei Zustände
|
|
vergleichen, um einen eingehenden Ruf zu erkennen:
|
|
|
|
| `event` | Bedeutung |
|
|
|---|---|
|
|
| `incoming` | Leitung beginnt zu klingeln (auch Anklopfen) |
|
|
| `outgoing` | Wahl eines ausgehenden Rufs beginnt |
|
|
| `connected` | Gespräch steht; `direction` sagt `incoming`/`outgoing`, sofern erkennbar |
|
|
| `ended` | Leitung ist wieder frei; Peer-Angaben aus dem letzten belegten Zustand |
|
|
|
|
```json
|
|
{"type":"call","event":"incoming","line":1,
|
|
"peer":"Muster GmbH (+493012345)","peerNumber":"+493012345","peerName":"Muster GmbH"}
|
|
```
|
|
|
|
Die Reihenfolge ist garantiert: erst der `snapshot`, unmittelbar danach die
|
|
daraus abgeleiteten `call`-Ereignisse. Ein typischer Ablauf für ein
|
|
angenommenes Gespräch ist also `incoming` → `connected` (mit
|
|
`"direction":"incoming"`) → `ended`, jeweils mit einem `snapshot` davor.
|
|
Wählt die Seite selbst per Kommando `call`, folgen `outgoing` → `connected` →
|
|
`ended` auf demselben Weg. Diese Ereignisse gibt es seit Protokollversion 2
|
|
(Feld `protocol` im `hello`).
|
|
|
|
### Was den Zugang absichert
|
|
|
|
> **Zur Zeit greift davon nur die Bindung.** Token-, Origin- und Host-Prüfung
|
|
> sind für die Erprobung abgeschaltet, und `AllowRemoteAccess` steht auf `true`
|
|
> — siehe die beiden folgenden Abschnitte. Der Rest dieses Abschnitts
|
|
> beschreibt den eingeschalteten Zustand.
|
|
|
|
Vier Schranken, die zusammen wirken:
|
|
|
|
1. **Gebunden nur an 127.0.0.1 und ::1.** Von außen ist der Port nicht
|
|
erreichbar, und die Windows-Firewall fragt beim Start nichts nach. Diese
|
|
Schranke fällt mit `AllowRemoteAccess` (siehe unten).
|
|
2. **Token** in jedem Handshake, zeitkonstant verglichen.
|
|
3. **Origin-Allowlist.** Für WebSockets gilt die Same-Origin-Policy *nicht* —
|
|
der Browser blockiert nichts, es gibt keinen Preflight. Diese Prüfung ist
|
|
damit nicht Härtung, sondern die eigentliche Schranke gegen fremde Seiten.
|
|
Fehlt der Origin-Header ganz, ist der Aufrufer kein Browser; das ist
|
|
zulässig, weil das Token bereits nachgewiesen wurde. Eine Webseite kann den
|
|
Header nicht weglassen.
|
|
4. **Host-Prüfung** auf `127.0.0.1`, `localhost` bzw. `[::1]`. Ohne sie könnte
|
|
eine Seite einen eigenen Namen auf 127.0.0.1 zeigen lassen (DNS-Rebinding).
|
|
Entfällt ebenfalls mit `AllowRemoteAccess`.
|
|
|
|
### Zugriff von einem anderen Rechner
|
|
|
|
Mit `"AllowRemoteAccess": true` bindet die App statt an die Loopback-Adressen an
|
|
**alle Schnittstellen** (`0.0.0.0` und `::`) und ist damit im lokalen Netz
|
|
erreichbar:
|
|
|
|
```js
|
|
new WebSocket("ws://192.168.180.135:17654/?token=…");
|
|
```
|
|
|
|
Die erreichbaren Adressen stehen beim Start im Protokoll, und
|
|
*Verbindungsdaten kopieren* liefert dann ebenfalls die LAN-Adresse statt
|
|
127.0.0.1.
|
|
|
|
Drei Dinge ändern sich damit:
|
|
|
|
- **Die Host-Prüfung entfällt.** Unter welchem Namen oder welcher Adresse ein
|
|
entfernter Rechner die App anspricht, kann sie nicht wissen. Der Schutz gegen
|
|
DNS-Rebinding ruht damit allein auf Token und Origin-Liste.
|
|
- **Die Windows-Firewall redet mit.** Ist sie aktiv, braucht es eine
|
|
eingehende Regel, sonst kommt vom anderen Rechner nichts an:
|
|
|
|
```powershell
|
|
New-NetFirewallRule -DisplayName "SwyxTray WebSocket" -Direction Inbound `
|
|
-Protocol TCP -LocalPort 17654 -Action Allow
|
|
```
|
|
|
|
Das erfordert Administratorrechte — die einzige Stelle des Vorhabens, an der
|
|
sie nötig sind, und ein Grund, warum Loopback die Voreinstellung bleibt.
|
|
- **`ws://` gilt aus der Ferne nicht mehr als vertrauenswürdig.** Die
|
|
Ausnahme für Mixed Content gilt nur für Loopback. Eine **HTTPS**-Seite auf dem
|
|
anderen Rechner kann `ws://192.168.…` also nicht öffnen — für den Test muss
|
|
die Seite über `http://` kommen, oder es braucht `wss://` mit einem
|
|
Zertifikat, das die App derzeit nicht anbietet.
|
|
|
|
Wer den Zugang nicht nur zum Testen aus dem Netz erreichbar macht, sollte sich
|
|
bewusst sein: Token und Origin-Liste sind dann das Einzige, was zwischen dem
|
|
Netz und dem Telefon des Benutzers steht.
|
|
|
|
### Zur Zeit sind die Prüfungen abgeschaltet
|
|
|
|
**Für die Erprobung entfallen Token-, Origin- und Host-Prüfung — in jedem
|
|
Build.** Eine Seite verbindet sich ohne jede Einrichtung, auch direkt aus dem
|
|
Dateisystem, wo der Browser den Origin `null` sendet:
|
|
|
|
```js
|
|
new WebSocket("ws://127.0.0.1:17654/");
|
|
```
|
|
|
|
Die Konfigurationsdatei wird nur noch für Port, Verbindungsgrenze und
|
|
`AllowRemoteAccess` gelesen; ein fehlendes Token verhindert den Start nicht.
|
|
|
|
Zusammen mit dem eingeschalteten Zugriff aus dem Netz heißt das: **jeder im
|
|
selben Netz kann über diesen Anschluss telefonieren und mitlesen, wer anruft.**
|
|
In diesem Zustand nicht ausliefern.
|
|
|
|
Erkennbar ist das am Protokoll und am Kontextmenü, das dann
|
|
*WebSocket: Port 17654, 1 Verbindung(en) — im Netz — OHNE PRUEFUNG* zeigt:
|
|
|
|
```
|
|
[INF] WebSocket-Zugang aus dem Netz erreichbar unter ws://192.168.180.135:17654/
|
|
— OHNE PRUEFUNG: Token, Origin und Host werden nicht geprueft. Jeder im
|
|
selben Netz kann telefonieren und mitlesen. Nicht in diesem Zustand
|
|
ausliefern.
|
|
```
|
|
|
|
Wieder einschalten lässt sich alles mit **einer Zeile** in
|
|
[Web/WebSocketConfig.cs](src/SwyxTray/Web/WebSocketConfig.cs) — die Prüfungen
|
|
selbst stehen unverändert in
|
|
[Web/WebSocketHandshake.cs](src/SwyxTray/Web/WebSocketHandshake.cs):
|
|
|
|
```csharp
|
|
public static readonly bool SecurityDisabled = true; // <- zur Zeit
|
|
```
|
|
|
|
Der dort im Kommentar hinterlegte Ersatz macht daraus wieder ein
|
|
`#if DEBUG` — dann prüft der Release-Build alles, während sich der Debug-Build
|
|
weiterhin ohne Einrichtung erproben lässt.
|
|
|
|
### Was im Browser noch dazwischenkommen kann
|
|
|
|
`ws://127.0.0.1` gilt als vertrauenswürdiger Ursprung und wird auch aus einer
|
|
HTTPS-Seite heraus nicht als Mixed Content blockiert. Neuere Chrome-Versionen
|
|
beschränken allerdings Zugriffe aus dem öffentlichen ins lokale Netz (*Local
|
|
Network Access*) und fragen den Benutzer — das lässt sich per
|
|
Unternehmensrichtlinie freigeben, aber nicht durch einen Antwort-Header lösen.
|
|
**Vor dem Rollout mit der IT klären.**
|
|
|
|
Browser lösen `localhost` häufig zuerst nach `::1` auf. Die App hört deshalb auf
|
|
beiden Loopback-Adressen; sonst funktionierte `ws://127.0.0.1:…`, während
|
|
`ws://localhost:…` ohne erkennbaren Grund scheiterte.
|
|
|
|
### Zwei Entwurfsentscheidungen
|
|
|
|
**Kein `HttpListener`.** Der läuft über http.sys und braucht auch für
|
|
127.0.0.1 eine einmalige URL-Reservierung mit Administratorrechten
|
|
(`netsh http add urlacl`). Die App soll ohne solche Rechte auskommen, deshalb
|
|
ein schlichter `TcpListener`. Vom WebSocket-Protokoll bleibt dabei nur der
|
|
HTTP-Upgrade von Hand zu schreiben — Rahmen, Maskierung, Ping/Pong und der
|
|
Schließvorgang kommen von `WebSocket.CreateFromStream` aus der
|
|
Klassenbibliothek.
|
|
|
|
**Kommandos laufen über den UI-Thread.** CLMgr ist ein STA-COM-Server, und die
|
|
App bündelt alle COM-Zugriffe im WinForms-Thread. Der
|
|
[UiDispatcher](src/SwyxTray/Web/UiDispatcher.cs) schleust jedes Kommando aus dem
|
|
Netzwerk-Thread dorthin, mit einer Zeitgrenze von 10 Sekunden: bliebe die
|
|
Nachrichtenschleife hängen, fröre die Verbindung sonst stumm ein.
|
|
|
|
## Bauen und Starten
|
|
|
|
```powershell
|
|
cd src\SwyxTray
|
|
dotnet build -c Release
|
|
.\bin\x64\Release\net10.0-windows\win-x64\SwyxTray.exe --verbose
|
|
```
|
|
|
|
`--verbose` schreibt zusätzlich jede CLMgr-Meldung ins Protokoll — für die
|
|
erste Inbetriebnahme empfehlenswert.
|
|
|
|
### Aus VS Code (F5)
|
|
|
|
Die Startkonfiguration **„SwyxTray debuggen (Desktop-Sitzung)"** in
|
|
[.vscode/launch.json](.vscode/launch.json) baut, startet und hängt den Debugger
|
|
an. Sie startet die App über eine geplante Aufgabe
|
|
([.vscode/start-desktop.ps1](.vscode/start-desktop.ps1)) — nötig, weil bei
|
|
einer Remote-Verbindung (SSH, Tunnel) der Debugger Prozesse sonst in der
|
|
Sitzung des VS-Code-Servers startet, meist Sitzung 0. Dort gibt es keine
|
|
Taskleiste, die App läuft dann ohne sichtbares Tray-Symbol. Die geplante
|
|
Aufgabe läuft dagegen mit dem interaktiven Token des Benutzers und landet auf
|
|
dem Desktop; danach hängt sich der Debugger per Prozessname an. Eine bereits
|
|
laufende Instanz wird vorher beendet, sonst griffe die Einzelinstanz-Sperre.
|
|
|
|
Autostart: eine Verknüpfung auf die EXE in
|
|
`shell:startup` (`%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup`)
|
|
ablegen. Kein Dienst, keine Administratorrechte.
|
|
|
|
### Voraussetzungen
|
|
|
|
- SwyxIt! muss laufen (getestet gegen 14.21.4345.0).
|
|
- **.NET-10-Desktop-Laufzeit.** Auf diesem Rechner ist nur .NET 10 installiert,
|
|
deshalb steht `net10.0-windows` in der csproj. Für .NET 8 einfach
|
|
`<TargetFramework>net8.0-windows</TargetFramework>` setzen. Alternativ ohne
|
|
Laufzeitabhängigkeit ausliefern:
|
|
|
|
```powershell
|
|
dotnet publish -c Release -r win-x64 --self-contained true
|
|
```
|
|
|
|
- Die App läuft **in derselben Windows-Sitzung wie SwyxIt!** — als normale
|
|
Benutzeranwendung, nicht als Dienst (siehe unten).
|
|
|
|
## Warum kein Windows-Dienst
|
|
|
|
Ursprünglich war ein Windows-Dienst vorgesehen. Das funktioniert für diesen
|
|
Zweck nicht: ein Dienst läuft in **Session 0**, der SwyxIt!-Client und sein
|
|
`CLMgr.exe` in der Benutzersitzung (1, 2, …). Über die Sitzungsgrenze hinweg
|
|
lässt sich der laufende Client nicht ansprechen. Ein Dienst müsste dafür einen
|
|
Hilfsprozess per `CreateProcessAsUser` in die Benutzersitzung starten und mit
|
|
ihm über eine Named Pipe reden. Die Tray-Anwendung umgeht das Problem
|
|
vollständig, weil sie von vornherein in der richtigen Sitzung läuft.
|
|
|
|
## Stand der Prüfung
|
|
|
|
- **Übersetzung gegen das echte SDK: erfolgreich.** Alle verwendeten Typen und
|
|
Signaturen stammen aus `Interop64.CLMgr.dll` (Version 2.0.0.0) des Pakets und
|
|
wurden gegen die installierte TypeLib `CLMgr 2.0`
|
|
(`{F8E552F7-4C00-11D3-80BC-00105A653379}`, registriert auf
|
|
`C:\Program Files (x86)\Swyx\SwyxIt!\CLMgr.exe`) verifiziert.
|
|
**Gegen den echten, laufenden Client verifiziert** (SwyxIt! 14.21.4345.0):
|
|
|
|
```
|
|
[INF] SwyxTray gestartet (PID 3348, Sitzung 0, x64).
|
|
[INF] Mit CLMgr verbunden (UserId 0, Benutzer 'Assecutor, G.Timm',
|
|
Server '127.0.0.1', 4 Leitungen).
|
|
[DBG] Zustand: Idle | L1=LSInactive(0) * | L2=LSInactive(0)
|
|
| L3=LSInactive(0) | L4=LSInactive(0)
|
|
```
|
|
|
|
Bestätigt sind damit: COM-Aktivierung, `DispInit`, `DispRegisterUser`, Anmelden
|
|
der Ereignissenke, Auslesen aller vier Leitungen samt Zustand und ausgewählter
|
|
Leitung, Ableitung des Gesamtzustands, Symbolerzeugung, Änderungserkennung
|
|
(keine Doppelmeldungen über mehrere Zyklen) und das Dateiprotokoll.
|
|
|
|
**Adressdaten gegen den laufenden Client verifiziert:** Das globale Telefonbuch
|
|
wird mit allen **404 Einträgen** gelesen (`Adressdaten gelesen: 404 Einträge`,
|
|
knapp eine Sekunde nach dem Verbindungsaufbau). Dabei zeigte sich, dass CLMgr
|
|
die Telefonbücher als reine IDispatch-Objekte liefert: weder die Sammlung noch
|
|
ihre Einträge implementieren die typisierten Interop-Schnittstellen
|
|
(`IDispCollection`, `IPbxPhoneBookEntryDisp`) — ein Cast darauf scheitert still
|
|
und ergibt 0 Einträge. Gelesen wird deshalb per `IEnumerable` und Late-Binding
|
|
(`dynamic`), siehe `AppendPhoneBook` in
|
|
[Swyx/SwyxClient.cs](src/SwyxTray/Swyx/SwyxClient.cs).
|
|
|
|
**WebSocket-Zugang gegen die laufende App geprüft** (mit einem
|
|
`ClientWebSocket` als Gegenstelle):
|
|
|
|
```
|
|
[INF] WebSocket-Zugang bereit auf ws://127.0.0.1:17654/
|
|
[INF] WebSocket-Anfrage von 127.0.0.1:51312 abgewiesen (403): Token fehlt oder ist falsch.
|
|
[INF] WebSocket-Anfrage von 127.0.0.1:51313 abgewiesen (403): Origin 'https://boese.example'
|
|
ist nicht freigegeben.
|
|
[INF] WebSocket 1 verbunden (127.0.0.1:51314, Origin (ohne Origin)).
|
|
[INF] WebSocket 2 verbunden ([::1]:64293, Origin https://test.example).
|
|
```
|
|
|
|
Bestätigt sind damit: Handshake samt Unterprotokoll-Aushandlung, Token- und
|
|
Origin-Prüfung in beide Richtungen, Erreichbarkeit über `127.0.0.1` *und*
|
|
`localhost`/`::1`, `hello` und `snapshot` beim Verbinden, die Kommandos `ping`,
|
|
`status`, `hangup`, das Abweisen ungültiger Rufnummern, unbekannter Kommandos
|
|
und kaputten JSONs sowie der Schließvorgang.
|
|
|
|
Geprüft wurde außerdem der jetzige Zustand mit abgeschalteten Prüfungen: der
|
|
**Release-Build** nimmt eine Verbindung über die LAN-Adresse ohne Token und mit
|
|
fremdem Origin an. Zuvor, mit eingeschalteten Prüfungen, wies er dieselbe
|
|
Anfrage mit 403 ab, während der Debug-Build sie annahm — beide Zweige sind also
|
|
erprobt.
|
|
|
|
Mit `"AllowRemoteAccess": true` lauscht die App auf `0.0.0.0:17654` und
|
|
`[::]:17654` (bestätigt über `Get-NetTCPConnection`); eine Verbindung über die
|
|
LAN-Adresse `ws://192.168.180.135:17654/` wird angenommen, dieselbe mit falschem
|
|
Token weiterhin abgewiesen. **Der Test lief vom selben Rechner aus** — dass ein
|
|
entfernter Rechner durchkommt, hängt zusätzlich an der Firewall. Auf dem
|
|
Testrechner ist sie in allen Profilen abgeschaltet, dort war keine Regel nötig.
|
|
|
|
**Noch nicht am realen Verkehr geprüft:** Zustandswechsel während eines echten
|
|
Gesprächs — Klingeln, Annehmen, Halten, Auflegen — und damit die Sprechblase bei
|
|
eingehenden Rufen, der Farbwechsel des Symbols und das Verteilen der
|
|
Zustandsmeldungen an verbundene Seiten. Dafür ist ein Testanruf nötig; `call`
|
|
wurde bewusst nur mit ungültigen Nummern erprobt, um keinen echten Ruf
|
|
auszulösen. Ebenso ungeprüft ist das Symbol im Infobereich selbst, weil die
|
|
verwendete Konsole in Session 0 lief und dort keine Taskleiste existiert — und
|
|
damit auch die Beispielseite in einem echten Browser.
|
|
|
|
### Zwei Hürden auf dem Weg dorthin
|
|
|
|
Beide betrafen die Umgebung, nicht den Code, sind aber für die Auslieferung
|
|
relevant:
|
|
|
|
1. **Smart App Control blockierte den COM-Proxy.** Jedes `QueryInterface` über
|
|
die Prozessgrenze lädt `C:\Program Files\Common Files\Swyx\CLMgrPs64.dll` in
|
|
den eigenen Prozess; genau diese Swyx-Datei wurde abgewiesen (`0x800711C7`,
|
|
CodeIntegrity 3033/3077/3118, *Enterprise signing level requirements*).
|
|
Das Abschalten von Smart App Control wirkt erst **nach einem Neustart** —
|
|
der Registry-Wert steht sofort auf `0`, `Win32_DeviceGuard` meldet aber bis
|
|
zum Reboot weiter Erzwingung. **Läuft beim Kunden WDAC, muss `CLMgrPs64.dll`
|
|
im Regelwerk freigegeben sein** — das trifft jede Anwendung, die den
|
|
Swyx-Proxy lädt.
|
|
2. **`IConnectionPoint::Advise` scheiterte mit `E_ACCESSDENIED`** — gelöst durch
|
|
`CoInitializeSecurity` (siehe oben).
|
|
|
|
## Quellen
|
|
|
|
- [INFO: SwyxIt! Client SDK — Enreach Help-Center](https://service.swyx.net/hc/en-gb/articles/360011686580-INFO-SwyxIt-Client-SDK)
|
|
- [SwyxIt! Client SDK — API-Referenz](https://clientsdk.swyx.engineering/)
|
|
- [IClientLineMgrEventsPub](https://clientsdk.swyx.engineering/interface_i_client_line_mgr_events_pub.html)
|
|
- [IClientLineMgrPub](https://clientsdk.swyx.engineering/interface_i_client_line_mgr_pub.html)
|
|
- [CLMgrPubTypes.h — PubCLMgrMessages](https://clientsdk.swyx.engineering/_c_l_mgr_pub_types_8h.html)
|
|
- [NuGet: Swyx.Client.ClmgrAPI](https://www.nuget.org/packages/Swyx.Client.ClmgrAPI)
|