| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2023-09-28 01:36:32 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2023-09-28 01:59:15 UTC |
| parent | a109683261438a40b3e777b1f734f475c8443c30 |
| xmpp/Chat.hx | +27 | -0 |
| xmpp/Client.hx | +78 | -2 |
| xmpp/PeerConnection.js.hx | +0 | -0 |
| xmpp/jingle/PeerConnection.js.hx | +1 | -0 |
| xmpp/jingle/Session.hx | +123 | -1 |
diff --git a/xmpp/Chat.hx b/xmpp/Chat.hx index bac2e93..2343135 100644 --- a/xmpp/Chat.hx +++ b/xmpp/Chat.hx @@ -8,6 +8,7 @@ import xmpp.Chat; import xmpp.GenericStream; import xmpp.queries.MAMQuery; import xmpp.Color; +using Lambda; enum ChatType { ChatTypeDirect; @@ -24,6 +25,7 @@ abstract class Chat { private var trusted:Bool = false; public var chatId(default, null):String; public var type(default, null):Null<ChatType>; + public var jingleSessions: Map<String, xmpp.jingle.Session> = []; private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, type:ChatType) { this.client = client; @@ -62,6 +64,31 @@ abstract class Chat { return this.trusted; } + public function acceptCall() { + for (session in jingleSessions) { + session.accept(); + } + } + + public function hangup() { + for (session in jingleSessions) { + session.hangup(); + jingleSessions.remove(session.sid); + } + } + + public function callStatus() { + for (session in jingleSessions) { + return session.callStatus(); + } + + return "none"; + } + + public function videoTracks() { + return jingleSessions.flatMap((session) -> session.videoTracks()); + } + public function onMessage(handler:ChatMessage->Void):Void { this.stream.on("message", function(event) { final stanza:Stanza = event.stanza; diff --git a/xmpp/Client.hx b/xmpp/Client.hx index c2401e9..587a437 100644 --- a/xmpp/Client.hx +++ b/xmpp/Client.hx @@ -10,6 +10,7 @@ import xmpp.EventEmitter; import xmpp.EventHandler; import xmpp.PubsubEvent; import xmpp.Stream; +import xmpp.jingle.Session; import xmpp.queries.DiscoInfoGet; import xmpp.queries.ExtDiscoGet; import xmpp.queries.GenericQuery; @@ -27,7 +28,22 @@ class Client extends xmpp.EventEmitter { public var jid(default,null):String; private var chats: ChatList = []; private var persistence: Persistence; - private final caps = new Caps("https://sdk.snikket.org", [], ["urn:xmpp:avatar:metadata+notify"]); + private final caps = new Caps( + "https://sdk.snikket.org", + [], + [ + "http://jabber.org/protocol/disco#info", + "http://jabber.org/protocol/caps", + "urn:xmpp:avatar:metadata+notify", + "urn:xmpp:jingle-message:0", + "urn:xmpp:jingle:1", + "urn:xmpp:jingle:apps:dtls:0", + "urn:xmpp:jingle:apps:rtp:1", + "urn:xmpp:jingle:apps:rtp:audio", + "urn:xmpp:jingle:apps:rtp:video", + "urn:xmpp:jingle:transports:ice-udp:1" + ] + ); public function new(jid: String, persistence: Persistence) { super(); @@ -59,6 +75,27 @@ class Client extends xmpp.EventEmitter { } this.stream.on("message", function(event) { final stanza:Stanza = event.stanza; + final from = stanza.attr.get("from") == null ? null : JID.parse(stanza.attr.get("from")); + + final jmiP = stanza.getChild("propose", "urn:xmpp:jingle-message:0"); + if (jmiP != null && jmiP.attr.get("id") != null) { + final session = new IncomingProposedSession(this, from, jmiP.attr.get("id")); + final chat = getDirectChat(from.asBare().asString()); + chat.jingleSessions.set(session.sid, session); + chatActivity(chat); + session.ring(); + } + + final jmiR = stanza.getChild("retract", "urn:xmpp:jingle-message:0"); + if (jmiR != null && jmiR.attr.get("id") != null) { + final chat = getDirectChat(from.asBare().asString()); + final session = chat.jingleSessions.get(jmiR.attr.get("id")); + if (session != null) { + session.retract(); + chat.jingleSessions.remove(session.sid); + } + } + final chatMessage = ChatMessage.fromStanza(stanza, jid); if (chatMessage != null) { var chat = getDirectChat(chatMessage.conversation()); @@ -107,6 +144,41 @@ class Client extends xmpp.EventEmitter { this.stream.on("iq", function(event) { final stanza:Stanza = event.stanza; + final from = stanza.attr.get("from") == null ? null : JID.parse(stanza.attr.get("from")); + + final jingle = stanza.getChild("jingle", "urn:xmpp:jingle:1"); + if (stanza.attr.get("type") == "set" && jingle != null) { + // First, jingle requires useless replies to every iq + sendStanza(new Stanza("iq", { type: "result", to: stanza.attr.get("from"), id: stanza.attr.get("id") })); + final chat = getDirectChat(from.asBare().asString()); + final session = chat.jingleSessions.get(jingle.attr.get("sid")); + + if (jingle.attr.get("action") == "session-initiate") { + final newSession = new xmpp.jingle.InitiatedSession(this, stanza); + if (session != null) { + final nextSession = session.initiate(newSession); + if (nextSession == null) { + chat.jingleSessions.remove(session.sid); + } else { + chat.jingleSessions.set(session.sid, nextSession); + } + } else { + chat.jingleSessions.set(session.sid, newSession); + chatActivity(chat); + newSession.ring(); + } + } + + if (session != null && jingle.attr.get("action") == "session-terminate") { + session.terminate(); + chat.jingleSessions.remove(jingle.attr.get("sid")); + } + + if (session != null && jingle.attr.get("action") == "transport-info") { + session.transportInfo(stanza); + } + return EventHandled; + } if (stanza.attr.get("type") == "get" && stanza.getChild("query", "http://jabber.org/protocol/disco#info") != null) { stream.sendStanza(caps.discoReply(stanza)); @@ -178,7 +250,7 @@ class Client extends xmpp.EventEmitter { rosterGet(); sync(() -> { // Set self to online - stream.sendStanza(caps.addC(new Stanza("presence"))); + sendPresence(); this.trigger("status/online", {}); }); @@ -270,6 +342,10 @@ class Client extends xmpp.EventEmitter { stream.sendStanza(stanza); } + public function sendPresence(?to: String) { + sendStanza(caps.addC(new Stanza("presence", to == null ? {} : { to: to }))); + } + #if js public function subscribePush(reg: js.html.ServiceWorkerRegistration, push_service: String, vapid_key: { publicKey: js.html.CryptoKey, privateKey: js.html.CryptoKey}) { js.Browser.window.crypto.subtle.exportKey("raw", vapid_key.publicKey).then((vapid_public_raw) -> { diff --git a/xmpp/PeerConnection.js.hx b/xmpp/PeerConnection.js.hx new file mode 100644 index 0000000..e69de29 diff --git a/xmpp/jingle/PeerConnection.js.hx b/xmpp/jingle/PeerConnection.js.hx index ae07cae..1480ec8 100644 --- a/xmpp/jingle/PeerConnection.js.hx +++ b/xmpp/jingle/PeerConnection.js.hx @@ -6,3 +6,4 @@ typedef PeerConnection = js.html.rtc.PeerConnection; typedef SdpType = js.html.rtc.SdpType; typedef Promise<T> = js.lib.Promise<T>; typedef MediaStream = js.html.MediaStream; +typedef MediaStreamTrack = js.html.MediaStreamTrack; diff --git a/xmpp/jingle/Session.hx b/xmpp/jingle/Session.hx index 0105d2d..ce7bad9 100644 --- a/xmpp/jingle/Session.hx +++ b/xmpp/jingle/Session.hx @@ -1,10 +1,93 @@ package xmpp.jingle; +import xmpp.ID; import xmpp.jingle.PeerConnection; import xmpp.jingle.SessionDescription; using Lambda; -class Session { +interface Session { + public var sid (get, null): String; + public function initiate(session: InitiatedSession): Null<InitiatedSession>; + public function accept(): Void; + public function hangup(): Void; + public function retract(): Void; + public function terminate(): Void; + public function transportInfo(stanza: Stanza): Promise<Void>; + public function callStatus():String; + public function videoTracks():Array<MediaStreamTrack>; +} + +class IncomingProposedSession implements Session { + public var sid (get, null): String; + private final client: Client; + private final from: JID; + private final _sid: String; + private var accepted: Bool = false; + + public function new(client: Client, from: JID, sid: String) { + this.client = client; + this.from = from; + this._sid = sid; + } + + public function ring() { + // XEP-0353 says to send <ringing/> but that leaks presence if not careful + client.trigger("call/ring", { chatId: from.asBare().asString(), session: this }); + } + + public function hangup() { + // XEP-0353 says to send <reject/> but that leaks presence if not careful + // It also tells all other devices to stop ringing, which you may or may not want + client.getDirectChat(from.asBare().asString(), false).jingleSessions.remove(sid); + } + + public function retract() { + // Other side retracted, stop ringing + client.trigger("call/retract", { chatId: from.asBare().asString() }); + } + + public function terminate() { + trace("Tried to terminate before session-inititate: " + sid, this); + } + + public function transportInfo(_) { + trace("Got transport-info before session-inititate: " + sid, this); + return Promise.resolve(null); + } + + public function accept() { + if (accepted) return; + accepted = true; + client.sendPresence(from.asString()); + client.sendStanza( + new Stanza("message", { to: from.asString(), type: "chat" }) + .tag("proceed", { xmlns: "urn:xmpp:jingle-message:0", id: sid }).up() + .tag("store", { xmlns: "urn:xmpp:hints" }) + ); + } + + public function initiate(session: InitiatedSession) { + // TODO: check if new session has corrent media + if (session.sid != sid) return null; + if (!accepted) return null; + session.accept(); + return session; + } + + public function callStatus() { + return "incoming"; + } + + public function videoTracks() { + return []; + } + + private function get_sid() { + return this._sid; + } +} + +class InitiatedSession implements Session { public var sid (get, null): String; private final client: Client; private final sessionInitiate: Stanza; @@ -13,6 +96,7 @@ class Session { private var pc: PeerConnection = null; private final queuedInboundTransportInfo: Array<Stanza> = []; private final queuedOutboundCandidate: Array<{ candidate: String, sdpMid: String, usernameFragment: String }> = []; + private var accepted: Bool = false; public function new(client: Client, sessionInitiate: Stanza) { this.client = client; @@ -25,10 +109,36 @@ class Session { return jingle.attr.get("sid"); } + public function ring() { + client.trigger("call/ring", { chatId: JID.parse(sessionInitiate.attr.get("from")).asBare().asString(), session: this }); + } + + public function retract() { + trace("Tried to retract session in wrong state: " + sid, this); + } + public function accept() { + if (accepted) return; + accepted = true; client.trigger("call/media", { session: this }); } + public function hangup() { + client.sendStanza( + new Stanza("iq", { to: sessionInitiate.attr.get("from"), type: "set", id: ID.medium() }) + .tag("jingle", { xmlns: "urn:xmpp:jingle:1", action: "session-terminate", sid: sid }) + .tag("reason").tag("success") + .up().up().up() + ); + terminate(); + client.trigger("call/retract", { chatId: JID.parse(sessionInitiate.attr.get("from")).asBare().asString() }); + } + + public function initiate(session: InitiatedSession) { + trace("Trying to inititate already initiated session: " + sid); + return null; + } + public function terminate() { if (pc == null) return; pc.close(); @@ -37,6 +147,7 @@ class Session { tranceiver.sender.track.stop(); } } + pc = null; } public function transportInfo(stanza: Stanza) { @@ -56,6 +167,17 @@ class Session { })).then((_) -> {}); } + public function callStatus() { + return "ongoing"; + } + + public function videoTracks() { + if (pc == null) return []; + return pc.getTransceivers() + .filter((t) -> t.receiver != null && t.receiver.track != null && t.receiver.track.kind == "video" && !t.receiver.track.muted) + .map((t) -> t.receiver.track); + } + private function sendIceCandidate(candidate: { candidate: String, sdpMid: String, usernameFragment: String }) { if (candidate == null) return; // All candidates received now if (candidate.candidate == "") return; // All candidates received now