diff --git a/README.md b/README.md
index dce194a..2a11fab 100644
--- a/README.md
+++ b/README.md
@@ -667,3 +667,110 @@ relevant:
- [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)
+
+## Gespraechsaufnahme (WebSocket-Protokoll 10)
+
+Aufnahmebefehle verwenden die 1-basierte `line` **und** `callId` aus dem aktuellen
+Snapshot. `callId` stammt aus `IClientLineDisp.DispCallId`. Ein alter Auftrag wird
+abgewiesen, wenn inzwischen ein anderer Anruf dieselbe Leitung belegt.
+
+```json
+{"id":21,"cmd":"recording.start","line":1,"callId":4711}
+{"type":"result","id":21,"ok":true,"line":1,"callId":4711,"recordingRequested":true}
+{"id":22,"cmd":"recording.stop","line":1,"callId":4711}
+{"type":"result","id":22,"ok":true,"line":1,"callId":4711,"recordingRequested":false}
+{"type":"call","event":"ended","line":1,"callId":4711,"peer":"Test","peerNumber":"123","peerName":"Test"}
+```
+
+`recording.start` ruft `DispStartRecording()` auf, sobald eine Sprechverbindung
+besteht. Ein wiederholter Start desselben Anrufs ist wirkungslos. Stoppen ruft
+`DispStopRecording()` auf, auch bei gehaltenen Gespraechen oder wenn die Aufnahme
+in SwyxIt! selbst gestartet wurde. Fehler des SDK werden als `ok:false` gemeldet.
+Alle COM-Zugriffe laufen weiterhin im STA-Thread.
+
+`recordingRequested` in den Leitungs-Snapshots bezeichnet den letzten erfolgreich
+an das SDK uebergebenen Auftrag dieser App. Das SDK stellt keinen lesbaren
+Aufnahmestatus bereit; das Feld bestaetigt weder eine geschriebene Datei noch
+manuelle Aenderungen in SwyxIt!. Dateiname und Speicherort bestimmt SwyxIt!
+entsprechend seiner Aufnahmekonfiguration. Die SDK-Funktion setzt den normalen
+Betriebsmodus und die Aufnahmeberechtigung voraus; eine Audiodatei wird nicht
+ueber WebSocket uebertragen.
+
+SwyxWeb sendet `answer` mit der Anrufkennung, wartet auf die aktive
+Sprechverbindung desselben Anrufs und sendet dann `recording.start`. Ein Fehler
+beim Start aendert nichts an der erfolgreichen Anrufannahme. Auflegen, Abbruch,
+Wiederverwendung der Leitung und ein Timeout verhindern den verspaeteten Start.
+`call/ended` folgt weiterhin auf den Snapshot und enthaelt die Kennung und
+Gegenstelle des beendeten Anrufs. Ein Ausfall von CLMgr/Server wird nicht als
+nachgewiesenes Gespraechsende gemeldet.
+
+Pruefung ohne Telefonate oder COM-Aufrufe:
+
+```powershell
+dotnet build src/SwyxTray/SwyxTray.csproj -p:Platform=x64
+dotnet run --project tests/SwyxTray.RecordingChecks -p:Platform=x64
+```
+
+API-Grundlage: [Swyx Client SDK, IClientLineDisp](https://www.swyxdownload.com/download/kb3373_SwyxClientSDKManual.pdf),
+Methodensignaturen gegen das installierte NuGet-Paket 14.21.0 geprueft.
+
+
+## Automatische WAV-Übergabe an SwyxWeb (Protokoll 11)
+
+`recording.start` und `recording.stop` akzeptieren zusätzlich `recordingId` (UUID).
+Snapshots und Aufnahmequittungen geben dieselbe Kennung zurück. Ein doppelter Start
+behält die UUID bei; ein neuer Abschnitt erhält eine neue. Die Zuordnung bleibt bei
+COM-Unterbrechungen erhalten; nur ein bestätigter Stopp oder ein gesunder Snapshot
+mit beendeter/ersetzter Verbindung schließt den Abschnitt ab.
+Der Aufnahmestopp allein gibt die Datei nicht zur Übertragung frei. Erst ein
+gesunder Snapshot mit beendeter oder ersetzter Verbindung bestätigt das
+Gesprächsende für alle Aufnahmeabschnitte dieses Gesprächs. Halten und ein
+Verbindungsabbruch zählen nicht als Ende; die Sperre übersteht einen Neustart.
+Ob SwyxWeb, SwyxIt!, das Telefon oder die Gegenstelle beendet hat, ist dabei
+unerheblich. Auch `LSTerminated`, freigegebene/deaktivierte Leitungen und eine
+neue Anrufkennung lösen die Übergabe ohne vorherigen `hangup`- oder
+`recording.stop`-Befehl aus. Nach einer Unterbrechung genügt die Bestätigung
+des Gesprächsendes im nächsten gesunden Snapshot.
+
+Die neue `RecordingOutbox` liest den Aufnahmeordner aus `IClientConfig` und merkt
+beim Start die bereits vorhandenen WAV-Dateien. Sie bietet nur eindeutig zuordenbare,
+neu erzeugte, geschlossene WAVs mit stabiler Länge und gültigem RIFF-Dateikopf an.
+Mehrdeutige Dateien werden als Fehler gemeldet und müssen in SwyxWeb manuell
+zugeordnet werden. Originaldateien bleiben unverändert. Über SwyxIt! selbst
+begonnene Mitschnitte haben keine automatische Zuordnung.
+
+Warteschlange: `%LOCALAPPDATA%\SwyxTray\recordings` (atomare JSON-Manifeste und WAV-Kopien).
+Unbestätigte Dateien überstehen Neustarts. `recording.ready` wird erst nach
+bestätigtem Gesprächsende gesendet und enthält `transport: "websocket"`,
+`recordingId`, `line`, `callId`, `callEnded: true`, `filename`, `bytes` und Gegenstelle.
+Vorher meldet `recording.status` den Zustand `waiting_call_end`; die WAV darf lokal
+vorbereitet werden, aber `recording.download` und `recording.ack` werden abgewiesen.
+Es wird kein zusätzlicher HTTP-Endpunkt eingerichtet. Auf `recording.download`
+mit UUID und Kommando-ID sendet die bestehende Sitzung binäre Nachrichten:
+`SWYX` (4 ASCII-Bytes), Kommando-ID (Int32 LE), Offset (Int32 LE), maximal 65536 Audio-Bytes.
+Am Ende folgt die normale `result`-Quittung. Telefonbefehle bleiben parallel nutzbar.
+
+SwyxWeb reicht die WAV an sein Backend und von dort an SwyxTrans weiter. Erst danach
+bestätigt der Browser mit `recording.ack`, `recordingId` und `jobId` (32 Hexzeichen).
+Die Bestätigung wird vor dem Entfernen der temporären Kopie gespeichert. Das Original
+wird nie entfernt. `recording.status` zeigt `waiting_call_end`, `waiting`, `error`, `submitted`; bei letzterem
+enthält `jobId` die SwyxTrans-Kennung. Beim Verbinden und auf `status` werden alle offenen
+Aufnahmen und die letzten 100 bestätigten Aufträge wiederholt. SwyxWeb verhindert
+Doppelverarbeitung zusätzlich durch idempotente Uploads. Der Browser muss für die
+Übertragung geöffnet und verbunden sein.
+
+`hangup` akzeptiert zusätzlich `callId` und prüft unmittelbar vor dem SDK-Aufruf,
+ob die Leitung noch genau dieses Gespräch führt. SwyxWeb deaktiviert **Auflegen**
+während des Befehls und wartet auch nach dessen Quittung auf das bestätigte Ende.
+
+Prüfungen ohne COM/Telefonate:
+
+```powershell
+dotnet run --project tests/SwyxTray.RecordingChecks -p:Platform=x64
+dotnet build src/SwyxTray -p:Platform=x64
+```
+
+Die Checks prüfen außerdem Windows-Dateisperren, unvollständige WAVs, mehrdeutige
+Zuordnung, dauerhaft gespeicherte Bestätigungen und den tatsächlichen Binärtransport
+mit Verbindungsabbruch. `-- --serve` startet bei Bedarf eine lokale Browser-Testquelle
+auf Port 17658 mit einer synthetischen WAV, ohne COM-Verbindung und ohne Telefonat.
diff --git a/src/SwyxTray/Recording/RecordingOutbox.cs b/src/SwyxTray/Recording/RecordingOutbox.cs
new file mode 100644
index 0000000..ac128c3
--- /dev/null
+++ b/src/SwyxTray/Recording/RecordingOutbox.cs
@@ -0,0 +1,290 @@
+using System.Buffers.Binary;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using SwyxTray.Swyx;
+using SwyxTray.Web;
+
+namespace SwyxTray.Recording;
+
+internal sealed record RecordingNotice(Guid RecordingId, int Line, int CallId, string PeerNumber,
+ string PeerName, string State, string? Filename = null, long? Bytes = null,
+ string? JobId = null, string? Error = null, bool CallEnded = false)
+{
+ public string Type => State == "ready" ? "recording.ready" : "recording.status";
+ public string Transport => "websocket";
+}
+
+internal sealed record RecordingEntry(Guid Id, int Line, int CallId, string PeerNumber, string PeerName,
+ string Folder, DateTime Started, string[] Baseline, DateTime? Ended = null,
+ string State = "capturing", string? Filename = null, long Bytes = 0,
+ string? JobId = null, string? Error = null, string[]? SourceFiles = null, DateTime? CallEnded = null);
+
+/// Persistent outbox. Original Swyx files are never moved or deleted.
+/// A WAV is only offered after a confirmed call end and an exclusive, stable read.
+internal sealed class RecordingOutbox : IDisposable
+{
+ public const long MaxBytes = 500L * 1024 * 1024;
+ private readonly string _folder;
+ private readonly Func _now;
+ private readonly object _gate = new();
+ private readonly Dictionary _entries = new();
+ private readonly Dictionary _stable = new();
+ private readonly CancellationTokenSource _shutdown = new();
+ private Task? _worker;
+ public event Action? Changed;
+
+ public RecordingOutbox(string folder, Func? now = null)
+ {
+ _folder = folder;
+ _now = now ?? (() => DateTime.UtcNow);
+ Directory.CreateDirectory(folder);
+ foreach (var path in Directory.EnumerateFiles(folder, "*.json"))
+ {
+ try
+ {
+ var entry = JsonSerializer.Deserialize(File.ReadAllText(path), Protocol.Json);
+ if (entry != null && Path.GetFileNameWithoutExtension(path) == entry.Id.ToString())
+ // Older manifests only recorded the recording stop, not the call end.
+ _entries[entry.Id] = entry.CallEnded == null && entry.State == "ready"
+ ? entry with { State = "waiting_call_end" } : entry;
+ }
+ catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
+ { Log.Error("Aufnahme-Warteschlange konnte nicht gelesen werden: " + path, ex); }
+ }
+ }
+
+ public void Start() => _worker ??= Task.Run(async () =>
+ {
+ while (!_shutdown.IsCancellationRequested)
+ {
+ try { Process(); }
+ catch (Exception ex) { Log.Error("Aufnahme-Warteschlange: Verarbeitung fehlgeschlagen.", ex); }
+ try { await Task.Delay(2000, _shutdown.Token); }
+ catch (OperationCanceledException) { break; }
+ }
+ });
+
+ public Guid? CurrentId(int line, int callId)
+ {
+ lock (_gate) return _entries.Values.FirstOrDefault(e => e.Line == line && e.CallId == callId && e.Ended == null)?.Id;
+ }
+
+ public Guid Begin(int line, int callId, string peerNumber, string peerName, string folder, Guid? id)
+ {
+ lock (_gate)
+ {
+ if (CurrentId(line, callId) is { } existing) return existing;
+ var key = id ?? Guid.NewGuid();
+ if (key == Guid.Empty || _entries.ContainsKey(key))
+ throw new InvalidOperationException("Diese Aufnahme-Kennung wurde bereits verwendet.");
+ string[] baseline = [];
+ string? error = null;
+ try { baseline = Wavs(folder).Select(f => f.FullName).ToArray(); }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
+ { error = "Aufnahmeordner ist nicht erreichbar: " + ex.Message; }
+ Put(new(key, line, callId, peerNumber, peerName, folder, _now(), baseline, Error: error));
+ return key;
+ }
+ }
+
+ public void Abort(Guid id)
+ {
+ lock (_gate) { _entries.Remove(id); File.Delete(Manifest(id)); }
+ }
+
+ public void End(int line, int callId)
+ {
+ lock (_gate)
+ {
+ if (CurrentId(line, callId) is not { } id) return;
+ var entry = _entries[id];
+ string[]? sources = null;
+ try
+ {
+ var found = Wavs(entry.Folder).Where(f => Candidate(entry, f)).Select(f => f.FullName).ToArray();
+ if (found.Length > 0) sources = found;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
+ { Log.Debug("Aufnahmedatei wird nach dem Stopp erneut gesucht: " + ex.Message); }
+ Put(entry with { Ended = _now(), SourceFiles = sources,
+ State = entry.Error == null ? "waiting_call_end" : "error" });
+ }
+ }
+
+ public void Observe(SwyxSnapshot snapshot)
+ {
+ // COM/server disconnection is not evidence of a finished call.
+ if (!snapshot.IsConnected || !snapshot.IsServerUp) return;
+ lock (_gate)
+ {
+ foreach (var entry in _entries.Values.Where(e => e.CallEnded == null && e.State != "submitted").ToArray())
+ {
+ var line = snapshot.Lines.FirstOrDefault(l => l.Index + 1 == entry.Line);
+ if (line == null || (line.IsBusy && line.CallId == entry.CallId)) continue;
+ if (entry.Ended == null) End(entry.Line, entry.CallId);
+ var stopped = _entries[entry.Id];
+ Put(stopped with { CallEnded = _now(), State = stopped.Error != null ? "error"
+ : stopped.Bytes > 0 && File.Exists(Wav(entry.Id)) ? "ready" : "waiting" });
+ }
+ }
+ }
+
+ public RecordingNotice[] Notices()
+ {
+ lock (_gate) return _entries.Values.Where(e => e.Ended != null && e.State != "submitted")
+ .Concat(_entries.Values.Where(e => e.State == "submitted").OrderByDescending(e => e.Started).Take(100))
+ .OrderByDescending(e => e.Started).Select(Notice).ToArray();
+ }
+
+ public FileStream Open(Guid id)
+ {
+ lock (_gate)
+ {
+ if (!_entries.TryGetValue(id, out var entry) || entry.State != "ready" || entry.CallEnded == null)
+ throw new InvalidOperationException("Die fertige Aufnahme ist nicht verfügbar.");
+ return new FileStream(Wav(id), FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete);
+ }
+ }
+
+ public void Acknowledge(Guid id, string? jobId)
+ {
+ if (jobId == null || !Regex.IsMatch(jobId, "\\A[a-f0-9]{32}\\z"))
+ throw new InvalidOperationException("Ungültige SwyxTrans-Auftragskennung.");
+ lock (_gate)
+ {
+ if (!_entries.TryGetValue(id, out var entry) || (entry.CallEnded == null && entry.State != "submitted")
+ || entry.State is not ("ready" or "error" or "submitted"))
+ throw new InvalidOperationException("Aufnahme ist noch nicht zur Übergabe bereit.");
+ if (entry.JobId != null && entry.JobId != jobId)
+ throw new InvalidOperationException("Aufnahme wurde bereits einem anderen Auftrag zugeordnet.");
+ Put(entry with { State = "submitted", JobId = jobId, Baseline = [], Error = null });
+ // Persist receipt before removing our temporary copy. Swyx original remains untouched.
+ TryDeleteCopy(id);
+ }
+ }
+
+ public void Process()
+ {
+ RecordingEntry[] pending;
+ lock (_gate) pending = _entries.Values.Where(e => e.Ended != null &&
+ (e.State == "waiting" || (e.State == "waiting_call_end" && e.Bytes == 0))).ToArray();
+ foreach (var entry in pending)
+ {
+ if (_now() - entry.Ended < TimeSpan.FromSeconds(3)) continue;
+ try
+ {
+ // Recover a copy completed immediately before an application crash.
+ if (!File.Exists(Wav(entry.Id))) Prepare(entry);
+ if (!File.Exists(Wav(entry.Id))) continue;
+ lock (_gate)
+ {
+ // Observe may have confirmed the end while the local copy was being made.
+ var current = _entries[entry.Id];
+ Put(current with { State = current.CallEnded != null ? "ready" : "waiting_call_end",
+ Filename = entry.Id + ".wav", Bytes = new FileInfo(Wav(entry.Id)).Length });
+ }
+ }
+ catch (InvalidDataException ex) { Fail(entry, ex.Message); }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ if (_now() - entry.Ended > TimeSpan.FromMinutes(2))
+ Fail(entry, "Die WAV-Datei konnte nicht fertig gelesen werden: " + ex.Message);
+ }
+ }
+ }
+
+ private void Prepare(RecordingEntry entry)
+ {
+ var candidates = Wavs(entry.Folder).Where(f => Candidate(entry, f)).ToArray();
+ if (candidates.Length > 1)
+ throw new InvalidDataException("Mehrere neue WAV-Dateien gefunden. Bitte die Aufnahme manuell zuordnen.");
+ if (candidates.Length == 0)
+ {
+ if (_now() - entry.Ended > TimeSpan.FromMinutes(2))
+ throw new InvalidDataException("Keine neue WAV-Datei gefunden. Bitte Swyx-Aufnahmeordner prüfen oder Datei manuell auswählen.");
+ return;
+ }
+ var file = candidates[0];
+ lock (_gate)
+ {
+ if (_entries.Values.Any(other => other.Id != entry.Id &&
+ string.Equals(other.Folder, entry.Folder, StringComparison.OrdinalIgnoreCase) && Candidate(other, file)))
+ throw new InvalidDataException("WAV-Datei gehört möglicherweise zu mehreren Aufnahmeabschnitten. Bitte manuell zuordnen.");
+ if (!_stable.TryGetValue(entry.Id, out var old) || old.Path != file.FullName ||
+ old.Length != file.Length || old.Written != file.LastWriteTimeUtc)
+ {
+ _stable[entry.Id] = (file.FullName, file.Length, file.LastWriteTimeUtc, _now());
+ return;
+ }
+ if (_now() - old.Seen < TimeSpan.FromSeconds(2)) return;
+ }
+ using var input = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, FileShare.None);
+ ValidateWav(input);
+ input.Position = 0;
+ var temp = Wav(entry.Id) + ".tmp";
+ using (var output = new FileStream(temp, FileMode.Create, FileAccess.Write, FileShare.None))
+ { input.CopyTo(output); output.Flush(true); }
+ File.Move(temp, Wav(entry.Id), true);
+ }
+
+ private static bool Candidate(RecordingEntry e, FileInfo f) =>
+ !e.Baseline.Contains(f.FullName, StringComparer.OrdinalIgnoreCase)
+ && (e.SourceFiles == null || e.SourceFiles.Contains(f.FullName, StringComparer.OrdinalIgnoreCase))
+ && f.CreationTimeUtc >= e.Started.AddSeconds(-2)
+ && (e.Ended == null || f.CreationTimeUtc <= e.Ended.Value.AddSeconds(3));
+
+ private static FileInfo[] Wavs(string folder) => new DirectoryInfo(folder).GetFiles()
+ .Where(f => f.Extension.Equals(".wav", StringComparison.OrdinalIgnoreCase)).ToArray();
+
+ internal static void ValidateWav(Stream input)
+ {
+ if (input.Length < 44 || input.Length > MaxBytes) throw new InvalidDataException("WAV-Datei ist leer oder größer als 500 MB.");
+ Span header = stackalloc byte[12];
+ input.ReadExactly(header);
+ if (!header[..4].SequenceEqual("RIFF"u8) || !header[8..].SequenceEqual("WAVE"u8))
+ throw new InvalidDataException("Die Aufnahme hat keinen gültigen WAV-Dateikopf.");
+ long end = BinaryPrimitives.ReadUInt32LittleEndian(header[4..8]) + 8L;
+ if (end != input.Length) throw new IOException("WAV-Dateikopf ist noch nicht abgeschlossen.");
+ Span chunk = stackalloc byte[8];
+ bool audio = false, format = false;
+ while (input.Position + 8 <= end)
+ {
+ input.ReadExactly(chunk);
+ var length = BinaryPrimitives.ReadUInt32LittleEndian(chunk[4..]);
+ if (input.Position + length > end) throw new IOException("Unvollständiger WAV-Datenblock.");
+ if (chunk[..4].SequenceEqual("fmt "u8) && length >= 16) format = true;
+ if (chunk[..4].SequenceEqual("data"u8) && length > 0) audio = true;
+ input.Position += length + (length % 2);
+ }
+ if (!audio || !format) throw new InvalidDataException("Die WAV-Datei enthält keine Audiodaten.");
+ }
+
+ private void Fail(RecordingEntry e, string error)
+ { lock (_gate) Put(_entries[e.Id] with { State = "error", Error = error }); }
+
+ private string Manifest(Guid id) => Path.Combine(_folder, id + ".json");
+ private string Wav(Guid id) => Path.Combine(_folder, id + ".wav");
+ private static RecordingNotice Notice(RecordingEntry e) =>
+ new(e.Id, e.Line, e.CallId, e.PeerNumber, e.PeerName,
+ e.CallEnded == null && e.State != "submitted" ? "waiting_call_end" : e.State,
+ e.Filename, e.Bytes, e.JobId, e.Error, e.CallEnded != null || e.State == "submitted");
+
+ private void Put(RecordingEntry entry)
+ {
+ var path = Manifest(entry.Id);
+ using (var output = new FileStream(path + ".tmp", FileMode.Create, FileAccess.Write, FileShare.None))
+ { JsonSerializer.Serialize(output, entry, Protocol.Json); output.Flush(true); }
+ File.Move(path + ".tmp", path, true);
+ _entries[entry.Id] = entry;
+ if (entry.Ended != null) Changed?.Invoke(Notice(entry));
+ }
+
+ private void TryDeleteCopy(Guid id)
+ {
+ try { File.Delete(Wav(id)); }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ { Log.Error("Bestätigte Aufnahmekopie konnte nicht entfernt werden.", ex); }
+ }
+
+ public void Dispose() => _shutdown.Cancel();
+}
diff --git a/src/SwyxTray/Swyx/CallRecordingTracker.cs b/src/SwyxTray/Swyx/CallRecordingTracker.cs
new file mode 100644
index 0000000..d3e6762
--- /dev/null
+++ b/src/SwyxTray/Swyx/CallRecordingTracker.cs
@@ -0,0 +1,28 @@
+namespace SwyxTray.Swyx;
+
+/// Merkt ausschliesslich erfolgreich an CLMgr uebergebene Aufnahmeauftraege.
+/// Das SDK liefert keinen Aufnahmezustand; dies ist keine Dateischreibbestaetigung.
+internal sealed class CallRecordingTracker
+{
+ private readonly HashSet<(int Line, int CallId)> _requested = new();
+
+ public bool IsRequested(int line, int callId) => _requested.Contains((line, callId));
+
+ public void Set(int line, int expectedCallId, int actualCallId, bool active, bool held,
+ bool start, Action invokeSdk)
+ {
+ if (expectedCallId != actualCallId)
+ throw new InvalidOperationException("Der Anruf auf dieser Leitung hat sich geaendert.");
+ if (start ? !active : !(active || held))
+ throw new InvalidOperationException("Keine passende Sprechverbindung fuer die Aufnahme.");
+ // Wiederholtes Starten darf weder neu beginnen noch eine zweite Datei erzeugen.
+ // Stop immer ans SDK: auch ausserhalb der Schnittstelle gestartete Mitschnitte stoppen.
+ if (start && IsRequested(line, actualCallId)) return;
+ invokeSdk();
+ if (start) _requested.Add((line, actualCallId));
+ else _requested.Remove((line, actualCallId));
+ }
+
+ public void Retain(IEnumerable<(int Line, int CallId)> calls)
+ => _requested.IntersectWith(calls);
+}
diff --git a/src/SwyxTray/Swyx/SwyxClient.cs b/src/SwyxTray/Swyx/SwyxClient.cs
index 74d862e..72e7086 100644
--- a/src/SwyxTray/Swyx/SwyxClient.cs
+++ b/src/SwyxTray/Swyx/SwyxClient.cs
@@ -2,6 +2,7 @@ using System.Collections;
using System.Runtime.InteropServices;
using System.Text;
using IpPbx.CLMgrLib;
+using SwyxTray.Recording;
namespace SwyxTray.Swyx;
@@ -27,6 +28,10 @@ internal sealed class SwyxClient : IDisposable
private ClientLineMgrClass? _mgr;
private IClientLineMgrDisp? _disp;
+ private readonly CallRecordingTracker _recordings = new();
+ public RecordingOutbox Outbox { get; } = new(Path.Combine(Log.Directory, "recordings"));
+
+ public SwyxClient() => Outbox.Start();
private IClientLineMgrEventsPub_Event? _events;
private IClientLineMgrEventsPub_PubOnLineMgrNotificationEventHandler? _handler;
@@ -229,7 +234,7 @@ internal sealed class SwyxClient : IDisposable
}
}
- private static SwyxSnapshot ReadSnapshot(IClientLineMgrDisp disp)
+ private SwyxSnapshot ReadSnapshot(IClientLineMgrDisp disp)
{
var lineCount = disp.DispNumberOfLines;
var selected = disp.DispSelectedLineNumber;
@@ -250,7 +255,10 @@ internal sealed class SwyxClient : IDisposable
State: (CLMgrLineStates)line.DispState,
PeerNumber: line.DispPeerNumber ?? string.Empty,
PeerName: line.DispPeerName ?? string.Empty,
- IsSelected: i == selected));
+ IsSelected: i == selected,
+ CallId: line.DispCallId,
+ RecordingRequested: _recordings.IsRequested(i, line.DispCallId),
+ RecordingId: Outbox.CurrentId(i + 1, line.DispCallId)));
}
finally
{
@@ -268,6 +276,12 @@ internal sealed class SwyxClient : IDisposable
private void Publish(SwyxSnapshot snapshot)
{
+ try { Outbox.Observe(snapshot); }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ { Log.Error("Aufnahmeende konnte noch nicht gespeichert werden; wird beim nächsten Zustand erneut versucht.", ex); }
+ if (snapshot.IsConnected && snapshot.IsServerUp)
+ _recordings.Retain(snapshot.Lines.Where(l => l.IsBusy && l.CallId.HasValue)
+ .Select(l => (l.Index, l.CallId!.Value)));
var signature = BuildSignature(snapshot);
if (signature == _lastSignature)
{
@@ -306,7 +320,8 @@ internal sealed class SwyxClient : IDisposable
.Append((int)l.State).Append(':')
.Append(l.PeerNumber).Append(':')
.Append(l.PeerName).Append(':')
- .Append(l.IsSelected ? '1' : '0');
+ .Append(l.IsSelected ? '1' : '0').Append(':')
+ .Append(l.CallId).Append(':').Append(l.RecordingRequested).Append(':').Append(l.RecordingId);
}
return sb.ToString();
@@ -341,23 +356,90 @@ internal sealed class SwyxClient : IDisposable
}
/// Nimmt einen eingehenden Ruf an; liefert die Leitung (1-basiert).
- public int Answer(int? line)
+ public int Answer(int? line, int? callId = null)
{
+ Refresh();
var index = ResolveLine(line, l => l.State.IsRinging(), "Es klingelt keine Leitung.");
- WithLine(index, l => l.DispHookOff());
+ WithLine(index, l =>
+ {
+ if (callId.HasValue && l.DispCallId != callId.Value)
+ throw new InvalidOperationException("Der Anruf auf dieser Leitung hat sich geaendert.");
+ if (!((CLMgrLineStates)l.DispState).IsRinging())
+ throw new InvalidOperationException("Die Leitung klingelt nicht mehr.");
+ l.DispHookOff();
+ });
Refresh();
return index + 1;
}
/// Legt auf; liefert die Leitung (1-basiert).
- public int Hangup(int? line)
+ public int Hangup(int? line, int? callId = null)
{
+ Refresh();
var index = ResolveLine(line, l => l.IsBusy, "Es ist keine Leitung belegt.");
- WithLine(index, l => l.DispHookOn());
+ WithLine(index, l =>
+ {
+ if (((CLMgrLineStates)l.DispState).IsIdle())
+ throw new InvalidOperationException("Das Gespräch ist bereits beendet.");
+ if (callId.HasValue && l.DispCallId != callId.Value)
+ throw new InvalidOperationException("Der Anruf auf dieser Leitung hat sich geändert.");
+ l.DispHookOn();
+ });
Refresh();
return index + 1;
}
+ /// Aufnahme des exakt bezeichneten Anrufs; SDK-Aufruf im STA-Thread.
+ public Guid? SetRecording(int? line, int? callId, bool start, Guid? recordingId = null)
+ {
+ if (line is null or <= 0 || callId is null)
+ throw new ArgumentException("Felder 'line' und 'callId' fehlen oder sind ungueltig.");
+ RequireConnection();
+ Refresh();
+ Guid? id = Outbox.CurrentId(line.Value, callId.Value);
+ if (!start && recordingId.HasValue && id.HasValue && id != recordingId)
+ throw new InvalidOperationException("Der Aufnahmeabschnitt hat sich geändert.");
+ WithLine(line.Value - 1, l =>
+ {
+ var state = (CLMgrLineStates)l.DispState;
+ _recordings.Set(line.Value - 1, callId.Value, l.DispCallId,
+ state.IsActive(), state.IsOnHold(), start,
+ () =>
+ {
+ if (start)
+ {
+ id = Outbox.Begin(line.Value, callId.Value, l.DispPeerNumber ?? "", l.DispPeerName ?? "",
+ RecordingFolder(), recordingId);
+ try { l.DispStartRecording(); }
+ catch { Outbox.Abort(id.Value); throw; }
+ }
+ else
+ {
+ l.DispStopRecording();
+ Outbox.End(line.Value, callId.Value);
+ }
+ });
+ });
+ Refresh();
+ return id;
+ }
+
+ private string RecordingFolder()
+ {
+ if (RequireConnection().DispClientConfig is not IClientConfig config)
+ throw new InvalidOperationException("Swyx-Aufnahmeordner konnte nicht gelesen werden.");
+ try
+ {
+ var path = config.RecordedConversationsPath;
+ if (string.IsNullOrWhiteSpace(path)) path = config.DefaultConversationRecordingPath;
+ if (string.IsNullOrWhiteSpace(path))
+ path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Swyx", "Recording");
+ return Path.GetFullPath(Environment.ExpandEnvironmentVariables(path)
+ .Replace("[username]", Environment.UserName, StringComparison.OrdinalIgnoreCase));
+ }
+ finally { Marshal.FinalReleaseComObject(config); }
+ }
+
/// Stellt ein Gespraech in die Warteschleife.
public int Hold(int? line)
{
@@ -718,6 +800,7 @@ internal sealed class SwyxClient : IDisposable
}
_disposed = true;
+ Outbox.Dispose();
Cleanup();
}
}
diff --git a/src/SwyxTray/Swyx/SwyxModels.cs b/src/SwyxTray/Swyx/SwyxModels.cs
index ee12f52..2a4a3bb 100644
--- a/src/SwyxTray/Swyx/SwyxModels.cs
+++ b/src/SwyxTray/Swyx/SwyxModels.cs
@@ -35,7 +35,10 @@ public sealed record SwyxLineInfo(
CLMgrLineStates State,
string PeerNumber,
string PeerName,
- bool IsSelected)
+ bool IsSelected,
+ int? CallId = null,
+ bool RecordingRequested = false,
+ Guid? RecordingId = null)
{
/// Anzeigename des Gespraechspartners, Nummer als Rueckfallwert.
public string PeerDisplay =>
diff --git a/src/SwyxTray/Web/CommandExecutor.cs b/src/SwyxTray/Web/CommandExecutor.cs
index 0dd8bda..ef2f410 100644
--- a/src/SwyxTray/Web/CommandExecutor.cs
+++ b/src/SwyxTray/Web/CommandExecutor.cs
@@ -27,6 +27,8 @@ internal sealed class CommandExecutor
_tabs = tabs;
}
+ public SwyxTray.Recording.RecordingOutbox Outbox => _client.Outbox;
+
public Task GetSnapshotAsync(CancellationToken cancellationToken)
=> RunAsync(() => _client.Current, cancellationToken);
@@ -50,12 +52,25 @@ internal sealed class CommandExecutor
case "answer":
return new ResultMessage(command.Id, true,
- Line: await RunAsync(() => _client.Answer(command.Line), cancellationToken)
+ Line: await RunAsync(() => _client.Answer(command.Line, command.CallId), cancellationToken)
.ConfigureAwait(false));
+ case "recording.start":
+ case "recording.stop":
+ var start = command.Cmd.Trim().Equals("recording.start", StringComparison.OrdinalIgnoreCase);
+ var recordingId = await RunAsync(() =>
+ _client.SetRecording(command.Line, command.CallId, start, command.RecordingId),
+ cancellationToken).ConfigureAwait(false);
+ return new ResultMessage(command.Id, true, Line: command.Line,
+ CallId: command.CallId, RecordingRequested: start, RecordingId: recordingId);
+
+ case "recording.ack":
+ _client.Outbox.Acknowledge(command.RecordingId ?? Guid.Empty, command.JobId);
+ return new ResultMessage(command.Id, true);
+
case "hangup":
return new ResultMessage(command.Id, true,
- Line: await RunAsync(() => _client.Hangup(command.Line), cancellationToken)
+ Line: await RunAsync(() => _client.Hangup(command.Line, command.CallId), cancellationToken)
.ConfigureAwait(false));
case "hold":
@@ -108,7 +123,7 @@ internal sealed class CommandExecutor
return new ResultMessage(command.Id, false, $"Unbekanntes Kommando '{command.Cmd}'.");
}
}
- catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or IOException or UnauthorizedAccessException)
{
// Erwartbare Bedienfehler: falsche Nummer, keine passende Leitung,
// SwyxIt! nicht verbunden.
diff --git a/src/SwyxTray/Web/LocalWebSocketServer.cs b/src/SwyxTray/Web/LocalWebSocketServer.cs
index 49e4573..bc04f0a 100644
--- a/src/SwyxTray/Web/LocalWebSocketServer.cs
+++ b/src/SwyxTray/Web/LocalWebSocketServer.cs
@@ -40,6 +40,7 @@ internal sealed class LocalWebSocketServer : IDisposable
_config = config;
_tabChannel = new PluginTabChannel();
_executor = new CommandExecutor(client, ui, _tabChannel);
+ _executor.Outbox.Changed += BroadcastRecording;
}
public bool IsRunning { get; private set; }
@@ -444,8 +445,16 @@ internal sealed class LocalWebSocketServer : IDisposable
}
}
+ private void BroadcastRecording(SwyxTray.Recording.RecordingNotice notice)
+ {
+ var json = Protocol.Serialize(notice);
+ foreach (var session in _sessions.Values.Where(s => !s.IsPlugin))
+ _ = session.TrySendManyAsync([json], _shutdown.Token);
+ }
+
public void Dispose()
{
+ _executor.Outbox.Changed -= BroadcastRecording;
_shutdown.Cancel();
foreach (var listener in _listeners)
diff --git a/src/SwyxTray/Web/Protocol.cs b/src/SwyxTray/Web/Protocol.cs
index 51441c8..f0e7faa 100644
--- a/src/SwyxTray/Web/Protocol.cs
+++ b/src/SwyxTray/Web/Protocol.cs
@@ -38,6 +38,12 @@ internal sealed class ClientCommand
/// 1-basierte Leitung; fehlt sie, waehlt die App selbst.
public int? Line { get; set; }
+ /// SDK-Anrufkennung aus dem Snapshot; Pflicht fuer Aufnahmebefehle.
+ public int? CallId { get; set; }
+
+ public Guid? RecordingId { get; set; }
+ public string? JobId { get; set; }
+
/// Nur bei focus: gesuchter Fenstertitel.
public string? Title { get; set; }
@@ -110,7 +116,8 @@ internal sealed record ResultMessage(
int? Id, bool Ok, string? Error = null, int? Line = null, bool? Focused = null,
int? TabId = null, IReadOnlyList? Tabs = null,
IReadOnlyList? Contacts = null, string? Name = null,
- IReadOnlyList? Addresses = null)
+ IReadOnlyList? Addresses = null,
+ int? CallId = null, bool? RecordingRequested = null, Guid? RecordingId = null)
{
public string Type => "result";
}
@@ -138,7 +145,9 @@ internal sealed record LineMessage(
string PeerNumber,
string PeerName,
bool Busy,
- bool Selected);
+ bool Selected,
+ int? CallId,
+ bool RecordingRequested, Guid? RecordingId);
///
/// Anruf-Ereignis, abgeleitet aus dem Vergleich zweier aufeinanderfolgender
@@ -157,18 +166,22 @@ internal sealed record CallEventMessage(
string Peer,
string PeerNumber,
string PeerName,
- string? Direction = null)
+ string? Direction = null,
+ int? CallId = null)
{
public string Type => "call";
public static IReadOnlyList Diff(SwyxSnapshot previous, SwyxSnapshot current)
{
var events = new List();
+ // Verbindungsverlust ist kein nachgewiesenes Gespraechsende.
+ if (!current.IsConnected || !current.IsServerUp) return events;
var before = previous.Lines.ToDictionary(l => l.Index);
foreach (var line in current.Lines)
{
before.TryGetValue(line.Index, out var prev);
+ if (prev?.CallId != line.CallId) prev = null;
var wasRinging = prev?.State.IsRinging() ?? false;
var wasDialing = prev?.State.IsDialing() ?? false;
var wasTalking = prev is not null && (prev.State.IsActive() || prev.State.IsOnHold());
@@ -193,7 +206,8 @@ internal sealed record CallEventMessage(
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)
+ if (after.TryGetValue(prev.Index, out var line)
+ && (!line.IsBusy || line.CallId != prev.CallId))
{
events.Add(From("ended", prev));
}
@@ -203,7 +217,7 @@ internal sealed record CallEventMessage(
}
private static CallEventMessage From(string evt, SwyxLineInfo line, string? direction = null)
- => new(evt, line.Index + 1, line.PeerDisplayLong, line.PeerNumber, line.PeerName, direction);
+ => new(evt, line.Index + 1, line.PeerDisplayLong, line.PeerNumber, line.PeerName, direction, line.CallId);
}
///
@@ -237,5 +251,7 @@ internal sealed record SnapshotMessage(
PeerNumber: l.PeerNumber,
PeerName: l.PeerName,
Busy: l.IsBusy,
- Selected: l.IsSelected)).ToList());
+ Selected: l.IsSelected,
+ CallId: l.CallId,
+ RecordingRequested: l.RecordingRequested, RecordingId: l.RecordingId)).ToList());
}
diff --git a/src/SwyxTray/Web/WebSocketConfig.cs b/src/SwyxTray/Web/WebSocketConfig.cs
index 964ecab..7940fef 100644
--- a/src/SwyxTray/Web/WebSocketConfig.cs
+++ b/src/SwyxTray/Web/WebSocketConfig.cs
@@ -44,7 +44,9 @@ internal sealed class WebSocketConfig
/// Ruf holt SwyxTray den Firefox-Tab mit diesem Titel selbsttaetig nach
/// vorn.
///
- public const int ProtocolVersion = 9;
+ // Version 10: recording.start/recording.stop, callId und recordingRequested.
+ // Version 11: Aufnahme-UUID, persistente WAV-Warteschlange und WebSocket-Dateitransfer.
+ public const int ProtocolVersion = 11;
///
/// ZUR ZEIT ABGESCHALTET: Token-, Origin- und Host-Pruefung entfallen —
diff --git a/src/SwyxTray/Web/WebSocketSession.cs b/src/SwyxTray/Web/WebSocketSession.cs
index 67b6af4..771b4a3 100644
--- a/src/SwyxTray/Web/WebSocketSession.cs
+++ b/src/SwyxTray/Web/WebSocketSession.cs
@@ -1,4 +1,5 @@
using System.Net.WebSockets;
+using System.Buffers.Binary;
using System.Text;
using System.Text.Json;
@@ -20,6 +21,8 @@ internal sealed class WebSocketSession : IDisposable
private readonly CommandExecutor _executor;
private readonly PluginTabChannel _tabs;
private readonly SemaphoreSlim _sendGate = new(1, 1);
+ private readonly CancellationTokenSource _closed = new();
+ private int _downloading;
public int Id { get; }
@@ -55,6 +58,7 @@ internal sealed class WebSocketSession : IDisposable
{
await SendSnapshotAsync(cancellationToken).ConfigureAwait(false);
await SendAddressesAsync(cancellationToken).ConfigureAwait(false);
+ await SendRecordingsAsync(cancellationToken).ConfigureAwait(false);
}
var buffer = new byte[8 * 1024];
@@ -129,6 +133,20 @@ internal sealed class WebSocketSession : IDisposable
return;
}
+ if (command.Cmd.Equals("recording.download", StringComparison.OrdinalIgnoreCase))
+ {
+ if (command.Id is null or <= 0 || command.RecordingId == null ||
+ Interlocked.CompareExchange(ref _downloading, 1, 0) != 0)
+ {
+ await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, false,
+ "Ungültiger Downloadauftrag oder bereits laufende Übertragung.")), cancellationToken);
+ return;
+ }
+ // Receive loop remains available for hangup, stop, ping etc. while sending audio.
+ _ = DownloadAsync(command, cancellationToken);
+ return;
+ }
+
// 'status' beantwortet der Server mit dem vollen Zustand samt der
// zwischengespeicherten Adressdaten; die Quittung danach haelt den
// Ablauf fuer den Client einheitlich.
@@ -136,6 +154,7 @@ internal sealed class WebSocketSession : IDisposable
{
await SendSnapshotAsync(cancellationToken).ConfigureAwait(false);
await SendAddressesAsync(cancellationToken).ConfigureAwait(false);
+ await SendRecordingsAsync(cancellationToken).ConfigureAwait(false);
await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, true)), cancellationToken)
.ConfigureAwait(false);
return;
@@ -145,6 +164,45 @@ internal sealed class WebSocketSession : IDisposable
await SendAsync(Protocol.Serialize(response), cancellationToken).ConfigureAwait(false);
}
+ private Task SendRecordingsAsync(CancellationToken cancellationToken)
+ => SendManyAsync(_executor.Outbox.Notices().Select(Protocol.Serialize).ToArray(), cancellationToken);
+
+ private async Task DownloadAsync(ClientCommand command, CancellationToken cancellationToken)
+ {
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _closed.Token);
+ timeout.CancelAfter(TimeSpan.FromMinutes(5));
+ try
+ {
+ using var input = _executor.Outbox.Open(command.RecordingId!.Value);
+ var block = new byte[64 * 1024 + 12];
+ "SWYX"u8.CopyTo(block);
+ BinaryPrimitives.WriteInt32LittleEndian(block.AsSpan(4), command.Id!.Value);
+ int offset = 0;
+ int count;
+ while ((count = await input.ReadAsync(block.AsMemory(12), timeout.Token)) > 0)
+ {
+ BinaryPrimitives.WriteInt32LittleEndian(block.AsSpan(8), offset);
+ await _sendGate.WaitAsync(timeout.Token);
+ try
+ {
+ await _socket.SendAsync(block.AsMemory(0, count + 12), WebSocketMessageType.Binary,
+ true, timeout.Token);
+ }
+ finally { _sendGate.Release(); }
+ offset += count;
+ await Task.Yield();
+ }
+ await SendAsync(Protocol.Serialize(new ResultMessage(command.Id, true)), timeout.Token);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidOperationException or UnauthorizedAccessException
+ or WebSocketException or OperationCanceledException or ObjectDisposedException)
+ {
+ await TrySendManyAsync([Protocol.Serialize(new ResultMessage(command.Id, false,
+ "WAV-Übertragung unterbrochen: " + ex.Message))], cancellationToken);
+ }
+ finally { Interlocked.Exchange(ref _downloading, 0); }
+ }
+
///
/// Auf dem Plugin-Port gibt es nur die Tab-Verwaltung: Antworten auf
/// Tab-Auftraege und ein Lebenszeichen. Telefonie laeuft ausschliesslich
@@ -252,7 +310,8 @@ internal sealed class WebSocketSession : IDisposable
public void Dispose()
{
+ _closed.Cancel();
_socket.Dispose();
- _sendGate.Dispose();
+ // A download/broadcast may still be unwinding its finally block.
}
}
diff --git a/tests/SwyxTray.RecordingChecks/OutboxChecks.cs b/tests/SwyxTray.RecordingChecks/OutboxChecks.cs
new file mode 100644
index 0000000..710e2c1
--- /dev/null
+++ b/tests/SwyxTray.RecordingChecks/OutboxChecks.cs
@@ -0,0 +1,184 @@
+using System.Buffers.Binary;
+using IpPbx.CLMgrLib;
+using SwyxTray.Recording;
+using SwyxTray.Swyx;
+
+internal static class OutboxChecks
+{
+ public static void Run(Action check)
+ {
+ var temp = Path.Combine(Path.GetTempPath(), "swyx-outbox-check-" + Guid.NewGuid());
+ Directory.CreateDirectory(temp);
+ try
+ {
+ for (int scenario = 0; scenario < 7; scenario++)
+ {
+ var folder = Path.Combine(temp, scenario.ToString());
+ Directory.CreateDirectory(folder);
+ var queue = Path.Combine(folder, "queue");
+ var now = DateTime.UtcNow;
+ var wav = Path.Combine(folder, "conversation.wav");
+ WriteWav(Path.Combine(folder, "old.wav"), now.AddDays(-1));
+ using var box = new RecordingOutbox(queue, () => now);
+ var id = box.Begin(1, 41, "123", "Test", folder, null);
+ check(box.Begin(1, 41, "123", "Test", folder, Guid.NewGuid()) == id, "duplicate start keeps identity");
+ WriteWav(wav, now);
+ box.Process();
+ check(box.Notices().Length == 0, "no transfer during recording");
+ box.Observe(SwyxSnapshot.Offline);
+ check(box.CurrentId(1, 41) == id, "disconnect does not end recording");
+ if (scenario == 1) WriteWav(Path.Combine(folder, "ambiguous.wav"), now);
+ if (scenario == 2) File.Delete(wav);
+ if (scenario == 3)
+ {
+ // Overlapping recording windows cannot claim the same WAV.
+ File.Delete(wav);
+ box.Begin(2, 42, "456", "Other", folder, null);
+ WriteWav(wav, now);
+ box.End(2, 42);
+ }
+ box.Observe(Snapshot(CLMgrLineStates.LSOnHold));
+ check(box.CurrentId(1, 41) == id, "hold does not end recording");
+ box.Observe(Snapshot(CLMgrLineStates.LSInactive));
+ check(box.CurrentId(1, 41) == null, "call end finishes recording section");
+ now = now.AddSeconds(4);
+ box.Process();
+ if (scenario == 1 || scenario == 3)
+ {
+ check(box.Notices().First(n => n.RecordingId == id).State == "error", "ambiguous file is never offered");
+ continue;
+ }
+ if (scenario == 2)
+ {
+ now = now.AddMinutes(3); box.Process();
+ check(box.Notices()[0].State == "error", "missing new WAV does not select old file");
+ box.Acknowledge(id, new string('a', 32));
+ check(box.Notices()[0].State == "submitted", "manual fallback receipt persists too");
+ continue;
+ }
+ now = now.AddSeconds(3);
+ if (scenario == 4)
+ {
+ using (var writer = new FileStream(wav, FileMode.Open, FileAccess.Write, FileShare.Read))
+ {
+ box.Process();
+ check(box.Notices()[0].State == "waiting", "writer must close before ready");
+ }
+ }
+ if (scenario == 5)
+ {
+ var content = File.ReadAllBytes(wav);
+ BinaryPrimitives.WriteUInt32LittleEndian(content.AsSpan(4), (uint)content.Length + 100);
+ File.WriteAllBytes(wav, content);
+ box.Process(); now = now.AddSeconds(3); box.Process();
+ check(box.Notices()[0].State == "waiting", "unfinished RIFF header waits");
+ WriteWav(wav, now.AddSeconds(-7));
+ box.Process(); now = now.AddSeconds(3);
+ }
+ if (scenario == 6)
+ {
+ using var restarted = new RecordingOutbox(queue, () => now);
+ restarted.Process(); now = now.AddSeconds(3); restarted.Process();
+ check(restarted.Notices()[0].State == "ready", "pending section survives tray restart");
+ continue;
+ }
+ box.Process();
+ check(box.Notices()[0].State == "ready", "stable closed WAV becomes ready");
+ using (var input = box.Open(id)) check(input.Length == 2044, "copy contains complete audio");
+ using (var reloaded = new RecordingOutbox(queue, () => now))
+ check(reloaded.Notices()[0].State == "ready", "ready replays after restart");
+ box.Acknowledge(id, new string('a', 32));
+ box.Acknowledge(id, new string('a', 32));
+ using var received = new RecordingOutbox(queue, () => now);
+ check(received.Notices()[0].JobId == new string('a', 32), "durable idempotent receipt");
+ check(File.Exists(wav) && !File.Exists(Path.Combine(queue, id + ".wav")), "remove only acknowledged temporary copy");
+ }
+ // A call can end outside SwyxWeb. No recording.stop/hangup command
+ // is needed: the same outbox must produce a downloadable WAV.
+ var externalEnds = new[] {
+ ("remote", CLMgrLineStates.LSTerminated, 41),
+ ("local", CLMgrLineStates.LSInactive, 41),
+ ("disabled", CLMgrLineStates.LSDisabled, 41),
+ ("none", CLMgrLineStates.LSNone, 41),
+ ("replaced", CLMgrLineStates.LSRinging, 42),
+ ("reconnected", CLMgrLineStates.LSInactive, 41)
+ };
+ foreach (var (reason, state, callId) in externalEnds)
+ {
+ var folder = Path.Combine(temp, "external-" + reason);
+ Directory.CreateDirectory(folder);
+ var now = DateTime.UtcNow;
+ using var box = new RecordingOutbox(Path.Combine(folder, "queue"), () => now);
+ var id = box.Begin(1, 41, "123", "Test", folder, null);
+ WriteWav(Path.Combine(folder, "call.wav"), now);
+ box.Observe(Snapshot(CLMgrLineStates.LSActive));
+ if (reason == "reconnected")
+ {
+ box.Observe(SwyxSnapshot.Offline);
+ check(box.Notices().Length == 0, "connection loss alone does not release audio");
+ }
+ box.Observe(Snapshot(state, callId));
+ now = now.AddSeconds(4); box.Process(); now = now.AddSeconds(3); box.Process();
+ check(box.Notices().Single() is { State: "ready", CallEnded: true },
+ "automatic handoff without local hangup: " + reason);
+ using var audio = box.Open(id);
+ check(audio.Length == 2044, "complete WAV after external end: " + reason);
+ }
+ var sequential = Path.Combine(temp, "sequential");
+ Directory.CreateDirectory(sequential);
+ var clock = DateTime.UtcNow;
+ using var sections = new RecordingOutbox(Path.Combine(sequential, "queue"), () => clock);
+ sections.Begin(1, 88, "123", "Test", sequential, null);
+ WriteWav(Path.Combine(sequential, "first.wav"), clock);
+ sections.End(1, 88);
+ clock = clock.AddMilliseconds(100);
+ sections.Begin(1, 88, "123", "Test", sequential, null);
+ WriteWav(Path.Combine(sequential, "second.wav"), clock);
+ sections.End(1, 88);
+ clock = clock.AddSeconds(4); sections.Process();
+ clock = clock.AddSeconds(3); sections.Process();
+ check(sections.Notices().Length == 2 && sections.Notices().All(n => n.State == "waiting_call_end" && !n.CallEnded),
+ "all stopped recording sections wait for hangup");
+ sections.Observe(Snapshot(CLMgrLineStates.LSOnHold, 88));
+ sections.Observe(SwyxSnapshot.Offline);
+ check(sections.Notices().All(n => n.State == "waiting_call_end"), "hold/disconnect do not release stopped sections");
+ using (var restarted = new RecordingOutbox(Path.Combine(sequential, "queue"), () => clock))
+ {
+ restarted.Observe(Snapshot(CLMgrLineStates.LSActive, 88));
+ check(restarted.Notices().All(n => n.State == "waiting_call_end"), "stopped sections wait across restart");
+ foreach (var pending in restarted.Notices())
+ {
+ bool refused = false;
+ try { using var blockedInput = restarted.Open(pending.RecordingId); }
+ catch (InvalidOperationException) { refused = true; }
+ check(refused, "download denied before confirmed call end");
+ refused = false;
+ try { restarted.Acknowledge(pending.RecordingId, new string('b', 32)); }
+ catch (InvalidOperationException) { refused = true; }
+ check(refused, "receipt denied before confirmed call end");
+ }
+ restarted.Observe(Snapshot(CLMgrLineStates.LSInactive, 88));
+ check(restarted.Notices().Length == 2 && restarted.Notices().All(n => n.State == "ready" && n.CallEnded),
+ "hangup releases all finalized sections of the ended call");
+ using var input = restarted.Open(restarted.Notices()[0].RecordingId);
+ check(input.Length == 2044, "complete WAV available after hangup");
+ }
+ }
+ finally { Directory.Delete(temp, true); }
+ }
+
+ public static void WriteWav(string path, DateTime created, int samples = 1000)
+ {
+ using (var writer = new BinaryWriter(File.Create(path)))
+ {
+ writer.Write("RIFF"u8); writer.Write(36 + samples * 2); writer.Write("WAVEfmt "u8);
+ writer.Write(16); writer.Write((short)1); writer.Write((short)1); writer.Write(16000);
+ writer.Write(32000); writer.Write((short)2); writer.Write((short)16);
+ writer.Write("data"u8); writer.Write(samples * 2); writer.Write(new byte[samples * 2]);
+ }
+ File.SetCreationTimeUtc(path, created);
+ }
+
+ private static SwyxSnapshot Snapshot(CLMgrLineStates state, int callId = 41) =>
+ new(true, true, "test", "test", [new SwyxLineInfo(0, state, "123", "Test", true, callId)]);
+}
diff --git a/tests/SwyxTray.RecordingChecks/Program.cs b/tests/SwyxTray.RecordingChecks/Program.cs
new file mode 100644
index 0000000..93d0b7a
--- /dev/null
+++ b/tests/SwyxTray.RecordingChecks/Program.cs
@@ -0,0 +1,51 @@
+using IpPbx.CLMgrLib;
+using SwyxTray.Swyx;
+using SwyxTray.Web;
+
+if (args.Contains("--serve")) { await TransportChecks.Serve(); return; }
+
+int checks = 0;
+void Check(bool condition, string name) { if (!condition) throw new Exception(name); checks++; }
+void Reject(Action action, string name) {
+ try { action(); } catch (InvalidOperationException) { checks++; return; }
+ throw new Exception(name);
+}
+var recordings = new CallRecordingTracker();
+int invocations = 0;
+Action invoke = () => invocations++;
+Reject(() => recordings.Set(0, 41, 42, true, false, true, invoke), "stale call");
+Reject(() => recordings.Set(0, 41, 41, false, false, true, invoke), "ringing");
+Reject(() => recordings.Set(0, 41, 41, false, true, true, invoke), "held start");
+Check(invocations == 0, "invalid calls do not reach SDK");
+recordings.Set(0, 41, 41, true, false, true, invoke);
+recordings.Set(0, 41, 41, true, false, true, invoke);
+Check(invocations == 1 && recordings.IsRequested(0, 41), "idempotent start");
+recordings.Set(0, 41, 41, false, true, false, invoke);
+Check(invocations == 2 && !recordings.IsRequested(0, 41), "stop while held");
+Reject(() => recordings.Set(0, 41, 41, true, false, true, () => throw new InvalidOperationException()), "SDK error");
+Check(!recordings.IsRequested(0, 41), "failed start not reported as success");
+recordings.Set(0, 41, 41, true, false, true, invoke);
+Reject(() => recordings.Set(0, 41, 41, true, false, false, () => throw new InvalidOperationException()), "stop error");
+Check(recordings.IsRequested(0, 41), "failed stop retains request state");
+recordings.Retain(new[] { (0, 42) });
+Check(!recordings.IsRequested(0, 41), "call ended clears request state");
+recordings.Set(0, 42, 42, true, false, false, invoke);
+Check(invocations == 4, "stop external recording reaches SDK too");
+
+SwyxSnapshot Snapshot(CLMgrLineStates state, int id = 41, bool recording = false) =>
+ new(true, true, "test", "test", new[] { new SwyxLineInfo(0, state, "123", "Test", true, id, recording) });
+var active = Snapshot(CLMgrLineStates.LSActive, recording: true);
+var end = CallEventMessage.Diff(active, Snapshot(CLMgrLineStates.LSInactive));
+Check(end.Count == 1 && end[0].Event == "ended" && end[0].CallId == 41 && end[0].PeerNumber == "123", "end preserves call identity");
+Check(CallEventMessage.Diff(active, SwyxSnapshot.Offline).Count == 0, "disconnect is not call end");
+Check(CallEventMessage.Diff(active, active with { IsServerUp = false }).Count == 0, "server down is not call end");
+Check(CallEventMessage.Diff(active, Snapshot(CLMgrLineStates.LSOnHold)).Count == 0, "holding is not call end");
+Check(CallEventMessage.Diff(Snapshot(CLMgrLineStates.LSOnHold), active).Count == 0, "resume is not new call");
+var replaced = CallEventMessage.Diff(active, Snapshot(CLMgrLineStates.LSRinging, 42));
+Check(replaced.Count == 2 && replaced.Any(e => e.Event == "ended" && e.CallId == 41)
+ && replaced.Any(e => e.Event == "incoming" && e.CallId == 42), "reused line keeps calls distinct");
+var json = Protocol.Serialize(SnapshotMessage.From(active));
+Check(json.Contains("\"callId\":41") && json.Contains("\"recordingRequested\":true"), "wire fields");
+OutboxChecks.Run(Check);
+await TransportChecks.Run(Check);
+Console.WriteLine($"{checks} recording/protocol checks passed (no COM calls).");
diff --git a/tests/SwyxTray.RecordingChecks/SwyxTray.RecordingChecks.csproj b/tests/SwyxTray.RecordingChecks/SwyxTray.RecordingChecks.csproj
new file mode 100644
index 0000000..7f6095b
--- /dev/null
+++ b/tests/SwyxTray.RecordingChecks/SwyxTray.RecordingChecks.csproj
@@ -0,0 +1,21 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ x64
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/SwyxTray.RecordingChecks/TransportChecks.cs b/tests/SwyxTray.RecordingChecks/TransportChecks.cs
new file mode 100644
index 0000000..0e71e0c
--- /dev/null
+++ b/tests/SwyxTray.RecordingChecks/TransportChecks.cs
@@ -0,0 +1,154 @@
+using System.Buffers.Binary;
+using System.Net;
+using System.Net.Sockets;
+using System.Net.WebSockets;
+using System.Text;
+using System.Text.Json;
+using SwyxTray.Recording;
+using SwyxTray.Web;
+
+internal static class TransportChecks
+{
+ // Local-only fixture for browser -> real SwyxWeb -> isolated SwyxTrans integration checks.
+ public static async Task Serve()
+ {
+ var folder = Path.Combine(Path.GetTempPath(), "swyx-browser-check-" + Guid.NewGuid());
+ Directory.CreateDirectory(folder);
+ var now = DateTime.UtcNow;
+ using var outbox = new RecordingOutbox(Path.Combine(folder, "queue"), () => now);
+ var id = outbox.Begin(1, 123, "123", "WebSocket-Testgespräch", folder, null);
+ OutboxChecks.WriteWav(Path.Combine(folder, "call.wav"), now, 32_000);
+ outbox.Observe(new SwyxTray.Swyx.SwyxSnapshot(true, true, "test", "test",
+ [new SwyxTray.Swyx.SwyxLineInfo(0, IpPbx.CLMgrLib.CLMgrLineStates.LSInactive, "123", "Test", true, 123)]));
+ now = now.AddSeconds(4); outbox.Process(); now = now.AddSeconds(3); outbox.Process();
+ using var listener = new TcpListener(IPAddress.Loopback, 17658);
+ listener.Start();
+ Console.WriteLine("Synthetic WAV fixture ready: " + folder);
+ while (true)
+ {
+ var tcp = await listener.AcceptTcpClientAsync();
+ _ = Task.Run(async () =>
+ {
+ using (tcp)
+ {
+ try
+ {
+ var stream = tcp.GetStream();
+ var request = await WebSocketHandshake.ReadAsync(stream, CancellationToken.None);
+ if (request == null) return;
+ await WebSocketHandshake.AcceptAsync(stream, request, CancellationToken.None);
+ using var session = new WebSocketSession(1, WebSocket.CreateFromStream(stream, true, null,
+ TimeSpan.FromSeconds(15)), new CommandExecutor(outbox), new PluginTabChannel(), request.Origin, false);
+ await session.RunAsync(CancellationToken.None);
+ }
+ catch (Exception ex) { Console.WriteLine(ex.GetType().Name); }
+ }
+ });
+ }
+ }
+
+ public static async Task Run(Action check)
+ {
+ var folder = Path.Combine(Path.GetTempPath(), "swyx-transport-" + Guid.NewGuid());
+ Directory.CreateDirectory(folder);
+ try
+ {
+ var now = DateTime.UtcNow;
+ using var outbox = new RecordingOutbox(Path.Combine(folder, "queue"), () => now);
+ var id = outbox.Begin(1, 123, "123", "Test", folder, null);
+ var path = Path.Combine(folder, "call.wav");
+ OutboxChecks.WriteWav(path, now, samples: 1_000_000);
+ outbox.Observe(new SwyxTray.Swyx.SwyxSnapshot(true, true, "test", "test",
+ [new SwyxTray.Swyx.SwyxLineInfo(0, IpPbx.CLMgrLib.CLMgrLineStates.LSInactive, "123", "Test", true, 123)]));
+ now = now.AddSeconds(4); outbox.Process(); now = now.AddSeconds(3); outbox.Process();
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+ for (int round = 0; round < 3; round++)
+ {
+ using var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ using var tcp = new TcpClient();
+ var connection = tcp.ConnectAsync((IPEndPoint)listener.LocalEndpoint);
+ using var remote = await listener.AcceptTcpClientAsync(timeout.Token);
+ await connection;
+ using var socket = WebSocket.CreateFromStream(tcp.GetStream(), false, null, TimeSpan.FromSeconds(15));
+ using var server = new WebSocketSession(round, WebSocket.CreateFromStream(remote.GetStream(), true, null,
+ TimeSpan.FromSeconds(15)), new CommandExecutor(outbox), new PluginTabChannel(), "http://test", false);
+ var running = server.RunAsync(timeout.Token);
+ var notice = await Until(socket, e => e.TryGetProperty("recordingId", out _), timeout.Token);
+ check(notice.GetProperty("recordingId").GetGuid() == id, "recording replay uses original UUID");
+ if (round == 2)
+ {
+ check(notice.GetProperty("state").GetString() == "submitted", "receipt replay on reconnect");
+ }
+ else
+ {
+ await Send(socket, new { id = 7, cmd = "recording.download", recordingId = id }, timeout.Token);
+ await Send(socket, new { id = 8, cmd = "ping" }, timeout.Token);
+ using var received = new MemoryStream();
+ bool done = false, ping = false;
+ while (!done || !ping)
+ {
+ var (type, bytes) = await Receive(socket, timeout.Token);
+ if (type == WebSocketMessageType.Binary)
+ {
+ check(bytes.AsSpan(0, 4).SequenceEqual("SWYX"u8), "binary block magic");
+ check(BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(4)) == 7, "binary request id");
+ check(BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(8)) == received.Length, "binary offset");
+ received.Write(bytes, 12, bytes.Length - 12);
+ if (round == 0) break; // Interrupt, then request the same retained WAV on another session.
+ }
+ else
+ {
+ var result = JsonDocument.Parse(bytes).RootElement;
+ if (result.TryGetProperty("id", out var requestId))
+ {
+ check(result.GetProperty("ok").GetBoolean(), "transfer/control command succeeds");
+ if (requestId.GetInt32() == 7) done = true;
+ if (requestId.GetInt32() == 8) ping = true;
+ }
+ }
+ }
+ if (round == 1)
+ {
+ check(received.ToArray().SequenceEqual(File.ReadAllBytes(path)), "actual WebSocket preserves entire WAV");
+ check(ping, "control commands remain responsive during transfer");
+ await Send(socket, new { id = 9, cmd = "recording.ack", recordingId = id, jobId = new string('a', 32) }, timeout.Token);
+ var ack = await Until(socket, e => e.TryGetProperty("id", out var n) && n.GetInt32() == 9, timeout.Token);
+ check(ack.GetProperty("ok").GetBoolean(), "transfer receipt accepted");
+ }
+ }
+ socket.Abort(); server.Dispose();
+ try { await running; } catch (Exception ex) when (ex is WebSocketException or IOException or ObjectDisposedException) { }
+ }
+ check(File.Exists(path), "original WAV survives websocket acknowledgements");
+ }
+ finally { Directory.Delete(folder, true); }
+ }
+
+ private static Task Send(WebSocket socket, object value, CancellationToken token) =>
+ socket.SendAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value)), WebSocketMessageType.Text, true, token);
+
+ private static async Task Until(WebSocket socket, Func predicate, CancellationToken token)
+ {
+ while (true)
+ {
+ var (type, bytes) = await Receive(socket, token);
+ if (type != WebSocketMessageType.Text) continue;
+ var element = JsonDocument.Parse(bytes).RootElement;
+ if (predicate(element)) return element;
+ }
+ }
+
+ private static async Task<(WebSocketMessageType, byte[])> Receive(WebSocket socket, CancellationToken token)
+ {
+ using var message = new MemoryStream();
+ var buffer = new byte[32 * 1024];
+ WebSocketReceiveResult result;
+ do
+ {
+ result = await socket.ReceiveAsync(buffer, token);
+ message.Write(buffer, 0, result.Count);
+ } while (!result.EndOfMessage);
+ return (result.MessageType, message.ToArray());
+ }
+}
diff --git a/tests/SwyxTray.RecordingChecks/TransportStubs.cs b/tests/SwyxTray.RecordingChecks/TransportStubs.cs
new file mode 100644
index 0000000..bdebc98
--- /dev/null
+++ b/tests/SwyxTray.RecordingChecks/TransportStubs.cs
@@ -0,0 +1,17 @@
+// Only the COM executor and browser-plugin channel are replaced. The outbox,
+// session, framing, cancellation and persistence are production code.
+using SwyxTray.Recording;
+using SwyxTray.Swyx;
+namespace SwyxTray.Web;
+internal sealed class CommandExecutor(RecordingOutbox outbox)
+{
+ public RecordingOutbox Outbox => outbox;
+ public Task GetSnapshotAsync(CancellationToken _) => Task.FromResult(SwyxSnapshot.Offline);
+ public Task> GetAddressesAsync(CancellationToken _) => Task.FromResult>([]);
+ public Task ExecuteAsync(ClientCommand command, CancellationToken _)
+ {
+ if (command.Cmd == "recording.ack") Outbox.Acknowledge(command.RecordingId!.Value, command.JobId);
+ return Task.FromResult(new ResultMessage(command.Id, true));
+ }
+}
+internal sealed class PluginTabChannel { public bool TryComplete(ClientCommand _) => false; }