secure chat addon and stun

This commit is contained in:
ariska committed 2026-07-02 09:12:57 +07:00
1 parent a45fa67ed2
commit 813d3aa06e
40 files changed
+2767 -189

No files matched your search

+1 -1
View File
@@ -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",
+91 -54
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 &gt; <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();
};
+27
View File
@@ -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"
}
}
+186
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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();
}
};
};
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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",
+71 -51
View File
@@ -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 &gt; <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 &gt; <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();
}
};
+1 -1
View File
@@ -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",
+60 -43
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 &gt; <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();
}
};
+15
View File
@@ -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"
}
}
}
+343
View File
@@ -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
+15
View File
@@ -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