secure chat addon and stun
This commit is contained in:
1 parent
a45fa67ed2
commit
813d3aa06e
40 files changed
+2767
-189
No files matched your search
@@ -2,7 +2,7 @@
|
||||
"id": "chat",
|
||||
"name": "Chat P2P",
|
||||
"version": "1.17.1",
|
||||
"requires": ["comms-core"],
|
||||
"requires": ["comms-core", "webhub"],
|
||||
"backoneCore": {
|
||||
"apis": [
|
||||
"/app/api/peers",
|
||||
|
||||
@@ -4,12 +4,27 @@ window.BackOneAddons.chat = function chatAddon() {
|
||||
const messagesByPeer = {};
|
||||
let selectedPeer = null;
|
||||
let root = null;
|
||||
let unsubChat = null;
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function clearSessionMessages() {
|
||||
Object.keys(messagesByPeer).forEach(k => delete messagesByPeer[k]);
|
||||
}
|
||||
|
||||
function getThread(peerId) {
|
||||
if (!messagesByPeer[peerId]) messagesByPeer[peerId] = [];
|
||||
return messagesByPeer[peerId];
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString(undefined, { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function networkLabel() {
|
||||
const n = BackOneNetworkSession.getActiveNetwork();
|
||||
return n ? (n.name || n.id) : BackOneNetworkSession.getNetworkId();
|
||||
@@ -32,35 +47,37 @@ window.BackOneAddons.chat = function chatAddon() {
|
||||
header.textContent = net ? `Select a contact on ${net}` : 'Join a network in the header bar';
|
||||
return;
|
||||
}
|
||||
header.innerHTML = `<strong>${selectedPeer.displayName || selectedPeer.peerId}</strong> <small>${selectedPeer.peerId}${net ? ' · ' + net : ''}</small>`;
|
||||
header.innerHTML = `<strong>${escapeHtml(selectedPeer.displayName || selectedPeer.peerId)}</strong> <small>${escapeHtml(selectedPeer.peerId)}${net ? ' · ' + escapeHtml(net) : ''}</small>`;
|
||||
updateConnectionBar();
|
||||
}
|
||||
|
||||
function selfName() {
|
||||
const addr = (BackOneNetworkSession.getCoreStatus().address || '').toLowerCase();
|
||||
return addr ? addr.slice(0, 10) + '…' : 'me';
|
||||
}
|
||||
|
||||
return {
|
||||
mount(container) {
|
||||
root = container;
|
||||
clearSessionMessages();
|
||||
container.innerHTML = `
|
||||
<div class="comm-layout">
|
||||
<div class="comm-layout comm-split-layout">
|
||||
<aside class="comm-sidebar">
|
||||
<div class="comm-sidebar-head">
|
||||
<h2>Chat P2P</h2>
|
||||
<p class="comm-sidebar-sub">BackOne core · same network as backone-cli</p>
|
||||
<p class="comm-sidebar-sub">Contacts · session only</p>
|
||||
</div>
|
||||
<div class="comm-sidebar-body">
|
||||
<div id="chat-peer-picker"></div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="comm-main">
|
||||
<div class="comm-main-head">
|
||||
<div class="comm-breadcrumb">Chat P2P > <strong id="chat-breadcrumb-peer">Select contact</strong></div>
|
||||
</div>
|
||||
<div class="chat-thread-panel">
|
||||
<div class="chat-thread-header" id="chat-header">Select a contact</div>
|
||||
<div id="chat-thread"></div>
|
||||
<div id="chat-thread" class="chat-messages"></div>
|
||||
<div class="chat-compose">
|
||||
<textarea id="chat-input" rows="2" placeholder="Type a message" disabled></textarea>
|
||||
<div class="chat-compose-row">
|
||||
<input type="file" id="chat-file" accept="image/*,.pdf,.doc,.docx,.txt" disabled />
|
||||
<button type="button" id="chat-send" class="btn-primary" disabled>Send</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,31 +88,63 @@ window.BackOneAddons.chat = function chatAddon() {
|
||||
|
||||
const thread = container.querySelector('#chat-thread');
|
||||
const input = container.querySelector('#chat-input');
|
||||
const fileInput = container.querySelector('#chat-file');
|
||||
const sendBtn = container.querySelector('#chat-send');
|
||||
|
||||
function updateInputs() {
|
||||
const ok = !!BackOneNetworkSession.getNetworkId() && !!selectedPeer;
|
||||
input.disabled = !ok;
|
||||
fileInput.disabled = !ok;
|
||||
sendBtn.disabled = !ok;
|
||||
renderHeader();
|
||||
const bc = root.querySelector('#chat-breadcrumb-peer');
|
||||
if (bc) bc.textContent = selectedPeer ? (selectedPeer.displayName || selectedPeer.peerId) : 'Select contact';
|
||||
}
|
||||
|
||||
const render = () => {
|
||||
if (!selectedPeer) {
|
||||
thread.innerHTML = '<p class="peer-empty">Choose a peer from the list</p>';
|
||||
thread.innerHTML = '<p class="chat-empty">Choose a contact from the list</p>';
|
||||
return;
|
||||
}
|
||||
const messages = getThread(selectedPeer.peerId);
|
||||
thread.innerHTML = messages.map(m =>
|
||||
`<div><strong>${m.senderName || m.sender || 'me'}:</strong> ${m.body || ''}${m.file ? ` [${m.file}]` : ''}</div>`
|
||||
).join('');
|
||||
if (!messages.length) {
|
||||
thread.innerHTML = '<p class="chat-empty">No messages yet — start the conversation</p>';
|
||||
return;
|
||||
}
|
||||
thread.innerHTML = messages.map(m => `
|
||||
<div class="chat-message">
|
||||
<div class="chat-message-meta">
|
||||
<span class="chat-message-sender">${escapeHtml(m.senderName || m.sender || 'peer')}</span>
|
||||
<span class="chat-message-time">${escapeHtml(formatTime(m.at))}</span>
|
||||
</div>
|
||||
${m.body ? `<div class="chat-message-body">${escapeHtml(m.body)}</div>` : ''}
|
||||
</div>`).join('');
|
||||
thread.scrollTop = thread.scrollHeight;
|
||||
};
|
||||
|
||||
function updateInputs() {
|
||||
const networkId = BackOneNetworkSession.getNetworkId();
|
||||
const ok = !!networkId && !!selectedPeer;
|
||||
input.disabled = !ok;
|
||||
sendBtn.disabled = !ok;
|
||||
renderHeader();
|
||||
}
|
||||
|
||||
if (unsubChat) unsubChat();
|
||||
unsubChat = BackOneWebHubClient.subscribe('chat_message', (msg) => {
|
||||
const networkId = BackOneNetworkSession.getNetworkId();
|
||||
if (!networkId || (msg.networkId && msg.networkId.toLowerCase() !== networkId)) return;
|
||||
const senderId = (msg.senderId || '').toLowerCase();
|
||||
const peerId = selectedPeer?.peerId?.toLowerCase();
|
||||
if (!peerId || senderId !== peerId) {
|
||||
if (senderId) {
|
||||
getThread(senderId).push({
|
||||
sender: senderId,
|
||||
senderName: msg.senderName || senderId,
|
||||
body: msg.body || '',
|
||||
at: msg.at || Date.now()
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
getThread(peerId).push({
|
||||
sender: senderId,
|
||||
senderName: msg.senderName || senderId,
|
||||
body: msg.body || '',
|
||||
at: msg.at || Date.now()
|
||||
});
|
||||
render();
|
||||
});
|
||||
|
||||
BackOnePeerPicker(container.querySelector('#chat-peer-picker'), {
|
||||
layout: 'sidebar',
|
||||
showActionButton: false,
|
||||
@@ -106,6 +155,7 @@ window.BackOneAddons.chat = function chatAddon() {
|
||||
},
|
||||
onNetworkChange() {
|
||||
selectedPeer = null;
|
||||
clearSessionMessages();
|
||||
updateInputs();
|
||||
render();
|
||||
}
|
||||
@@ -116,40 +166,27 @@ window.BackOneAddons.chat = function chatAddon() {
|
||||
render();
|
||||
});
|
||||
|
||||
sendBtn.onclick = async () => {
|
||||
sendBtn.onclick = () => {
|
||||
const networkId = BackOneNetworkSession.getNetworkId();
|
||||
if (!selectedPeer || !networkId) return;
|
||||
const body = input.value.trim();
|
||||
const selfName = 'me';
|
||||
if (fileInput.files.length) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', fileInput.files[0]);
|
||||
fd.append('networkId', networkId);
|
||||
await BackOneAuth.fetchWithAuth(`/controller/comms/network/${networkId}/chat/attachment`, {
|
||||
method: 'POST',
|
||||
body: fd
|
||||
}).catch(() => {});
|
||||
getThread(selectedPeer.peerId).push({
|
||||
sender: selfName,
|
||||
senderName: selfName,
|
||||
body: '',
|
||||
file: fileInput.files[0].name
|
||||
});
|
||||
fileInput.value = '';
|
||||
}
|
||||
if (body) {
|
||||
await BackOneAuth.fetchWithAuth(`/controller/comms/network/${networkId}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body, targetId: selectedPeer.peerId, networkId })
|
||||
}).catch(() => {});
|
||||
getThread(selectedPeer.peerId).push({
|
||||
sender: selfName,
|
||||
senderName: selfName,
|
||||
body
|
||||
});
|
||||
input.value = '';
|
||||
}
|
||||
if (!body) return;
|
||||
const now = Date.now();
|
||||
const name = selfName();
|
||||
BackOneWebHubClient.send({
|
||||
type: 'chat_message',
|
||||
networkId,
|
||||
targetId: selectedPeer.peerId,
|
||||
senderName: name,
|
||||
body
|
||||
});
|
||||
getThread(selectedPeer.peerId).push({
|
||||
sender: 'me',
|
||||
senderName: name,
|
||||
body,
|
||||
at: now
|
||||
});
|
||||
input.value = '';
|
||||
render();
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"id": "secure-chat",
|
||||
"name": "Secure Chat",
|
||||
"version": "1.17.1",
|
||||
"requires": ["comms-core", "webhub"],
|
||||
"backoneCore": {
|
||||
"apis": [
|
||||
"/app/api/peers",
|
||||
"/app/api/networks",
|
||||
"POST /app/api/network/{nwid}",
|
||||
"DELETE /app/api/network/{nwid}",
|
||||
"/app/api/network/{nwid}",
|
||||
"/app/api/network/{nwid}/members",
|
||||
"/app/api/authtoken",
|
||||
"/app/api/contacts"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"routes": ["/controller/comms/network"],
|
||||
"websocketEvents": ["secure_chat_message"]
|
||||
},
|
||||
"frontend": {
|
||||
"entry": "/app/addons/secure-chat/index.js",
|
||||
"navLabel": "Secure Chat",
|
||||
"icon": "chat"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
window.BackOneAddons = window.BackOneAddons || {};
|
||||
|
||||
window.BackOneAddons['secure-chat'] = function secureChatAddon() {
|
||||
const messagesByPeer = {};
|
||||
let selectedPeer = null;
|
||||
let root = null;
|
||||
let unsubChat = null;
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function clearSessionMessages() {
|
||||
Object.keys(messagesByPeer).forEach(k => delete messagesByPeer[k]);
|
||||
}
|
||||
|
||||
function getThread(peerId) {
|
||||
if (!messagesByPeer[peerId]) messagesByPeer[peerId] = [];
|
||||
return messagesByPeer[peerId];
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString(undefined, { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function networkLabel() {
|
||||
const n = BackOneNetworkSession.getActiveNetwork();
|
||||
return n ? (n.name || n.id) : BackOneNetworkSession.getNetworkId();
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
const header = root?.querySelector('#secure-chat-header');
|
||||
if (!header) return;
|
||||
const net = networkLabel();
|
||||
if (!selectedPeer) {
|
||||
header.textContent = net ? `Select a contact on ${net}` : 'Join a network in the header bar';
|
||||
return;
|
||||
}
|
||||
header.innerHTML = `<strong>${escapeHtml(selectedPeer.displayName || selectedPeer.peerId)}</strong> <small>${escapeHtml(selectedPeer.peerId)}${net ? ' · ' + escapeHtml(net) : ''}</small>`;
|
||||
}
|
||||
|
||||
function selfName() {
|
||||
const addr = (BackOneNetworkSession.getCoreStatus().address || '').toLowerCase();
|
||||
return addr ? addr.slice(0, 10) + '…' : 'me';
|
||||
}
|
||||
|
||||
return {
|
||||
mount(container) {
|
||||
root = container;
|
||||
clearSessionMessages();
|
||||
container.innerHTML = `
|
||||
<div class="comm-layout comm-split-layout">
|
||||
<aside class="comm-sidebar">
|
||||
<div class="comm-sidebar-head">
|
||||
<h2>Secure Chat</h2>
|
||||
<p class="comm-sidebar-sub">Contacts · session only</p>
|
||||
</div>
|
||||
<div class="comm-sidebar-body">
|
||||
<div id="secure-chat-peer-picker"></div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="comm-main">
|
||||
<div class="chat-thread-panel">
|
||||
<div class="chat-thread-header" id="secure-chat-header">Select a contact</div>
|
||||
<div id="secure-chat-thread" class="chat-messages"></div>
|
||||
<div class="chat-compose">
|
||||
<textarea id="secure-chat-input" rows="2" placeholder="Type a secure message" disabled></textarea>
|
||||
<div class="chat-compose-row">
|
||||
<button type="button" id="secure-chat-send" class="btn-primary" disabled>Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const thread = container.querySelector('#secure-chat-thread');
|
||||
const input = container.querySelector('#secure-chat-input');
|
||||
const sendBtn = container.querySelector('#secure-chat-send');
|
||||
|
||||
const render = () => {
|
||||
if (!selectedPeer) {
|
||||
thread.innerHTML = '<p class="chat-empty">Choose a contact from the list</p>';
|
||||
return;
|
||||
}
|
||||
const messages = getThread(selectedPeer.peerId);
|
||||
if (!messages.length) {
|
||||
thread.innerHTML = '<p class="chat-empty">No messages yet — start the conversation</p>';
|
||||
return;
|
||||
}
|
||||
thread.innerHTML = messages.map(m => `
|
||||
<div class="chat-message">
|
||||
<div class="chat-message-meta">
|
||||
<span class="chat-message-sender">${escapeHtml(m.senderName || m.sender || 'me')}</span>
|
||||
<span class="chat-message-time">${escapeHtml(formatTime(m.at))}</span>
|
||||
</div>
|
||||
${m.body ? `<div class="chat-message-body">${escapeHtml(m.body)}</div>` : ''}
|
||||
</div>`).join('');
|
||||
thread.scrollTop = thread.scrollHeight;
|
||||
};
|
||||
|
||||
function updateInputs() {
|
||||
const networkId = BackOneNetworkSession.getNetworkId();
|
||||
const ok = !!networkId && !!selectedPeer;
|
||||
input.disabled = !ok;
|
||||
sendBtn.disabled = !ok;
|
||||
renderHeader();
|
||||
}
|
||||
|
||||
if (unsubChat) unsubChat();
|
||||
unsubChat = BackOneWebHubClient.subscribe('chat_message', (msg) => {
|
||||
const networkId = BackOneNetworkSession.getNetworkId();
|
||||
if (!networkId || (msg.networkId && msg.networkId.toLowerCase() !== networkId)) return;
|
||||
const senderId = (msg.senderId || '').toLowerCase();
|
||||
const peerId = selectedPeer?.peerId?.toLowerCase();
|
||||
if (!peerId || senderId !== peerId) {
|
||||
if (senderId) {
|
||||
getThread(senderId).push({
|
||||
sender: senderId,
|
||||
senderName: msg.senderName || senderId,
|
||||
body: msg.body || '',
|
||||
at: msg.at || Date.now()
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
getThread(peerId).push({
|
||||
sender: senderId,
|
||||
senderName: msg.senderName || senderId,
|
||||
body: msg.body || '',
|
||||
at: msg.at || Date.now()
|
||||
});
|
||||
render();
|
||||
});
|
||||
|
||||
BackOnePeerPicker(container.querySelector('#secure-chat-peer-picker'), {
|
||||
layout: 'sidebar',
|
||||
showActionButton: false,
|
||||
onSelect(peer) {
|
||||
selectedPeer = peer;
|
||||
updateInputs();
|
||||
render();
|
||||
},
|
||||
onNetworkChange() {
|
||||
selectedPeer = null;
|
||||
clearSessionMessages();
|
||||
updateInputs();
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
BackOneNetworkSession.subscribe(() => {
|
||||
updateInputs();
|
||||
render();
|
||||
});
|
||||
|
||||
sendBtn.onclick = () => {
|
||||
const networkId = BackOneNetworkSession.getNetworkId();
|
||||
if (!selectedPeer || !networkId) return;
|
||||
const body = input.value.trim();
|
||||
if (!body) return;
|
||||
const now = Date.now();
|
||||
const name = selfName();
|
||||
BackOneWebHubClient.send({
|
||||
type: 'chat_message',
|
||||
networkId,
|
||||
targetId: selectedPeer.peerId,
|
||||
senderName: name,
|
||||
body
|
||||
});
|
||||
getThread(selectedPeer.peerId).push({
|
||||
sender: 'me',
|
||||
senderName: name,
|
||||
body,
|
||||
at: now
|
||||
});
|
||||
input.value = '';
|
||||
render();
|
||||
};
|
||||
|
||||
updateInputs();
|
||||
render();
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -55,6 +55,9 @@ run_migrations() {
|
||||
003_*)
|
||||
[ "$scope" = "all" ] || [ "$scope" = "core" ] || continue
|
||||
;;
|
||||
004_*|005_*)
|
||||
[ "$scope" = "all" ] || [ "$scope" = "core" ] || continue
|
||||
;;
|
||||
*)
|
||||
if [ "$scope" != "all" ] && [ "$scope" != "$ADDON_SCOPE" ]; then
|
||||
continue
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
BACKONE_HOME="${BACKONE_HOME:-/var/lib/backone}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
if [ -x "${SCRIPT_DIR}/merge-local-conf.sh" ]; then
|
||||
BACKONE_HOME="$BACKONE_HOME" bash "${SCRIPT_DIR}/merge-local-conf.sh" '{
|
||||
"settings": {
|
||||
"icePreferOverlay": true,
|
||||
"iceServers": [{"urls": "stun:stun.l.google.com:19302"}],
|
||||
"iceFallbackTimeoutMs": 8000,
|
||||
"webhubPort": 9994
|
||||
}
|
||||
}'
|
||||
fi
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
BACKONE_HOME="${BACKONE_HOME:-/var/lib/backone}"
|
||||
CONF="${BACKONE_HOME}/local.conf"
|
||||
|
||||
python3 - "$CONF" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
path = sys.argv[1]
|
||||
defaults = ["comms-core", "webhub", "voice", "video", "chat"]
|
||||
current = {}
|
||||
if os.path.isfile(path):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
try:
|
||||
current = json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
current = {}
|
||||
if not isinstance(current, dict):
|
||||
current = {}
|
||||
settings = current.setdefault("settings", {})
|
||||
if not isinstance(settings, dict):
|
||||
settings = {}
|
||||
current["settings"] = settings
|
||||
enabled = settings.get("enabledAddons")
|
||||
if enabled is None:
|
||||
# No explicit list — all installed addons stay enabled.
|
||||
sys.exit(0)
|
||||
if not isinstance(enabled, list):
|
||||
enabled = []
|
||||
merged = list(enabled)
|
||||
for addon in defaults:
|
||||
if addon not in merged:
|
||||
merged.append(addon)
|
||||
settings["enabledAddons"] = merged
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(current, f, indent=2)
|
||||
f.write("\n")
|
||||
PY
|
||||
|
||||
chown backone:backone "$CONF" 2>/dev/null || true
|
||||
chmod 644 "$CONF" 2>/dev/null || true
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "video",
|
||||
"name": "Video Call P2P",
|
||||
"version": "1.17.1",
|
||||
"requires": ["comms-core", "voice"],
|
||||
"requires": ["comms-core", "webhub", "voice"],
|
||||
"backoneCore": {
|
||||
"apis": [
|
||||
"/app/api/peers",
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
window.BackOneAddons = window.BackOneAddons || {};
|
||||
|
||||
window.BackOneAddons.video = function videoAddon() {
|
||||
let pc = null;
|
||||
let picker = null;
|
||||
let selectedPeer = null;
|
||||
let inCall = false;
|
||||
let localStream = null;
|
||||
let micEnabled = true;
|
||||
let camEnabled = true;
|
||||
let root = null;
|
||||
|
||||
function networkLabel() {
|
||||
const n = BackOneNetworkSession.getActiveNetwork();
|
||||
return n ? (n.name || n.id) : BackOneNetworkSession.getNetworkId();
|
||||
}
|
||||
|
||||
function updateConnectionBar() {
|
||||
const bar = root?.querySelector('#video-conn-bar');
|
||||
if (!bar || !window.BackOneCommLayout) return;
|
||||
@@ -53,19 +46,40 @@ window.BackOneAddons.video = function videoAddon() {
|
||||
updateConnectionBar();
|
||||
}
|
||||
|
||||
function showPlaceholder(show) {
|
||||
const ph = root?.querySelector('#video-placeholder');
|
||||
const remote = root?.querySelector('#video-remote');
|
||||
if (ph) ph.style.display = show ? 'block' : 'none';
|
||||
if (remote) remote.style.display = show ? 'none' : 'block';
|
||||
function updateCallLayout() {
|
||||
const layout = root?.querySelector('.comm-layout');
|
||||
if (layout) layout.classList.toggle('video-in-call', inCall);
|
||||
}
|
||||
|
||||
function hangUp() {
|
||||
if (pc) { pc.close(); pc = null; }
|
||||
if (localStream) {
|
||||
localStream.getTracks().forEach(t => t.stop());
|
||||
localStream = null;
|
||||
function showPlaceholder(show) {
|
||||
const stage = root?.querySelector('.video-stage');
|
||||
const remote = root?.querySelector('#video-remote');
|
||||
const phLabel = root?.querySelector('#video-ph-label');
|
||||
if (stage) stage.classList.toggle('has-remote', !show);
|
||||
if (remote) remote.classList.toggle('active', !show);
|
||||
if (phLabel && show) {
|
||||
phLabel.textContent = inCall
|
||||
? 'Waiting for remote peer…'
|
||||
: (selectedPeer ? 'Ready to call' : 'Select a peer to start a video call');
|
||||
}
|
||||
updateCallLayout();
|
||||
}
|
||||
|
||||
function bindStreams() {
|
||||
const local = root?.querySelector('#video-local');
|
||||
const remote = root?.querySelector('#video-remote');
|
||||
const localStream = BackOneWebRtcSession.getLocalStream();
|
||||
const remoteStream = BackOneWebRtcSession.getRemoteStream();
|
||||
if (local && localStream) local.srcObject = localStream;
|
||||
if (remote && remoteStream && remoteStream.getTracks().length) {
|
||||
remote.srcObject = remoteStream;
|
||||
showPlaceholder(false);
|
||||
root?.querySelector('#video-breadcrumb-state')?.textContent = 'Connected to';
|
||||
}
|
||||
}
|
||||
|
||||
async function hangUp() {
|
||||
await BackOneWebRtcSession.hangUp();
|
||||
const local = root?.querySelector('#video-local');
|
||||
const remote = root?.querySelector('#video-remote');
|
||||
if (local) local.srcObject = null;
|
||||
@@ -73,6 +87,8 @@ window.BackOneAddons.video = function videoAddon() {
|
||||
inCall = false;
|
||||
micEnabled = true;
|
||||
camEnabled = true;
|
||||
const breadcrumbState = root?.querySelector('#video-breadcrumb-state');
|
||||
if (breadcrumbState) breadcrumbState.textContent = 'Select peer';
|
||||
showPlaceholder(true);
|
||||
updateCallButtons();
|
||||
window.BackOneRingingPopup?.dismiss();
|
||||
@@ -99,20 +115,23 @@ window.BackOneAddons.video = function videoAddon() {
|
||||
</aside>
|
||||
<div class="comm-main">
|
||||
<div class="comm-main-head">
|
||||
<div class="comm-breadcrumb">Video Call P2P > <span id="video-breadcrumb-state">${inCall ? 'Connected to' : 'Select peer'}</span> <strong id="video-breadcrumb-peer">—</strong></div>
|
||||
<div class="comm-breadcrumb">Video Call P2P > <span id="video-breadcrumb-state">Select peer</span> <strong id="video-breadcrumb-peer">—</strong></div>
|
||||
<div id="video-head-status" class="comm-head-status"></div>
|
||||
</div>
|
||||
<div class="comm-stage">
|
||||
<div class="comm-remote-tag"><span class="dot"></span> Remote</div>
|
||||
<div id="video-placeholder" class="comm-remote-placeholder">
|
||||
<div class="avatar">👤</div>
|
||||
<div class="peer-id" id="video-ph-peer">—</div>
|
||||
<div>Remote Peer</div>
|
||||
<div class="comm-stage video-stage">
|
||||
<div class="video-remote-main">
|
||||
<div id="video-placeholder" class="comm-remote-placeholder">
|
||||
<div class="avatar">👤</div>
|
||||
<div class="peer-id" id="video-ph-peer">—</div>
|
||||
<div id="video-ph-label">Select a peer to start a video call</div>
|
||||
</div>
|
||||
<video id="video-remote" class="video-remote-stream" autoplay playsinline></video>
|
||||
</div>
|
||||
<video id="video-remote" autoplay playsinline style="display:none"></video>
|
||||
<div class="video-local-pip">
|
||||
<span class="video-pip-label">You</span>
|
||||
<video id="video-local" autoplay muted playsinline></video>
|
||||
</div>
|
||||
<div class="comm-remote-tag"><span class="dot"></span> Remote</div>
|
||||
<div class="comm-call-controls">
|
||||
<button type="button" id="video-mic">Mic</button>
|
||||
<button type="button" id="video-cam">Camera</button>
|
||||
@@ -133,6 +152,7 @@ window.BackOneAddons.video = function videoAddon() {
|
||||
selectedPeer = peer;
|
||||
const phPeer = root.querySelector('#video-ph-peer');
|
||||
if (phPeer) phPeer.textContent = peer.peerId;
|
||||
if (!inCall) showPlaceholder(true);
|
||||
updateCallButtons();
|
||||
},
|
||||
onNetworkChange() {
|
||||
@@ -142,45 +162,41 @@ window.BackOneAddons.video = function videoAddon() {
|
||||
});
|
||||
|
||||
BackOneNetworkSession.subscribe(() => updateCallButtons());
|
||||
BackOneWebRtcSession.subscribe((event) => {
|
||||
if (event === 'call-started') {
|
||||
inCall = true;
|
||||
updateCallButtons();
|
||||
bindStreams();
|
||||
}
|
||||
if (event === 'remote-track' || event === 'call-accepted') bindStreams();
|
||||
if (event === 'call-ended') {
|
||||
inCall = false;
|
||||
showPlaceholder(true);
|
||||
updateCallButtons();
|
||||
}
|
||||
});
|
||||
|
||||
callBtn.onclick = async () => {
|
||||
const peer = selectedPeer || picker.getSelectedPeer();
|
||||
const networkId = BackOneNetworkSession.getNetworkId();
|
||||
if (!peer || !networkId) return;
|
||||
if (!peer || !BackOneNetworkSession.getNetworkId()) return;
|
||||
try {
|
||||
pc = new RTCPeerConnection();
|
||||
pc.ontrack = (ev) => {
|
||||
root.querySelector('#video-remote').srcObject = ev.streams[0];
|
||||
showPlaceholder(false);
|
||||
};
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
|
||||
root.querySelector('#video-local').srcObject = localStream;
|
||||
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
await BackOneAuth.fetchWithAuth('/controller/comms/call', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'video',
|
||||
networkId,
|
||||
targetId: peer.peerId,
|
||||
sdp: offer.sdp
|
||||
})
|
||||
});
|
||||
inCall = true;
|
||||
root.querySelector('#video-breadcrumb-state').textContent = 'Connected to';
|
||||
root.querySelector('#video-breadcrumb-state').textContent = 'Connecting to';
|
||||
updateCallLayout();
|
||||
updateCallButtons();
|
||||
await BackOneWebRtcSession.startOutgoingCall(peer, 'video');
|
||||
bindStreams();
|
||||
} catch (err) {
|
||||
hangUp();
|
||||
await hangUp();
|
||||
alert(err.message || 'Call failed');
|
||||
}
|
||||
};
|
||||
|
||||
container.querySelector('#video-hangup').onclick = hangUp;
|
||||
container.querySelector('#video-hangup-stage').onclick = hangUp;
|
||||
container.querySelector('#video-hangup').onclick = () => hangUp();
|
||||
container.querySelector('#video-hangup-stage').onclick = () => hangUp();
|
||||
|
||||
container.querySelector('#video-mic').onclick = () => {
|
||||
const localStream = BackOneWebRtcSession.getLocalStream();
|
||||
if (!localStream) return;
|
||||
micEnabled = !micEnabled;
|
||||
localStream.getAudioTracks().forEach(t => { t.enabled = micEnabled; });
|
||||
@@ -188,6 +204,7 @@ window.BackOneAddons.video = function videoAddon() {
|
||||
};
|
||||
|
||||
container.querySelector('#video-cam').onclick = () => {
|
||||
const localStream = BackOneWebRtcSession.getLocalStream();
|
||||
if (!localStream) return;
|
||||
camEnabled = !camEnabled;
|
||||
localStream.getVideoTracks().forEach(t => { t.enabled = camEnabled; });
|
||||
@@ -199,7 +216,10 @@ window.BackOneAddons.video = function videoAddon() {
|
||||
if (stage.requestFullscreen) stage.requestFullscreen();
|
||||
};
|
||||
|
||||
const active = BackOneWebRtcSession.getActiveCall();
|
||||
inCall = !!active && active.callType === 'video';
|
||||
showPlaceholder(true);
|
||||
if (inCall) bindStreams();
|
||||
updateCallButtons();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "voice",
|
||||
"name": "Voice Call P2P",
|
||||
"version": "1.17.1",
|
||||
"requires": ["comms-core"],
|
||||
"requires": ["comms-core", "webhub"],
|
||||
"backoneCore": {
|
||||
"apis": [
|
||||
"/app/api/peers",
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
window.BackOneAddons = window.BackOneAddons || {};
|
||||
|
||||
window.BackOneAddons.voice = function voiceAddon() {
|
||||
let pc = null;
|
||||
let picker = null;
|
||||
let selectedPeer = null;
|
||||
let inCall = false;
|
||||
let localStream = null;
|
||||
let micEnabled = true;
|
||||
let root = null;
|
||||
let remoteAudio = null;
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function updateConnectionBar() {
|
||||
const bar = root?.querySelector('#voice-conn-bar');
|
||||
@@ -34,6 +37,16 @@ window.BackOneAddons.voice = function voiceAddon() {
|
||||
}
|
||||
}
|
||||
|
||||
function updateActiveBanner() {
|
||||
const banner = root?.querySelector('#voice-active-banner');
|
||||
const label = root?.querySelector('#voice-active-label');
|
||||
if (!banner) return;
|
||||
banner.style.display = inCall && selectedPeer ? 'flex' : 'none';
|
||||
if (label && selectedPeer) {
|
||||
label.textContent = `In call with ${selectedPeer.displayName || selectedPeer.peerId}`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateCallButtons() {
|
||||
const callBtn = root?.querySelector('#voice-call');
|
||||
if (callBtn) callBtn.disabled = !selectedPeer || !BackOneNetworkSession.getNetworkId() || inCall;
|
||||
@@ -45,16 +58,26 @@ window.BackOneAddons.voice = function voiceAddon() {
|
||||
}
|
||||
updateHeadStatus();
|
||||
updateConnectionBar();
|
||||
updateActiveBanner();
|
||||
}
|
||||
|
||||
function hangUp() {
|
||||
if (pc) { pc.close(); pc = null; }
|
||||
if (localStream) {
|
||||
localStream.getTracks().forEach(t => t.stop());
|
||||
localStream = null;
|
||||
function syncInCallState() {
|
||||
const active = BackOneWebRtcSession.getActiveCall();
|
||||
inCall = !!active && (active.callType === 'voice' || !active.callType);
|
||||
if (inCall && active.peer) {
|
||||
selectedPeer = active.peer.peerId ? active.peer : selectedPeer;
|
||||
root?.querySelector('#voice-breadcrumb-state')?.textContent = 'Connected to';
|
||||
}
|
||||
updateCallButtons();
|
||||
}
|
||||
|
||||
async function hangUp() {
|
||||
await BackOneWebRtcSession.hangUp();
|
||||
inCall = false;
|
||||
micEnabled = true;
|
||||
if (remoteAudio) remoteAudio.srcObject = null;
|
||||
const breadcrumbState = root?.querySelector('#voice-breadcrumb-state');
|
||||
if (breadcrumbState) breadcrumbState.textContent = 'Select peer';
|
||||
updateCallButtons();
|
||||
window.BackOneRingingPopup?.dismiss();
|
||||
}
|
||||
@@ -63,11 +86,11 @@ window.BackOneAddons.voice = function voiceAddon() {
|
||||
mount(container) {
|
||||
root = container;
|
||||
container.innerHTML = `
|
||||
<div class="comm-layout">
|
||||
<div class="comm-layout comm-split-layout">
|
||||
<aside class="comm-sidebar">
|
||||
<div class="comm-sidebar-head">
|
||||
<h2>Voice Call P2P</h2>
|
||||
<p class="comm-sidebar-sub">BackOne core · same network as backone-cli</p>
|
||||
<p class="comm-sidebar-sub">Contacts</p>
|
||||
</div>
|
||||
<div class="comm-sidebar-body">
|
||||
<div id="voice-picker"></div>
|
||||
@@ -83,31 +106,32 @@ window.BackOneAddons.voice = function voiceAddon() {
|
||||
<div class="comm-breadcrumb">Voice Call P2P > <span id="voice-breadcrumb-state">Select peer</span> <strong id="voice-breadcrumb-peer">—</strong></div>
|
||||
<div id="voice-head-status" class="comm-head-status"></div>
|
||||
</div>
|
||||
<div class="comm-stage">
|
||||
<div class="voice-stage-inner">
|
||||
<div class="avatar">🎙</div>
|
||||
<div class="peer-id" id="voice-ph-peer">—</div>
|
||||
<div id="voice-stage-label">Select a peer to call</div>
|
||||
</div>
|
||||
<div class="comm-call-controls">
|
||||
<button type="button" id="voice-mic">Mic</button>
|
||||
<button type="button" id="voice-hangup-stage" class="danger">Hang Up</button>
|
||||
<div class="voice-main-panel">
|
||||
<div id="voice-active-banner" class="voice-active-banner" style="display:none">
|
||||
<div>
|
||||
<span class="voice-active-dot"></span>
|
||||
<span id="voice-active-label">In call</span>
|
||||
</div>
|
||||
<div class="voice-active-controls">
|
||||
<button type="button" id="voice-mic">Mic</button>
|
||||
<button type="button" id="voice-hangup-stage" class="danger">Hang Up</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="voice-session-hint">Live calls only — no call history is stored.</p>
|
||||
<audio id="voice-remote-audio" autoplay playsinline class="voice-remote-audio"></audio>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="voice-conn-bar" class="comm-connection-bar"></div>`;
|
||||
|
||||
remoteAudio = container.querySelector('#voice-remote-audio');
|
||||
const callBtn = container.querySelector('#voice-call');
|
||||
const stageLabel = container.querySelector('#voice-stage-label');
|
||||
|
||||
picker = BackOnePeerPicker(container.querySelector('#voice-picker'), {
|
||||
layout: 'sidebar',
|
||||
showActionButton: false,
|
||||
onSelect(peer) {
|
||||
selectedPeer = peer;
|
||||
root.querySelector('#voice-ph-peer').textContent = peer.peerId;
|
||||
if (!inCall) stageLabel.textContent = `Ready to call ${peer.displayName || peer.peerId}`;
|
||||
updateCallButtons();
|
||||
},
|
||||
onNetworkChange() {
|
||||
@@ -117,47 +141,40 @@ window.BackOneAddons.voice = function voiceAddon() {
|
||||
});
|
||||
|
||||
BackOneNetworkSession.subscribe(() => updateCallButtons());
|
||||
BackOneWebRtcSession.subscribe((event) => {
|
||||
if (event === 'call-started' || event === 'call-ended' || event === 'remote-track') {
|
||||
syncInCallState();
|
||||
const stream = BackOneWebRtcSession.getRemoteStream();
|
||||
if (remoteAudio && stream) remoteAudio.srcObject = stream;
|
||||
}
|
||||
});
|
||||
|
||||
callBtn.onclick = async () => {
|
||||
const peer = selectedPeer || picker.getSelectedPeer();
|
||||
const networkId = BackOneNetworkSession.getNetworkId();
|
||||
if (!peer || !networkId) return;
|
||||
if (!peer || !BackOneNetworkSession.getNetworkId()) return;
|
||||
try {
|
||||
pc = new RTCPeerConnection();
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
||||
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
await BackOneAuth.fetchWithAuth('/controller/comms/call', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'voice',
|
||||
networkId,
|
||||
targetId: peer.peerId,
|
||||
sdp: offer.sdp
|
||||
})
|
||||
});
|
||||
await BackOneWebRtcSession.startOutgoingCall(peer, 'voice');
|
||||
inCall = true;
|
||||
root.querySelector('#voice-breadcrumb-state').textContent = 'Connected to';
|
||||
stageLabel.textContent = `In call with ${peer.displayName || peer.peerId}`;
|
||||
root.querySelector('#voice-breadcrumb-state').textContent = 'Connecting to';
|
||||
updateCallButtons();
|
||||
} catch (err) {
|
||||
hangUp();
|
||||
await hangUp();
|
||||
alert(err.message || 'Call failed');
|
||||
}
|
||||
};
|
||||
|
||||
container.querySelector('#voice-hangup').onclick = hangUp;
|
||||
container.querySelector('#voice-hangup-stage').onclick = hangUp;
|
||||
container.querySelector('#voice-hangup').onclick = () => hangUp();
|
||||
container.querySelector('#voice-hangup-stage').onclick = () => hangUp();
|
||||
|
||||
container.querySelector('#voice-mic').onclick = () => {
|
||||
const localStream = BackOneWebRtcSession.getLocalStream();
|
||||
if (!localStream) return;
|
||||
micEnabled = !micEnabled;
|
||||
localStream.getAudioTracks().forEach(t => { t.enabled = micEnabled; });
|
||||
container.querySelector('#voice-mic').textContent = micEnabled ? 'Mic' : 'Mic off';
|
||||
};
|
||||
|
||||
syncInCallState();
|
||||
updateCallButtons();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "webhub",
|
||||
"name": "WebSocket Signaling Hub",
|
||||
"version": "1.17.1",
|
||||
"requires": ["comms-core"],
|
||||
"backend": {
|
||||
"routes": [],
|
||||
"websocketEvents": ["presence", "incoming_call", "call_state", "ice_candidate", "chat_message", "typing"],
|
||||
"sidecar": {
|
||||
"command": "/usr/lib/backone/addons/webhub/backone-webhub",
|
||||
"port": 9994,
|
||||
"bind": "127.0.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+343
@@ -0,0 +1,343 @@
|
||||
#!/usr/bin/env python3
|
||||
"""BackOne WebHub — ephemeral in-memory WebSocket signaling (no DB, no history)."""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
try:
|
||||
import websockets
|
||||
from websockets.server import serve
|
||||
except ImportError:
|
||||
raise SystemExit("python3-websockets is required")
|
||||
|
||||
BACKONE_HOME = os.environ.get("BACKONE_HOME", "/var/lib/backone")
|
||||
AUTH_PATH = os.path.join(BACKONE_HOME, "authtoken.secret")
|
||||
CONF_PATH = os.path.join(BACKONE_HOME, "local.conf")
|
||||
DEFAULT_PORT = 9994
|
||||
RING_TIMEOUT_SEC = 30
|
||||
|
||||
peers = {} # peer_id -> {ws, network_id, registered_at}
|
||||
calls = {} # call_id -> call dict
|
||||
ice_queues = {} # call_id -> list of {from, candidate, ...}
|
||||
peer_to_calls = {} # peer_id -> set(call_id)
|
||||
|
||||
|
||||
def load_auth_token():
|
||||
try:
|
||||
with open(AUTH_PATH, encoding="utf-8") as f:
|
||||
t = f.read().strip()
|
||||
return t if t else None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def load_port():
|
||||
try:
|
||||
with open(CONF_PATH, encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
settings = cfg.get("settings") or {}
|
||||
return int(settings.get("webhubPort", DEFAULT_PORT))
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
||||
return DEFAULT_PORT
|
||||
|
||||
|
||||
def valid_token(token):
|
||||
expected = load_auth_token()
|
||||
if not expected or not token:
|
||||
return False
|
||||
return secrets.compare_digest(token.strip(), expected.strip())
|
||||
|
||||
|
||||
def new_call_id():
|
||||
return f"call_{int(time.time() * 1000)}_{secrets.token_hex(4)}"
|
||||
|
||||
|
||||
def peer_key(peer_id):
|
||||
return (peer_id or "").lower()
|
||||
|
||||
|
||||
def send_json(ws, payload):
|
||||
return ws.send(json.dumps(payload))
|
||||
|
||||
|
||||
async def push_to_peer(target_id, payload):
|
||||
key = peer_key(target_id)
|
||||
entry = peers.get(key)
|
||||
if not entry:
|
||||
return False
|
||||
try:
|
||||
await send_json(entry["ws"], payload)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def track_call_for_peer(pid, call_id):
|
||||
key = peer_key(pid)
|
||||
if key not in peer_to_calls:
|
||||
peer_to_calls[key] = set()
|
||||
peer_to_calls[key].add(call_id)
|
||||
|
||||
|
||||
def untrack_call(call_id, call):
|
||||
for pid in (call.get("callerId"), call.get("calleeId")):
|
||||
key = peer_key(pid)
|
||||
if key in peer_to_calls:
|
||||
peer_to_calls[key].discard(call_id)
|
||||
if not peer_to_calls[key]:
|
||||
del peer_to_calls[key]
|
||||
|
||||
|
||||
def purge_call(call_id):
|
||||
call = calls.pop(call_id, None)
|
||||
ice_queues.pop(call_id, None)
|
||||
if call:
|
||||
untrack_call(call_id, call)
|
||||
|
||||
|
||||
def end_calls_for_peer(peer_id):
|
||||
key = peer_key(peer_id)
|
||||
for call_id in list(peer_to_calls.get(key, set())):
|
||||
call = calls.get(call_id)
|
||||
if not call:
|
||||
continue
|
||||
other = call["calleeId"] if peer_key(call["callerId"]) == key else call["callerId"]
|
||||
asyncio.create_task(push_to_peer(other, {
|
||||
"type": "call_state",
|
||||
"callId": call_id,
|
||||
"state": "ended",
|
||||
"reason": "peer_disconnect"
|
||||
}))
|
||||
purge_call(call_id)
|
||||
|
||||
|
||||
async def ring_timeout_watch():
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
now = time.time()
|
||||
for call_id, call in list(calls.items()):
|
||||
if call.get("state") != "ringing":
|
||||
continue
|
||||
if now - call.get("createdAt", now) > RING_TIMEOUT_SEC:
|
||||
await push_to_peer(call["callerId"], {
|
||||
"type": "call_state",
|
||||
"callId": call_id,
|
||||
"state": "rejected",
|
||||
"reason": "timeout"
|
||||
})
|
||||
purge_call(call_id)
|
||||
|
||||
|
||||
async def handle_message(ws, state, msg):
|
||||
mtype = msg.get("type")
|
||||
if mtype == "auth":
|
||||
token = msg.get("token", "")
|
||||
if not valid_token(token):
|
||||
await send_json(ws, {"type": "error", "error": "unauthorized"})
|
||||
await ws.close()
|
||||
return False
|
||||
state["authed"] = True
|
||||
await send_json(ws, {"type": "auth_ok"})
|
||||
return True
|
||||
|
||||
if not state.get("authed"):
|
||||
await send_json(ws, {"type": "error", "error": "not authenticated"})
|
||||
return True
|
||||
|
||||
if mtype == "register":
|
||||
peer_id = peer_key(msg.get("peerId", ""))
|
||||
network_id = (msg.get("networkId") or "").lower()
|
||||
if not peer_id:
|
||||
await send_json(ws, {"type": "error", "error": "peerId required"})
|
||||
return True
|
||||
peers[peer_id] = {"ws": ws, "networkId": network_id, "registeredAt": time.time()}
|
||||
state["peerId"] = peer_id
|
||||
await send_json(ws, {"type": "registered", "peerId": peer_id})
|
||||
return True
|
||||
|
||||
peer_id = state.get("peerId")
|
||||
if not peer_id:
|
||||
await send_json(ws, {"type": "error", "error": "register first"})
|
||||
return True
|
||||
|
||||
if mtype == "call_offer":
|
||||
target_id = peer_key(msg.get("targetId", ""))
|
||||
call_type = msg.get("callType") or "voice"
|
||||
sdp = msg.get("sdp", "")
|
||||
network_id = (msg.get("networkId") or peers[peer_id].get("networkId") or "").lower()
|
||||
if not target_id or not sdp:
|
||||
await send_json(ws, {"type": "error", "error": "targetId and sdp required"})
|
||||
return True
|
||||
call_id = new_call_id()
|
||||
call = {
|
||||
"callId": call_id,
|
||||
"callType": call_type,
|
||||
"networkId": network_id,
|
||||
"callerId": peer_id,
|
||||
"calleeId": target_id,
|
||||
"state": "ringing",
|
||||
"offerSdp": sdp,
|
||||
"answerSdp": "",
|
||||
"createdAt": time.time()
|
||||
}
|
||||
calls[call_id] = call
|
||||
ice_queues[call_id] = []
|
||||
track_call_for_peer(peer_id, call_id)
|
||||
track_call_for_peer(target_id, call_id)
|
||||
delivered = await push_to_peer(target_id, {
|
||||
"type": "incoming_call",
|
||||
"callId": call_id,
|
||||
"callType": call_type,
|
||||
"networkId": network_id,
|
||||
"callerId": peer_id,
|
||||
"callerName": msg.get("callerName", peer_id),
|
||||
"sdp": sdp,
|
||||
"overlayIps": msg.get("overlayIps", [])
|
||||
})
|
||||
await send_json(ws, {
|
||||
"type": "call_state",
|
||||
"callId": call_id,
|
||||
"state": "ringing",
|
||||
"delivered": delivered
|
||||
})
|
||||
return True
|
||||
|
||||
if mtype == "call_answer":
|
||||
call_id = msg.get("callId", "")
|
||||
sdp = msg.get("sdp", "")
|
||||
call = calls.get(call_id)
|
||||
if not call or peer_key(call["calleeId"]) != peer_id:
|
||||
await send_json(ws, {"type": "error", "error": "call not found"})
|
||||
return True
|
||||
call["answerSdp"] = sdp
|
||||
call["state"] = "accepted"
|
||||
await push_to_peer(call["callerId"], {
|
||||
"type": "call_state",
|
||||
"callId": call_id,
|
||||
"state": "accepted",
|
||||
"sdp": sdp
|
||||
})
|
||||
await send_json(ws, {"type": "call_state", "callId": call_id, "state": "accepted"})
|
||||
return True
|
||||
|
||||
if mtype == "call_reject":
|
||||
call_id = msg.get("callId", "")
|
||||
call = calls.get(call_id)
|
||||
if call and peer_key(call["calleeId"]) == peer_id:
|
||||
await push_to_peer(call["callerId"], {
|
||||
"type": "call_state",
|
||||
"callId": call_id,
|
||||
"state": "rejected"
|
||||
})
|
||||
purge_call(call_id)
|
||||
return True
|
||||
|
||||
if mtype == "call_end":
|
||||
call_id = msg.get("callId", "")
|
||||
call = calls.get(call_id)
|
||||
if call and peer_id in (peer_key(call["callerId"]), peer_key(call["calleeId"])):
|
||||
other = call["calleeId"] if peer_key(call["callerId"]) == peer_id else call["callerId"]
|
||||
await push_to_peer(other, {
|
||||
"type": "call_state",
|
||||
"callId": call_id,
|
||||
"state": "ended"
|
||||
})
|
||||
purge_call(call_id)
|
||||
return True
|
||||
|
||||
if mtype == "ice_candidate":
|
||||
call_id = msg.get("callId", "")
|
||||
call = calls.get(call_id)
|
||||
if not call:
|
||||
return True
|
||||
entry = {
|
||||
"from": peer_id,
|
||||
"candidate": msg.get("candidate"),
|
||||
"sdpMid": msg.get("sdpMid"),
|
||||
"sdpMLineIndex": msg.get("sdpMLineIndex"),
|
||||
"at": time.time()
|
||||
}
|
||||
ice_queues.setdefault(call_id, []).append(entry)
|
||||
other = call["calleeId"] if peer_key(call["callerId"]) == peer_id else call["callerId"]
|
||||
await push_to_peer(other, {
|
||||
"type": "ice_candidate",
|
||||
"callId": call_id,
|
||||
**{k: entry[k] for k in ("candidate", "sdpMid", "sdpMLineIndex")}
|
||||
})
|
||||
return True
|
||||
|
||||
if mtype == "ice_fetch":
|
||||
call_id = msg.get("callId", "")
|
||||
since = float(msg.get("since", 0))
|
||||
queued = [e for e in ice_queues.get(call_id, []) if e.get("at", 0) > since and e.get("from") != peer_id]
|
||||
await send_json(ws, {"type": "ice_candidates", "callId": call_id, "candidates": queued})
|
||||
return True
|
||||
|
||||
if mtype == "chat_message":
|
||||
target_id = peer_key(msg.get("targetId", ""))
|
||||
if target_id:
|
||||
await push_to_peer(target_id, {
|
||||
"type": "chat_message",
|
||||
"networkId": msg.get("networkId", ""),
|
||||
"senderId": peer_id,
|
||||
"senderName": msg.get("senderName", peer_id),
|
||||
"body": msg.get("body", ""),
|
||||
"at": int(time.time() * 1000)
|
||||
})
|
||||
return True
|
||||
|
||||
if mtype == "typing":
|
||||
target_id = peer_key(msg.get("targetId", ""))
|
||||
if target_id:
|
||||
await push_to_peer(target_id, {
|
||||
"type": "typing",
|
||||
"networkId": msg.get("networkId", ""),
|
||||
"senderId": peer_id,
|
||||
"typing": bool(msg.get("typing", True))
|
||||
})
|
||||
return True
|
||||
|
||||
await send_json(ws, {"type": "error", "error": f"unknown type: {mtype}"})
|
||||
return True
|
||||
|
||||
|
||||
async def connection_handler(ws, path):
|
||||
state = {"authed": False, "peerId": None}
|
||||
query = urllib.parse.parse_qs(urllib.parse.urlparse(path).query)
|
||||
if "auth" in query and valid_token(query["auth"][0]):
|
||||
state["authed"] = True
|
||||
await send_json(ws, {"type": "auth_ok"})
|
||||
|
||||
try:
|
||||
async for raw in ws:
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
await send_json(ws, {"type": "error", "error": "invalid json"})
|
||||
continue
|
||||
if not await handle_message(ws, state, msg):
|
||||
break
|
||||
finally:
|
||||
pid = state.get("peerId")
|
||||
if pid and peers.get(pid, {}).get("ws") is ws:
|
||||
del peers[pid]
|
||||
end_calls_for_peer(pid)
|
||||
|
||||
|
||||
async def main():
|
||||
port = load_port()
|
||||
host = "127.0.0.1"
|
||||
asyncio.create_task(ring_timeout_watch())
|
||||
async with serve(connection_handler, host, port, ping_interval=20, ping_timeout=20):
|
||||
print(f"BackOne WebHub listening on ws://{host}:{port}", flush=True)
|
||||
await asyncio.Future()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=BackOne WebSocket signaling hub (webhub)
|
||||
After=network.target backone.service
|
||||
Wants=backone.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/lib/backone/addons/webhub/backone-webhub
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
User=backone
|
||||
Group=backone
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in new issue
Block a user