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
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -2,7 +2,7 @@
|
|||||||
"id": "chat",
|
"id": "chat",
|
||||||
"name": "Chat P2P",
|
"name": "Chat P2P",
|
||||||
"version": "1.17.1",
|
"version": "1.17.1",
|
||||||
"requires": ["comms-core"],
|
"requires": ["comms-core", "webhub"],
|
||||||
"backoneCore": {
|
"backoneCore": {
|
||||||
"apis": [
|
"apis": [
|
||||||
"/app/api/peers",
|
"/app/api/peers",
|
||||||
|
|||||||
@@ -4,12 +4,27 @@ window.BackOneAddons.chat = function chatAddon() {
|
|||||||
const messagesByPeer = {};
|
const messagesByPeer = {};
|
||||||
let selectedPeer = null;
|
let selectedPeer = null;
|
||||||
let root = 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) {
|
function getThread(peerId) {
|
||||||
if (!messagesByPeer[peerId]) messagesByPeer[peerId] = [];
|
if (!messagesByPeer[peerId]) messagesByPeer[peerId] = [];
|
||||||
return 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() {
|
function networkLabel() {
|
||||||
const n = BackOneNetworkSession.getActiveNetwork();
|
const n = BackOneNetworkSession.getActiveNetwork();
|
||||||
return n ? (n.name || n.id) : BackOneNetworkSession.getNetworkId();
|
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';
|
header.textContent = net ? `Select a contact on ${net}` : 'Join a network in the header bar';
|
||||||
return;
|
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();
|
updateConnectionBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selfName() {
|
||||||
|
const addr = (BackOneNetworkSession.getCoreStatus().address || '').toLowerCase();
|
||||||
|
return addr ? addr.slice(0, 10) + '…' : 'me';
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mount(container) {
|
mount(container) {
|
||||||
root = container;
|
root = container;
|
||||||
|
clearSessionMessages();
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="comm-layout">
|
<div class="comm-layout comm-split-layout">
|
||||||
<aside class="comm-sidebar">
|
<aside class="comm-sidebar">
|
||||||
<div class="comm-sidebar-head">
|
<div class="comm-sidebar-head">
|
||||||
<h2>Chat P2P</h2>
|
<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>
|
||||||
<div class="comm-sidebar-body">
|
<div class="comm-sidebar-body">
|
||||||
<div id="chat-peer-picker"></div>
|
<div id="chat-peer-picker"></div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="comm-main">
|
<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-panel">
|
||||||
<div class="chat-thread-header" id="chat-header">Select a contact</div>
|
<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">
|
<div class="chat-compose">
|
||||||
<textarea id="chat-input" rows="2" placeholder="Type a message" disabled></textarea>
|
<textarea id="chat-input" rows="2" placeholder="Type a message" disabled></textarea>
|
||||||
<div class="chat-compose-row">
|
<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>
|
<button type="button" id="chat-send" class="btn-primary" disabled>Send</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,31 +88,63 @@ window.BackOneAddons.chat = function chatAddon() {
|
|||||||
|
|
||||||
const thread = container.querySelector('#chat-thread');
|
const thread = container.querySelector('#chat-thread');
|
||||||
const input = container.querySelector('#chat-input');
|
const input = container.querySelector('#chat-input');
|
||||||
const fileInput = container.querySelector('#chat-file');
|
|
||||||
const sendBtn = container.querySelector('#chat-send');
|
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 = () => {
|
const render = () => {
|
||||||
if (!selectedPeer) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const messages = getThread(selectedPeer.peerId);
|
const messages = getThread(selectedPeer.peerId);
|
||||||
thread.innerHTML = messages.map(m =>
|
if (!messages.length) {
|
||||||
`<div><strong>${m.senderName || m.sender || 'me'}:</strong> ${m.body || ''}${m.file ? ` [${m.file}]` : ''}</div>`
|
thread.innerHTML = '<p class="chat-empty">No messages yet — start the conversation</p>';
|
||||||
).join('');
|
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;
|
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'), {
|
BackOnePeerPicker(container.querySelector('#chat-peer-picker'), {
|
||||||
layout: 'sidebar',
|
layout: 'sidebar',
|
||||||
showActionButton: false,
|
showActionButton: false,
|
||||||
@@ -106,6 +155,7 @@ window.BackOneAddons.chat = function chatAddon() {
|
|||||||
},
|
},
|
||||||
onNetworkChange() {
|
onNetworkChange() {
|
||||||
selectedPeer = null;
|
selectedPeer = null;
|
||||||
|
clearSessionMessages();
|
||||||
updateInputs();
|
updateInputs();
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
@@ -116,40 +166,27 @@ window.BackOneAddons.chat = function chatAddon() {
|
|||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
sendBtn.onclick = async () => {
|
sendBtn.onclick = () => {
|
||||||
const networkId = BackOneNetworkSession.getNetworkId();
|
const networkId = BackOneNetworkSession.getNetworkId();
|
||||||
if (!selectedPeer || !networkId) return;
|
if (!selectedPeer || !networkId) return;
|
||||||
const body = input.value.trim();
|
const body = input.value.trim();
|
||||||
const selfName = 'me';
|
if (!body) return;
|
||||||
if (fileInput.files.length) {
|
const now = Date.now();
|
||||||
const fd = new FormData();
|
const name = selfName();
|
||||||
fd.append('file', fileInput.files[0]);
|
BackOneWebHubClient.send({
|
||||||
fd.append('networkId', networkId);
|
type: 'chat_message',
|
||||||
await BackOneAuth.fetchWithAuth(`/controller/comms/network/${networkId}/chat/attachment`, {
|
networkId,
|
||||||
method: 'POST',
|
targetId: selectedPeer.peerId,
|
||||||
body: fd
|
senderName: name,
|
||||||
}).catch(() => {});
|
body
|
||||||
getThread(selectedPeer.peerId).push({
|
});
|
||||||
sender: selfName,
|
getThread(selectedPeer.peerId).push({
|
||||||
senderName: selfName,
|
sender: 'me',
|
||||||
body: '',
|
senderName: name,
|
||||||
file: fileInput.files[0].name
|
body,
|
||||||
});
|
at: now
|
||||||
fileInput.value = '';
|
});
|
||||||
}
|
input.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 = '';
|
|
||||||
}
|
|
||||||
render();
|
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_*)
|
003_*)
|
||||||
[ "$scope" = "all" ] || [ "$scope" = "core" ] || continue
|
[ "$scope" = "all" ] || [ "$scope" = "core" ] || continue
|
||||||
;;
|
;;
|
||||||
|
004_*|005_*)
|
||||||
|
[ "$scope" = "all" ] || [ "$scope" = "core" ] || continue
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
if [ "$scope" != "all" ] && [ "$scope" != "$ADDON_SCOPE" ]; then
|
if [ "$scope" != "all" ] && [ "$scope" != "$ADDON_SCOPE" ]; then
|
||||||
continue
|
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",
|
"id": "video",
|
||||||
"name": "Video Call P2P",
|
"name": "Video Call P2P",
|
||||||
"version": "1.17.1",
|
"version": "1.17.1",
|
||||||
"requires": ["comms-core", "voice"],
|
"requires": ["comms-core", "webhub", "voice"],
|
||||||
"backoneCore": {
|
"backoneCore": {
|
||||||
"apis": [
|
"apis": [
|
||||||
"/app/api/peers",
|
"/app/api/peers",
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
window.BackOneAddons = window.BackOneAddons || {};
|
window.BackOneAddons = window.BackOneAddons || {};
|
||||||
|
|
||||||
window.BackOneAddons.video = function videoAddon() {
|
window.BackOneAddons.video = function videoAddon() {
|
||||||
let pc = null;
|
|
||||||
let picker = null;
|
let picker = null;
|
||||||
let selectedPeer = null;
|
let selectedPeer = null;
|
||||||
let inCall = false;
|
let inCall = false;
|
||||||
let localStream = null;
|
|
||||||
let micEnabled = true;
|
let micEnabled = true;
|
||||||
let camEnabled = true;
|
let camEnabled = true;
|
||||||
let root = null;
|
let root = null;
|
||||||
|
|
||||||
function networkLabel() {
|
|
||||||
const n = BackOneNetworkSession.getActiveNetwork();
|
|
||||||
return n ? (n.name || n.id) : BackOneNetworkSession.getNetworkId();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateConnectionBar() {
|
function updateConnectionBar() {
|
||||||
const bar = root?.querySelector('#video-conn-bar');
|
const bar = root?.querySelector('#video-conn-bar');
|
||||||
if (!bar || !window.BackOneCommLayout) return;
|
if (!bar || !window.BackOneCommLayout) return;
|
||||||
@@ -53,19 +46,40 @@ window.BackOneAddons.video = function videoAddon() {
|
|||||||
updateConnectionBar();
|
updateConnectionBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showPlaceholder(show) {
|
function updateCallLayout() {
|
||||||
const ph = root?.querySelector('#video-placeholder');
|
const layout = root?.querySelector('.comm-layout');
|
||||||
const remote = root?.querySelector('#video-remote');
|
if (layout) layout.classList.toggle('video-in-call', inCall);
|
||||||
if (ph) ph.style.display = show ? 'block' : 'none';
|
|
||||||
if (remote) remote.style.display = show ? 'none' : 'block';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hangUp() {
|
function showPlaceholder(show) {
|
||||||
if (pc) { pc.close(); pc = null; }
|
const stage = root?.querySelector('.video-stage');
|
||||||
if (localStream) {
|
const remote = root?.querySelector('#video-remote');
|
||||||
localStream.getTracks().forEach(t => t.stop());
|
const phLabel = root?.querySelector('#video-ph-label');
|
||||||
localStream = null;
|
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 local = root?.querySelector('#video-local');
|
||||||
const remote = root?.querySelector('#video-remote');
|
const remote = root?.querySelector('#video-remote');
|
||||||
if (local) local.srcObject = null;
|
if (local) local.srcObject = null;
|
||||||
@@ -73,6 +87,8 @@ window.BackOneAddons.video = function videoAddon() {
|
|||||||
inCall = false;
|
inCall = false;
|
||||||
micEnabled = true;
|
micEnabled = true;
|
||||||
camEnabled = true;
|
camEnabled = true;
|
||||||
|
const breadcrumbState = root?.querySelector('#video-breadcrumb-state');
|
||||||
|
if (breadcrumbState) breadcrumbState.textContent = 'Select peer';
|
||||||
showPlaceholder(true);
|
showPlaceholder(true);
|
||||||
updateCallButtons();
|
updateCallButtons();
|
||||||
window.BackOneRingingPopup?.dismiss();
|
window.BackOneRingingPopup?.dismiss();
|
||||||
@@ -99,20 +115,23 @@ window.BackOneAddons.video = function videoAddon() {
|
|||||||
</aside>
|
</aside>
|
||||||
<div class="comm-main">
|
<div class="comm-main">
|
||||||
<div class="comm-main-head">
|
<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 id="video-head-status" class="comm-head-status"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="comm-stage">
|
<div class="comm-stage video-stage">
|
||||||
<div class="comm-remote-tag"><span class="dot"></span> Remote</div>
|
<div class="video-remote-main">
|
||||||
<div id="video-placeholder" class="comm-remote-placeholder">
|
<div id="video-placeholder" class="comm-remote-placeholder">
|
||||||
<div class="avatar">👤</div>
|
<div class="avatar">👤</div>
|
||||||
<div class="peer-id" id="video-ph-peer">—</div>
|
<div class="peer-id" id="video-ph-peer">—</div>
|
||||||
<div>Remote 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>
|
</div>
|
||||||
<video id="video-remote" autoplay playsinline style="display:none"></video>
|
|
||||||
<div class="video-local-pip">
|
<div class="video-local-pip">
|
||||||
|
<span class="video-pip-label">You</span>
|
||||||
<video id="video-local" autoplay muted playsinline></video>
|
<video id="video-local" autoplay muted playsinline></video>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="comm-remote-tag"><span class="dot"></span> Remote</div>
|
||||||
<div class="comm-call-controls">
|
<div class="comm-call-controls">
|
||||||
<button type="button" id="video-mic">Mic</button>
|
<button type="button" id="video-mic">Mic</button>
|
||||||
<button type="button" id="video-cam">Camera</button>
|
<button type="button" id="video-cam">Camera</button>
|
||||||
@@ -133,6 +152,7 @@ window.BackOneAddons.video = function videoAddon() {
|
|||||||
selectedPeer = peer;
|
selectedPeer = peer;
|
||||||
const phPeer = root.querySelector('#video-ph-peer');
|
const phPeer = root.querySelector('#video-ph-peer');
|
||||||
if (phPeer) phPeer.textContent = peer.peerId;
|
if (phPeer) phPeer.textContent = peer.peerId;
|
||||||
|
if (!inCall) showPlaceholder(true);
|
||||||
updateCallButtons();
|
updateCallButtons();
|
||||||
},
|
},
|
||||||
onNetworkChange() {
|
onNetworkChange() {
|
||||||
@@ -142,45 +162,41 @@ window.BackOneAddons.video = function videoAddon() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
BackOneNetworkSession.subscribe(() => updateCallButtons());
|
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 () => {
|
callBtn.onclick = async () => {
|
||||||
const peer = selectedPeer || picker.getSelectedPeer();
|
const peer = selectedPeer || picker.getSelectedPeer();
|
||||||
const networkId = BackOneNetworkSession.getNetworkId();
|
if (!peer || !BackOneNetworkSession.getNetworkId()) return;
|
||||||
if (!peer || !networkId) return;
|
|
||||||
try {
|
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;
|
inCall = true;
|
||||||
root.querySelector('#video-breadcrumb-state').textContent = 'Connected to';
|
root.querySelector('#video-breadcrumb-state').textContent = 'Connecting to';
|
||||||
|
updateCallLayout();
|
||||||
updateCallButtons();
|
updateCallButtons();
|
||||||
|
await BackOneWebRtcSession.startOutgoingCall(peer, 'video');
|
||||||
|
bindStreams();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
hangUp();
|
await hangUp();
|
||||||
alert(err.message || 'Call failed');
|
alert(err.message || 'Call failed');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
container.querySelector('#video-hangup').onclick = hangUp;
|
container.querySelector('#video-hangup').onclick = () => hangUp();
|
||||||
container.querySelector('#video-hangup-stage').onclick = hangUp;
|
container.querySelector('#video-hangup-stage').onclick = () => hangUp();
|
||||||
|
|
||||||
container.querySelector('#video-mic').onclick = () => {
|
container.querySelector('#video-mic').onclick = () => {
|
||||||
|
const localStream = BackOneWebRtcSession.getLocalStream();
|
||||||
if (!localStream) return;
|
if (!localStream) return;
|
||||||
micEnabled = !micEnabled;
|
micEnabled = !micEnabled;
|
||||||
localStream.getAudioTracks().forEach(t => { t.enabled = micEnabled; });
|
localStream.getAudioTracks().forEach(t => { t.enabled = micEnabled; });
|
||||||
@@ -188,6 +204,7 @@ window.BackOneAddons.video = function videoAddon() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
container.querySelector('#video-cam').onclick = () => {
|
container.querySelector('#video-cam').onclick = () => {
|
||||||
|
const localStream = BackOneWebRtcSession.getLocalStream();
|
||||||
if (!localStream) return;
|
if (!localStream) return;
|
||||||
camEnabled = !camEnabled;
|
camEnabled = !camEnabled;
|
||||||
localStream.getVideoTracks().forEach(t => { t.enabled = camEnabled; });
|
localStream.getVideoTracks().forEach(t => { t.enabled = camEnabled; });
|
||||||
@@ -199,7 +216,10 @@ window.BackOneAddons.video = function videoAddon() {
|
|||||||
if (stage.requestFullscreen) stage.requestFullscreen();
|
if (stage.requestFullscreen) stage.requestFullscreen();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const active = BackOneWebRtcSession.getActiveCall();
|
||||||
|
inCall = !!active && active.callType === 'video';
|
||||||
showPlaceholder(true);
|
showPlaceholder(true);
|
||||||
|
if (inCall) bindStreams();
|
||||||
updateCallButtons();
|
updateCallButtons();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "voice",
|
"id": "voice",
|
||||||
"name": "Voice Call P2P",
|
"name": "Voice Call P2P",
|
||||||
"version": "1.17.1",
|
"version": "1.17.1",
|
||||||
"requires": ["comms-core"],
|
"requires": ["comms-core", "webhub"],
|
||||||
"backoneCore": {
|
"backoneCore": {
|
||||||
"apis": [
|
"apis": [
|
||||||
"/app/api/peers",
|
"/app/api/peers",
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
window.BackOneAddons = window.BackOneAddons || {};
|
window.BackOneAddons = window.BackOneAddons || {};
|
||||||
|
|
||||||
window.BackOneAddons.voice = function voiceAddon() {
|
window.BackOneAddons.voice = function voiceAddon() {
|
||||||
let pc = null;
|
|
||||||
let picker = null;
|
let picker = null;
|
||||||
let selectedPeer = null;
|
let selectedPeer = null;
|
||||||
let inCall = false;
|
let inCall = false;
|
||||||
let localStream = null;
|
|
||||||
let micEnabled = true;
|
let micEnabled = true;
|
||||||
let root = null;
|
let root = null;
|
||||||
|
let remoteAudio = null;
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
function updateConnectionBar() {
|
function updateConnectionBar() {
|
||||||
const bar = root?.querySelector('#voice-conn-bar');
|
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() {
|
function updateCallButtons() {
|
||||||
const callBtn = root?.querySelector('#voice-call');
|
const callBtn = root?.querySelector('#voice-call');
|
||||||
if (callBtn) callBtn.disabled = !selectedPeer || !BackOneNetworkSession.getNetworkId() || inCall;
|
if (callBtn) callBtn.disabled = !selectedPeer || !BackOneNetworkSession.getNetworkId() || inCall;
|
||||||
@@ -45,16 +58,26 @@ window.BackOneAddons.voice = function voiceAddon() {
|
|||||||
}
|
}
|
||||||
updateHeadStatus();
|
updateHeadStatus();
|
||||||
updateConnectionBar();
|
updateConnectionBar();
|
||||||
|
updateActiveBanner();
|
||||||
}
|
}
|
||||||
|
|
||||||
function hangUp() {
|
function syncInCallState() {
|
||||||
if (pc) { pc.close(); pc = null; }
|
const active = BackOneWebRtcSession.getActiveCall();
|
||||||
if (localStream) {
|
inCall = !!active && (active.callType === 'voice' || !active.callType);
|
||||||
localStream.getTracks().forEach(t => t.stop());
|
if (inCall && active.peer) {
|
||||||
localStream = null;
|
selectedPeer = active.peer.peerId ? active.peer : selectedPeer;
|
||||||
|
root?.querySelector('#voice-breadcrumb-state')?.textContent = 'Connected to';
|
||||||
}
|
}
|
||||||
|
updateCallButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hangUp() {
|
||||||
|
await BackOneWebRtcSession.hangUp();
|
||||||
inCall = false;
|
inCall = false;
|
||||||
micEnabled = true;
|
micEnabled = true;
|
||||||
|
if (remoteAudio) remoteAudio.srcObject = null;
|
||||||
|
const breadcrumbState = root?.querySelector('#voice-breadcrumb-state');
|
||||||
|
if (breadcrumbState) breadcrumbState.textContent = 'Select peer';
|
||||||
updateCallButtons();
|
updateCallButtons();
|
||||||
window.BackOneRingingPopup?.dismiss();
|
window.BackOneRingingPopup?.dismiss();
|
||||||
}
|
}
|
||||||
@@ -63,11 +86,11 @@ window.BackOneAddons.voice = function voiceAddon() {
|
|||||||
mount(container) {
|
mount(container) {
|
||||||
root = container;
|
root = container;
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="comm-layout">
|
<div class="comm-layout comm-split-layout">
|
||||||
<aside class="comm-sidebar">
|
<aside class="comm-sidebar">
|
||||||
<div class="comm-sidebar-head">
|
<div class="comm-sidebar-head">
|
||||||
<h2>Voice Call P2P</h2>
|
<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>
|
||||||
<div class="comm-sidebar-body">
|
<div class="comm-sidebar-body">
|
||||||
<div id="voice-picker"></div>
|
<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 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 id="voice-head-status" class="comm-head-status"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="comm-stage">
|
<div class="voice-main-panel">
|
||||||
<div class="voice-stage-inner">
|
<div id="voice-active-banner" class="voice-active-banner" style="display:none">
|
||||||
<div class="avatar">🎙</div>
|
<div>
|
||||||
<div class="peer-id" id="voice-ph-peer">—</div>
|
<span class="voice-active-dot"></span>
|
||||||
<div id="voice-stage-label">Select a peer to call</div>
|
<span id="voice-active-label">In call</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="comm-call-controls">
|
<div class="voice-active-controls">
|
||||||
<button type="button" id="voice-mic">Mic</button>
|
<button type="button" id="voice-mic">Mic</button>
|
||||||
<button type="button" id="voice-hangup-stage" class="danger">Hang Up</button>
|
<button type="button" id="voice-hangup-stage" class="danger">Hang Up</button>
|
||||||
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
<div id="voice-conn-bar" class="comm-connection-bar"></div>`;
|
<div id="voice-conn-bar" class="comm-connection-bar"></div>`;
|
||||||
|
|
||||||
|
remoteAudio = container.querySelector('#voice-remote-audio');
|
||||||
const callBtn = container.querySelector('#voice-call');
|
const callBtn = container.querySelector('#voice-call');
|
||||||
const stageLabel = container.querySelector('#voice-stage-label');
|
|
||||||
|
|
||||||
picker = BackOnePeerPicker(container.querySelector('#voice-picker'), {
|
picker = BackOnePeerPicker(container.querySelector('#voice-picker'), {
|
||||||
layout: 'sidebar',
|
layout: 'sidebar',
|
||||||
showActionButton: false,
|
showActionButton: false,
|
||||||
onSelect(peer) {
|
onSelect(peer) {
|
||||||
selectedPeer = peer;
|
selectedPeer = peer;
|
||||||
root.querySelector('#voice-ph-peer').textContent = peer.peerId;
|
|
||||||
if (!inCall) stageLabel.textContent = `Ready to call ${peer.displayName || peer.peerId}`;
|
|
||||||
updateCallButtons();
|
updateCallButtons();
|
||||||
},
|
},
|
||||||
onNetworkChange() {
|
onNetworkChange() {
|
||||||
@@ -117,47 +141,40 @@ window.BackOneAddons.voice = function voiceAddon() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
BackOneNetworkSession.subscribe(() => updateCallButtons());
|
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 () => {
|
callBtn.onclick = async () => {
|
||||||
const peer = selectedPeer || picker.getSelectedPeer();
|
const peer = selectedPeer || picker.getSelectedPeer();
|
||||||
const networkId = BackOneNetworkSession.getNetworkId();
|
if (!peer || !BackOneNetworkSession.getNetworkId()) return;
|
||||||
if (!peer || !networkId) return;
|
|
||||||
try {
|
try {
|
||||||
pc = new RTCPeerConnection();
|
await BackOneWebRtcSession.startOutgoingCall(peer, 'voice');
|
||||||
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
|
|
||||||
})
|
|
||||||
});
|
|
||||||
inCall = true;
|
inCall = true;
|
||||||
root.querySelector('#voice-breadcrumb-state').textContent = 'Connected to';
|
root.querySelector('#voice-breadcrumb-state').textContent = 'Connecting to';
|
||||||
stageLabel.textContent = `In call with ${peer.displayName || peer.peerId}`;
|
|
||||||
updateCallButtons();
|
updateCallButtons();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
hangUp();
|
await hangUp();
|
||||||
alert(err.message || 'Call failed');
|
alert(err.message || 'Call failed');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
container.querySelector('#voice-hangup').onclick = hangUp;
|
container.querySelector('#voice-hangup').onclick = () => hangUp();
|
||||||
container.querySelector('#voice-hangup-stage').onclick = hangUp;
|
container.querySelector('#voice-hangup-stage').onclick = () => hangUp();
|
||||||
|
|
||||||
container.querySelector('#voice-mic').onclick = () => {
|
container.querySelector('#voice-mic').onclick = () => {
|
||||||
|
const localStream = BackOneWebRtcSession.getLocalStream();
|
||||||
if (!localStream) return;
|
if (!localStream) return;
|
||||||
micEnabled = !micEnabled;
|
micEnabled = !micEnabled;
|
||||||
localStream.getAudioTracks().forEach(t => { t.enabled = micEnabled; });
|
localStream.getAudioTracks().forEach(t => { t.enabled = micEnabled; });
|
||||||
container.querySelector('#voice-mic').textContent = micEnabled ? 'Mic' : 'Mic off';
|
container.querySelector('#voice-mic').textContent = micEnabled ? 'Mic' : 'Mic off';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
syncInCallState();
|
||||||
updateCallButtons();
|
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
|
||||||
Vendored
+2
@@ -36,6 +36,8 @@
|
|||||||
<script src="/app/contacts-store.js"></script>
|
<script src="/app/contacts-store.js"></script>
|
||||||
<script src="/app/peer-directory.js"></script>
|
<script src="/app/peer-directory.js"></script>
|
||||||
<script src="/app/network-session.js"></script>
|
<script src="/app/network-session.js"></script>
|
||||||
|
<script src="/app/webhub-client.js"></script>
|
||||||
|
<script src="/app/webrtc-session.js"></script>
|
||||||
<script src="/app/peer-picker.js"></script>
|
<script src="/app/peer-picker.js"></script>
|
||||||
<script src="/app/addon-loader.js"></script>
|
<script src="/app/addon-loader.js"></script>
|
||||||
<script src="/app/main.js"></script>
|
<script src="/app/main.js"></script>
|
||||||
|
|||||||
Vendored
+30
-2
@@ -175,19 +175,47 @@ async function initNetworkBar() {
|
|||||||
}, 45000);
|
}, 45000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function initCommsStack() {
|
||||||
|
await BackOneWebHubClient.connect();
|
||||||
|
await BackOneWebRtcSession.init();
|
||||||
|
|
||||||
|
BackOneWebHubClient.subscribe('incoming_call', (msg) => {
|
||||||
|
if (BackOneWebRtcSession.isInCall()) return;
|
||||||
|
BackOneRingingPopup.showIncomingCall({
|
||||||
|
callerName: msg.callerName || msg.callerId,
|
||||||
|
callType: msg.callType || 'voice',
|
||||||
|
callId: msg.callId,
|
||||||
|
onAccept: async () => {
|
||||||
|
const type = msg.callType || 'voice';
|
||||||
|
const addonId = type === 'video' ? 'video' : 'voice';
|
||||||
|
if (addonButtons[addonId]) activateAddon(addonId);
|
||||||
|
try {
|
||||||
|
await BackOneWebRtcSession.acceptIncomingCall(msg);
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Could not accept call');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onReject: (callId) => {
|
||||||
|
BackOneWebRtcSession.rejectIncomingCall(callId || msg.callId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function initAppShell() {
|
async function initAppShell() {
|
||||||
await BackOneAuth.init();
|
await BackOneAuth.init();
|
||||||
await initNetworkBar();
|
await initNetworkBar();
|
||||||
|
await initCommsStack();
|
||||||
|
|
||||||
const nav = document.getElementById('addon-nav');
|
const nav = document.getElementById('addon-nav');
|
||||||
const panel = document.getElementById('addon-panel');
|
const panel = document.getElementById('addon-panel');
|
||||||
const addons = await BackOneAddonLoader.loadAddonManifest();
|
const addons = await BackOneAddonLoader.loadAddonManifest();
|
||||||
if (!addons.length) {
|
if (!addons.length) {
|
||||||
panel.innerHTML = '<p>No UI addons found. Install backone-addon-voice, backone-addon-video, or backone-addon-chat, then run: <code>sudo backone-updater rebuild-manifest</code> and restart backone.</p>';
|
panel.innerHTML = '<p>No UI addons found. Install backone-addon-secure-chat, backone-addon-voice, backone-addon-video, or backone-addon-chat, then run: <code>sudo backone-updater rebuild-manifest</code> and restart backone.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const navOrder = ['chat', 'video', 'voice'];
|
const navOrder = ['secure-chat', 'chat', 'video', 'voice'];
|
||||||
const sorted = [...addons].sort((a, b) => {
|
const sorted = [...addons].sort((a, b) => {
|
||||||
const ia = navOrder.indexOf(a.id);
|
const ia = navOrder.indexOf(a.id);
|
||||||
const ib = navOrder.indexOf(b.id);
|
const ib = navOrder.indexOf(b.id);
|
||||||
|
|||||||
Vendored
+316
-15
@@ -175,6 +175,38 @@ body {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Chat & voice: 25% contact list / 75% main panel */
|
||||||
|
.comm-layout.comm-split-layout .comm-sidebar {
|
||||||
|
width: 25%;
|
||||||
|
max-width: 25%;
|
||||||
|
flex: 0 0 25%;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comm-layout.comm-split-layout .comm-main {
|
||||||
|
flex: 1 1 75%;
|
||||||
|
width: 75%;
|
||||||
|
max-width: 75%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comm-layout.comm-split-layout .comm-sidebar-head {
|
||||||
|
padding: 0.75rem 0.75rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comm-layout.comm-split-layout .comm-sidebar-head h2 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comm-layout.comm-split-layout .comm-sidebar-body {
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comm-layout.comm-split-layout .comm-sidebar-actions {
|
||||||
|
padding: 0.5rem 0.65rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.comm-sidebar-head {
|
.comm-sidebar-head {
|
||||||
padding: 1rem 1rem 0.5rem;
|
padding: 1rem 1rem 0.5rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
@@ -529,30 +561,91 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 1rem;
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comm-split-layout .chat-thread-panel {
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-thread-header {
|
.chat-thread-header {
|
||||||
padding-bottom: 0.5rem;
|
flex-shrink: 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
margin-bottom: 0.5rem;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
|
||||||
|
|
||||||
#chat-thread {
|
|
||||||
flex: 1;
|
|
||||||
overflow: auto;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-thread-header small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: normal;
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages,
|
||||||
|
#chat-thread {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #0a0e14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
margin: 2rem 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
max-width: 85%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-sender {
|
||||||
|
color: #e8a54b;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-time {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-body {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-file {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.chat-compose {
|
.chat-compose {
|
||||||
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-compose textarea {
|
.chat-compose textarea {
|
||||||
@@ -571,8 +664,146 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* —— voice stage —— */
|
/* —— voice —— */
|
||||||
|
|
||||||
|
.voice-main-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0a0e14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-active-banner {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
background: rgba(45, 90, 138, 0.35);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-active-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ok);
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-active-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-active-controls button {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-active-controls button.danger {
|
||||||
|
background: var(--danger);
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-session-hint {
|
||||||
|
margin: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-remote-audio {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-history-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-history-title {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-call-history {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-call-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-call-item:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-call-icon {
|
||||||
|
width: 1.75rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-call-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-call-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-call-sub {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-call-when {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-call-empty {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* legacy voice stage (unused) */
|
||||||
.voice-stage-inner {
|
.voice-stage-inner {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
@@ -625,19 +856,89 @@ body {
|
|||||||
.ringing-accept { background: var(--ok); color: #000; }
|
.ringing-accept { background: var(--ok); color: #000; }
|
||||||
.ringing-reject { background: var(--danger); color: #fff; }
|
.ringing-reject { background: var(--danger); color: #fff; }
|
||||||
|
|
||||||
|
/* —— video call: remote fills main stage, local is PiP —— */
|
||||||
|
|
||||||
|
.comm-layout.video-in-call .comm-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comm-layout.video-in-call .comm-main-head {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 4;
|
||||||
|
background: linear-gradient(to bottom, rgba(10, 14, 20, 0.85), transparent);
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-stage .video-remote-main {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-stage .video-remote-stream {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-stage .video-remote-stream.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-stage.has-remote .comm-remote-placeholder {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-stage .comm-remote-tag {
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
.video-local-pip {
|
.video-local-pip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 4.5rem;
|
bottom: 4.5rem;
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
width: 160px;
|
width: 180px;
|
||||||
|
z-index: 3;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 2px solid var(--border);
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
background: #000;
|
background: #000;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-pip-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 8px;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-local-pip video {
|
.video-local-pip video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
display: block;
|
display: block;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-stage .comm-call-controls {
|
||||||
|
z-index: 4;
|
||||||
}
|
}
|
||||||
Vendored
+170
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket client for backone-webhub (127.0.0.1, authtoken auth).
|
||||||
|
*/
|
||||||
|
window.BackOneWebHubClient = (function () {
|
||||||
|
let ws = null;
|
||||||
|
let connected = false;
|
||||||
|
let reconnectDelay = 1000;
|
||||||
|
let reconnectTimer = null;
|
||||||
|
let pollTimer = null;
|
||||||
|
let port = 9994;
|
||||||
|
let selfPeerId = '';
|
||||||
|
const seenIncoming = new Set();
|
||||||
|
const handlers = new Map();
|
||||||
|
|
||||||
|
function emit(type, msg) {
|
||||||
|
const set = handlers.get(type);
|
||||||
|
if (!set) return;
|
||||||
|
set.forEach(fn => {
|
||||||
|
try { fn(msg); } catch (_) { /* ignore */ }
|
||||||
|
});
|
||||||
|
const all = handlers.get('*');
|
||||||
|
if (all) {
|
||||||
|
all.forEach(fn => {
|
||||||
|
try { fn(type, msg); } catch (_) { /* ignore */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(type, fn) {
|
||||||
|
if (!handlers.has(type)) handlers.set(type, new Set());
|
||||||
|
handlers.get(type).add(fn);
|
||||||
|
return () => handlers.get(type)?.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(msg) {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(msg));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerPeer() {
|
||||||
|
const core = BackOneNetworkSession.getCoreStatus();
|
||||||
|
selfPeerId = ((core && core.address) || '').toLowerCase();
|
||||||
|
const networkId = BackOneNetworkSession.getNetworkId();
|
||||||
|
if (!selfPeerId) return;
|
||||||
|
send({ type: 'register', peerId: selfPeerId, networkId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopHttpPoll() {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHttpPoll() {
|
||||||
|
stopHttpPoll();
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
if (connected) return;
|
||||||
|
const peerId = selfPeerId || ((BackOneNetworkSession.getCoreStatus().address || '').toLowerCase());
|
||||||
|
if (!peerId) return;
|
||||||
|
try {
|
||||||
|
const res = await BackOneAuth.fetchWithAuth(
|
||||||
|
`/controller/comms/call/incoming?peerId=${encodeURIComponent(peerId)}`
|
||||||
|
);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
const calls = Array.isArray(data.calls) ? data.calls : [];
|
||||||
|
for (const call of calls) {
|
||||||
|
if (seenIncoming.has(call.callId)) continue;
|
||||||
|
seenIncoming.add(call.callId);
|
||||||
|
emit('incoming_call', {
|
||||||
|
type: 'incoming_call',
|
||||||
|
callId: call.callId,
|
||||||
|
callType: call.callType || 'voice',
|
||||||
|
callerId: call.callerId,
|
||||||
|
callerName: call.callerName || call.callerId,
|
||||||
|
networkId: call.networkId,
|
||||||
|
sdp: call.sdp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (reconnectTimer) return;
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectTimer = null;
|
||||||
|
connect();
|
||||||
|
}, reconnectDelay);
|
||||||
|
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPort() {
|
||||||
|
try {
|
||||||
|
const res = await BackOneAuth.fetchWithAuth('/app/api/webrtc-config');
|
||||||
|
if (res.ok) {
|
||||||
|
const cfg = await res.json();
|
||||||
|
if (cfg.webhubPort) port = cfg.webhubPort;
|
||||||
|
}
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
await loadPort();
|
||||||
|
const token = await BackOneAuth.getToken();
|
||||||
|
if (!token) return false;
|
||||||
|
|
||||||
|
if (ws) {
|
||||||
|
try { ws.close(); } catch (_) { /* ignore */ }
|
||||||
|
ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const url = `ws://127.0.0.1:${port}/?auth=${encodeURIComponent(token)}`;
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
} catch (_) {
|
||||||
|
connected = false;
|
||||||
|
startHttpPoll();
|
||||||
|
scheduleReconnect();
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
connected = true;
|
||||||
|
reconnectDelay = 1000;
|
||||||
|
stopHttpPoll();
|
||||||
|
send({ type: 'auth', token });
|
||||||
|
registerPeer();
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(ev.data);
|
||||||
|
if (msg && msg.type) emit(msg.type, msg);
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
connected = false;
|
||||||
|
ws = null;
|
||||||
|
startHttpPoll();
|
||||||
|
scheduleReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
connected = false;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
BackOneNetworkSession.subscribe(() => {
|
||||||
|
if (connected) registerPeer();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
connect,
|
||||||
|
send,
|
||||||
|
subscribe,
|
||||||
|
isConnected: () => connected,
|
||||||
|
getSelfPeerId: () => selfPeerId,
|
||||||
|
registerPeer
|
||||||
|
};
|
||||||
|
})();
|
||||||
Vendored
+352
@@ -0,0 +1,352 @@
|
|||||||
|
/**
|
||||||
|
* Shared WebRTC session — signaling via WebHub WS with HTTP fallback.
|
||||||
|
*/
|
||||||
|
window.BackOneWebRtcSession = (function () {
|
||||||
|
let config = {
|
||||||
|
icePreferOverlay: true,
|
||||||
|
iceFallbackTimeoutMs: 8000,
|
||||||
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||||
|
};
|
||||||
|
let pc = null;
|
||||||
|
let localStream = null;
|
||||||
|
let remoteStream = null;
|
||||||
|
let activeCall = null;
|
||||||
|
let iceFallbackTimer = null;
|
||||||
|
let httpIcePollTimer = null;
|
||||||
|
let httpAnswerPollTimer = null;
|
||||||
|
const listeners = new Set();
|
||||||
|
|
||||||
|
function emit(event, detail) {
|
||||||
|
listeners.forEach(fn => {
|
||||||
|
try { fn(event, detail); } catch (_) { /* ignore */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(fn) {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTimers() {
|
||||||
|
if (iceFallbackTimer) { clearTimeout(iceFallbackTimer); iceFallbackTimer = null; }
|
||||||
|
if (httpIcePollTimer) { clearInterval(httpIcePollTimer); httpIcePollTimer = null; }
|
||||||
|
if (httpAnswerPollTimer) { clearInterval(httpAnswerPollTimer); httpAnswerPollTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlayIceServers() {
|
||||||
|
return config.icePreferOverlay ? [] : (config.iceServers || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullIceServers() {
|
||||||
|
return config.iceServers || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPeerConnection(peer) {
|
||||||
|
clearTimers();
|
||||||
|
if (pc) {
|
||||||
|
try { pc.close(); } catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
pc = new RTCPeerConnection({ iceServers: overlayIceServers() });
|
||||||
|
remoteStream = new MediaStream();
|
||||||
|
pc.ontrack = (ev) => {
|
||||||
|
ev.streams[0].getTracks().forEach(t => remoteStream.addTrack(t));
|
||||||
|
emit('remote-track', { stream: remoteStream });
|
||||||
|
};
|
||||||
|
pc.onicecandidate = (ev) => {
|
||||||
|
if (!ev.candidate || !activeCall) return;
|
||||||
|
relayIce(activeCall.callId, {
|
||||||
|
candidate: ev.candidate.candidate,
|
||||||
|
sdpMid: ev.candidate.sdpMid,
|
||||||
|
sdpMLineIndex: ev.candidate.sdpMLineIndex
|
||||||
|
});
|
||||||
|
};
|
||||||
|
pc.onconnectionstatechange = () => {
|
||||||
|
emit('connection-state', { state: pc.connectionState });
|
||||||
|
if (pc.connectionState === 'connected') clearTimers();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.icePreferOverlay) {
|
||||||
|
iceFallbackTimer = setTimeout(() => {
|
||||||
|
if (!pc || pc.connectionState === 'connected') return;
|
||||||
|
try {
|
||||||
|
pc.setConfiguration({ iceServers: fullIceServers() });
|
||||||
|
pc.restartIce();
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
}, config.iceFallbackTimeoutMs || 8000);
|
||||||
|
}
|
||||||
|
return pc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function relayIce(callId, candidate) {
|
||||||
|
if (BackOneWebHubClient.isConnected()) {
|
||||||
|
BackOneWebHubClient.send({
|
||||||
|
type: 'ice_candidate',
|
||||||
|
callId,
|
||||||
|
...candidate
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const peerId = BackOneWebHubClient.getSelfPeerId()
|
||||||
|
|| ((BackOneNetworkSession.getCoreStatus().address || '').toLowerCase());
|
||||||
|
await BackOneAuth.fetchWithAuth(`/controller/comms/call/${encodeURIComponent(callId)}/ice`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...candidate, fromPeerId: peerId })
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHttpIcePoll(callId) {
|
||||||
|
let since = 0;
|
||||||
|
httpIcePollTimer = setInterval(async () => {
|
||||||
|
if (!pc || !activeCall || activeCall.callId !== callId) return;
|
||||||
|
const peerId = BackOneWebHubClient.getSelfPeerId()
|
||||||
|
|| ((BackOneNetworkSession.getCoreStatus().address || '').toLowerCase());
|
||||||
|
try {
|
||||||
|
const res = await BackOneAuth.fetchWithAuth(
|
||||||
|
`/controller/comms/call/${encodeURIComponent(callId)}/ice?since=${since}&peerId=${encodeURIComponent(peerId)}`
|
||||||
|
);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
const list = Array.isArray(data.candidates) ? data.candidates : [];
|
||||||
|
for (const c of list) {
|
||||||
|
try {
|
||||||
|
await pc.addIceCandidate({
|
||||||
|
candidate: c.candidate,
|
||||||
|
sdpMid: c.sdpMid,
|
||||||
|
sdpMLineIndex: c.sdpMLineIndex
|
||||||
|
});
|
||||||
|
since = Math.max(since, c.at || 0);
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchHubCallState() {
|
||||||
|
return BackOneWebHubClient.subscribe('call_state', async (msg) => {
|
||||||
|
if (!activeCall || msg.callId !== activeCall.callId) return;
|
||||||
|
if (msg.state === 'accepted' && msg.sdp && activeCall.role === 'caller') {
|
||||||
|
try {
|
||||||
|
await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp });
|
||||||
|
emit('call-accepted', activeCall);
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
if (msg.state === 'ended' || msg.state === 'rejected') {
|
||||||
|
hangUp(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let unsubHub = [];
|
||||||
|
|
||||||
|
async function waitForOutgoingCallId() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const unsub = BackOneWebHubClient.subscribe('call_state', (msg) => {
|
||||||
|
if (msg.callId && msg.state === 'ringing') {
|
||||||
|
unsub();
|
||||||
|
resolve(msg.callId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setTimeout(() => { unsub(); resolve(null); }, 5000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const res = await BackOneAuth.fetchWithAuth('/app/api/webrtc-config');
|
||||||
|
if (res.ok) {
|
||||||
|
const cfg = await res.json();
|
||||||
|
config = { ...config, ...cfg };
|
||||||
|
}
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
|
||||||
|
unsubHub.forEach(fn => fn && fn());
|
||||||
|
unsubHub = [
|
||||||
|
watchHubCallState(),
|
||||||
|
BackOneWebHubClient.subscribe('ice_candidate', async (msg) => {
|
||||||
|
if (!pc || !activeCall || msg.callId !== activeCall.callId) return;
|
||||||
|
try {
|
||||||
|
await pc.addIceCandidate({
|
||||||
|
candidate: msg.candidate,
|
||||||
|
sdpMid: msg.sdpMid,
|
||||||
|
sdpMLineIndex: msg.sdpMLineIndex
|
||||||
|
});
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMedia(callType) {
|
||||||
|
const audio = true;
|
||||||
|
const video = callType === 'video';
|
||||||
|
localStream = await navigator.mediaDevices.getUserMedia({ audio, video });
|
||||||
|
return localStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachLocalTracks() {
|
||||||
|
if (!pc || !localStream) return;
|
||||||
|
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startOutgoingCall(peer, callType) {
|
||||||
|
if (!peer || activeCall) throw new Error('Already in a call');
|
||||||
|
const networkId = BackOneNetworkSession.getNetworkId();
|
||||||
|
const selfId = ((BackOneNetworkSession.getCoreStatus().address || '').toLowerCase());
|
||||||
|
createPeerConnection(peer);
|
||||||
|
localStream = await getMedia(callType);
|
||||||
|
attachLocalTracks();
|
||||||
|
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
type: 'call_offer',
|
||||||
|
callType,
|
||||||
|
networkId,
|
||||||
|
targetId: peer.peerId,
|
||||||
|
callerName: peer.displayName || peer.peerId,
|
||||||
|
sdp: offer.sdp,
|
||||||
|
overlayIps: peer.ip ? [peer.ip] : []
|
||||||
|
};
|
||||||
|
|
||||||
|
let callId = null;
|
||||||
|
if (BackOneWebHubClient.isConnected()) {
|
||||||
|
BackOneWebHubClient.send(payload);
|
||||||
|
callId = await waitForOutgoingCallId();
|
||||||
|
if (!callId) throw new Error('Call signaling timeout');
|
||||||
|
} else {
|
||||||
|
const res = await BackOneAuth.fetchWithAuth('/controller/comms/call', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
callType,
|
||||||
|
type: callType,
|
||||||
|
networkId,
|
||||||
|
targetId: peer.peerId,
|
||||||
|
callerId: selfId,
|
||||||
|
callerName: peer.displayName || peer.peerId,
|
||||||
|
sdp: offer.sdp
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Call signaling failed');
|
||||||
|
const data = await res.json();
|
||||||
|
callId = data.callId;
|
||||||
|
startHttpAnswerPoll(callId);
|
||||||
|
}
|
||||||
|
|
||||||
|
activeCall = {
|
||||||
|
callId,
|
||||||
|
role: 'caller',
|
||||||
|
callType,
|
||||||
|
peer,
|
||||||
|
networkId
|
||||||
|
};
|
||||||
|
if (!BackOneWebHubClient.isConnected()) startHttpIcePoll(activeCall.callId);
|
||||||
|
emit('call-started', activeCall);
|
||||||
|
return activeCall;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHttpAnswerPoll(callId) {
|
||||||
|
httpAnswerPollTimer = setInterval(async () => {
|
||||||
|
if (!pc || !activeCall || activeCall.callId !== callId) return;
|
||||||
|
try {
|
||||||
|
const res = await BackOneAuth.fetchWithAuth(`/controller/comms/call/${encodeURIComponent(callId)}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.state === 'accepted' && data.answerSdp) {
|
||||||
|
await pc.setRemoteDescription({ type: 'answer', sdp: data.answerSdp });
|
||||||
|
clearInterval(httpAnswerPollTimer);
|
||||||
|
httpAnswerPollTimer = null;
|
||||||
|
emit('call-accepted', activeCall);
|
||||||
|
}
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acceptIncomingCall(incoming) {
|
||||||
|
if (!incoming || activeCall) throw new Error('Already in a call');
|
||||||
|
const callType = incoming.callType || 'voice';
|
||||||
|
const callId = incoming.callId;
|
||||||
|
createPeerConnection({ peerId: incoming.callerId });
|
||||||
|
localStream = await getMedia(callType);
|
||||||
|
attachLocalTracks();
|
||||||
|
|
||||||
|
await pc.setRemoteDescription({ type: 'offer', sdp: incoming.sdp });
|
||||||
|
const answer = await pc.createAnswer();
|
||||||
|
await pc.setLocalDescription(answer);
|
||||||
|
|
||||||
|
if (BackOneWebHubClient.isConnected()) {
|
||||||
|
BackOneWebHubClient.send({
|
||||||
|
type: 'call_answer',
|
||||||
|
callId,
|
||||||
|
sdp: answer.sdp
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await BackOneAuth.fetchWithAuth(`/controller/comms/call/${encodeURIComponent(callId)}/answer`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sdp: answer.sdp })
|
||||||
|
});
|
||||||
|
startHttpIcePoll(callId);
|
||||||
|
}
|
||||||
|
|
||||||
|
activeCall = {
|
||||||
|
callId,
|
||||||
|
role: 'callee',
|
||||||
|
callType,
|
||||||
|
peer: { peerId: incoming.callerId, displayName: incoming.callerName },
|
||||||
|
networkId: incoming.networkId
|
||||||
|
};
|
||||||
|
emit('call-started', activeCall);
|
||||||
|
return activeCall;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejectIncomingCall(callId) {
|
||||||
|
if (BackOneWebHubClient.isConnected()) {
|
||||||
|
BackOneWebHubClient.send({ type: 'call_reject', callId });
|
||||||
|
} else {
|
||||||
|
await BackOneAuth.fetchWithAuth(`/controller/comms/call/${encodeURIComponent(callId)}/reject`, {
|
||||||
|
method: 'POST'
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hangUp(signalRemote = true) {
|
||||||
|
const callId = activeCall?.callId;
|
||||||
|
clearTimers();
|
||||||
|
if (signalRemote && callId) {
|
||||||
|
if (BackOneWebHubClient.isConnected()) {
|
||||||
|
BackOneWebHubClient.send({ type: 'call_end', callId });
|
||||||
|
} else {
|
||||||
|
await BackOneAuth.fetchWithAuth(`/controller/comms/call/${encodeURIComponent(callId)}/end`, {
|
||||||
|
method: 'POST'
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pc) {
|
||||||
|
try { pc.close(); } catch (_) { /* ignore */ }
|
||||||
|
pc = null;
|
||||||
|
}
|
||||||
|
if (localStream) {
|
||||||
|
localStream.getTracks().forEach(t => t.stop());
|
||||||
|
localStream = null;
|
||||||
|
}
|
||||||
|
remoteStream = null;
|
||||||
|
activeCall = null;
|
||||||
|
emit('call-ended', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
subscribe,
|
||||||
|
startOutgoingCall,
|
||||||
|
acceptIncomingCall,
|
||||||
|
rejectIncomingCall,
|
||||||
|
hangUp,
|
||||||
|
getPeerConnection: () => pc,
|
||||||
|
getLocalStream: () => localStream,
|
||||||
|
getRemoteStream: () => remoteStream,
|
||||||
|
getActiveCall: () => activeCall,
|
||||||
|
isInCall: () => !!activeCall
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -25,7 +25,7 @@ echo "$VERSION" > "$ROOT/debian/version"
|
|||||||
"$ROOT/ci/scripts/munge_rpm_spec.sh" "$ROOT/backone.spec" "$VERSION" \
|
"$ROOT/ci/scripts/munge_rpm_spec.sh" "$ROOT/backone.spec" "$VERSION" \
|
||||||
"Dedy Sutanto <dsutanto@backone.cloud>" "$MESSAGE"
|
"Dedy Sutanto <dsutanto@backone.cloud>" "$MESSAGE"
|
||||||
|
|
||||||
for addon in comms-core voice video chat updater app-tray; do
|
for addon in comms-core webhub voice video chat secure-chat updater app-tray; do
|
||||||
if [ -f "$ROOT/addons/$addon/addon.json" ]; then
|
if [ -f "$ROOT/addons/$addon/addon.json" ]; then
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" "$ROOT/addons/$addon/addon.json"
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" "$ROOT/addons/$addon/addon.json"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c)2019 ZeroTier, Inc.
|
||||||
|
*
|
||||||
|
* Use of this software is governed by the Business Source License included
|
||||||
|
* in the LICENSE.TXT file in the project's root directory.
|
||||||
|
*/
|
||||||
|
/****/
|
||||||
|
|
||||||
|
#include "CommsController.hpp"
|
||||||
|
|
||||||
|
#include "../node/Mutex.hpp"
|
||||||
|
#include "../node/Utils.hpp"
|
||||||
|
#include "../osdep/OSUtils.hpp"
|
||||||
|
|
||||||
|
#include <map>
|
||||||
|
#include <set>
|
||||||
|
#include <sstream>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace ZeroTier {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct IceEntry
|
||||||
|
{
|
||||||
|
std::string fromPeerId;
|
||||||
|
std::string candidate;
|
||||||
|
std::string sdpMid;
|
||||||
|
int sdpMLineIndex;
|
||||||
|
int64_t at;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CallState
|
||||||
|
{
|
||||||
|
std::string callId;
|
||||||
|
std::string callType;
|
||||||
|
std::string networkId;
|
||||||
|
std::string callerId;
|
||||||
|
std::string calleeId;
|
||||||
|
std::string callerName;
|
||||||
|
std::string state;
|
||||||
|
std::string offerSdp;
|
||||||
|
std::string answerSdp;
|
||||||
|
int64_t createdAt;
|
||||||
|
};
|
||||||
|
|
||||||
|
Mutex g_commsMutex;
|
||||||
|
std::map<std::string, CallState> g_calls;
|
||||||
|
std::map<std::string, std::vector<IceEntry> > g_iceByCall;
|
||||||
|
std::map<std::string, std::set<std::string> > g_callsByPeer;
|
||||||
|
|
||||||
|
std::string lowerId(const std::string &id)
|
||||||
|
{
|
||||||
|
std::string out = id;
|
||||||
|
for (std::string::iterator i(out.begin()); i != out.end(); ++i) {
|
||||||
|
if (*i >= 'A' && *i <= 'F') {
|
||||||
|
*i = (char)(*i - 'A' + 'a');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string newCallId()
|
||||||
|
{
|
||||||
|
uint8_t randBytes[8];
|
||||||
|
Utils::getSecureRandom(randBytes, sizeof(randBytes));
|
||||||
|
char randHex[17];
|
||||||
|
Utils::hex(randBytes, (unsigned int)sizeof(randBytes), randHex);
|
||||||
|
return std::string("call_") + std::to_string(OSUtils::now()) + "_" + randHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
void trackCallForPeer(const std::string &peerId, const std::string &callId)
|
||||||
|
{
|
||||||
|
if (peerId.empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
g_callsByPeer[peerId].insert(callId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void untrackCall(const CallState &call)
|
||||||
|
{
|
||||||
|
const std::string caller = lowerId(call.callerId);
|
||||||
|
const std::string callee = lowerId(call.calleeId);
|
||||||
|
if (!caller.empty()) {
|
||||||
|
g_callsByPeer[caller].erase(call.callId);
|
||||||
|
if (g_callsByPeer[caller].empty()) {
|
||||||
|
g_callsByPeer.erase(caller);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!callee.empty()) {
|
||||||
|
g_callsByPeer[callee].erase(call.callId);
|
||||||
|
if (g_callsByPeer[callee].empty()) {
|
||||||
|
g_callsByPeer.erase(callee);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void purgeCall(const std::string &callId)
|
||||||
|
{
|
||||||
|
std::map<std::string, CallState>::iterator it = g_calls.find(callId);
|
||||||
|
if (it != g_calls.end()) {
|
||||||
|
untrackCall(it->second);
|
||||||
|
g_calls.erase(it);
|
||||||
|
}
|
||||||
|
g_iceByCall.erase(callId);
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json callToIncomingJson(const CallState &call)
|
||||||
|
{
|
||||||
|
nlohmann::json out = nlohmann::json::object();
|
||||||
|
out["callId"] = call.callId;
|
||||||
|
out["callType"] = call.callType;
|
||||||
|
out["networkId"] = call.networkId;
|
||||||
|
out["callerId"] = call.callerId;
|
||||||
|
out["callerName"] = call.callerName;
|
||||||
|
out["sdp"] = call.offerSdp;
|
||||||
|
out["state"] = call.state;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json webrtcConfigFromLocal(const nlohmann::json &localConfig)
|
||||||
|
{
|
||||||
|
nlohmann::json out = nlohmann::json::object();
|
||||||
|
out["icePreferOverlay"] = true;
|
||||||
|
out["iceFallbackTimeoutMs"] = 8000;
|
||||||
|
out["webhubPort"] = 9994;
|
||||||
|
out["iceServers"] = nlohmann::json::array();
|
||||||
|
out["iceServers"].push_back(nlohmann::json::object({ { "urls", "stun:stun.l.google.com:19302" } }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (localConfig.contains("settings") && localConfig["settings"].is_object()) {
|
||||||
|
const nlohmann::json &settings = localConfig["settings"];
|
||||||
|
if (settings.contains("icePreferOverlay") && settings["icePreferOverlay"].is_boolean()) {
|
||||||
|
out["icePreferOverlay"] = settings["icePreferOverlay"].get<bool>();
|
||||||
|
}
|
||||||
|
if (settings.contains("iceFallbackTimeoutMs") && settings["iceFallbackTimeoutMs"].is_number_integer()) {
|
||||||
|
out["iceFallbackTimeoutMs"] = settings["iceFallbackTimeoutMs"].get<int64_t>();
|
||||||
|
}
|
||||||
|
if (settings.contains("webhubPort") && settings["webhubPort"].is_number_integer()) {
|
||||||
|
out["webhubPort"] = settings["webhubPort"].get<int64_t>();
|
||||||
|
}
|
||||||
|
if (settings.contains("iceServers") && settings["iceServers"].is_array()) {
|
||||||
|
out["iceServers"] = settings["iceServers"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch ( ... ) {
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void CommsController::configureRoutes(
|
||||||
|
httplib::Server &s,
|
||||||
|
httplib::Server &sv6,
|
||||||
|
const std::function<nlohmann::json()> &localConfigGetter,
|
||||||
|
const std::function<void(const httplib::Request&, httplib::Response&, std::string)> &setContent)
|
||||||
|
{
|
||||||
|
auto webrtcConfigGet = [localConfigGetter, setContent](const httplib::Request &req, httplib::Response &res) {
|
||||||
|
nlohmann::json localConfig = nlohmann::json::object();
|
||||||
|
try {
|
||||||
|
localConfig = localConfigGetter();
|
||||||
|
} catch ( ... ) {
|
||||||
|
}
|
||||||
|
setContent(req, res, webrtcConfigFromLocal(localConfig).dump());
|
||||||
|
};
|
||||||
|
s.Get("/app/api/webrtc-config", webrtcConfigGet);
|
||||||
|
sv6.Get("/app/api/webrtc-config", webrtcConfigGet);
|
||||||
|
|
||||||
|
auto callCreate = [setContent](const httplib::Request &req, httplib::Response &res) {
|
||||||
|
try {
|
||||||
|
nlohmann::json body = OSUtils::jsonParse(req.body);
|
||||||
|
if (!body.is_object()) {
|
||||||
|
res.status = 400;
|
||||||
|
setContent(req, res, "{\"error\":\"body must be a JSON object\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const std::string targetId = lowerId(body.contains("targetId") && body["targetId"].is_string()
|
||||||
|
? body["targetId"].get<std::string>() : "");
|
||||||
|
const std::string sdp = body.contains("sdp") && body["sdp"].is_string()
|
||||||
|
? body["sdp"].get<std::string>() : "";
|
||||||
|
if (targetId.empty() || sdp.empty()) {
|
||||||
|
res.status = 400;
|
||||||
|
setContent(req, res, "{\"error\":\"targetId and sdp are required\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::string callType = "voice";
|
||||||
|
if (body.contains("callType") && body["callType"].is_string()) {
|
||||||
|
callType = body["callType"].get<std::string>();
|
||||||
|
} else if (body.contains("type") && body["type"].is_string()) {
|
||||||
|
const std::string t = body["type"].get<std::string>();
|
||||||
|
if (t == "voice" || t == "video") {
|
||||||
|
callType = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const std::string networkId = body.contains("networkId") && body["networkId"].is_string()
|
||||||
|
? lowerId(body["networkId"].get<std::string>()) : "";
|
||||||
|
const std::string callerId = body.contains("callerId") && body["callerId"].is_string()
|
||||||
|
? lowerId(body["callerId"].get<std::string>()) : "local";
|
||||||
|
const std::string callerName = body.contains("callerName") && body["callerName"].is_string()
|
||||||
|
? body["callerName"].get<std::string>() : callerId;
|
||||||
|
|
||||||
|
CallState call;
|
||||||
|
call.callId = newCallId();
|
||||||
|
call.callType = callType;
|
||||||
|
call.networkId = networkId;
|
||||||
|
call.callerId = callerId;
|
||||||
|
call.calleeId = targetId;
|
||||||
|
call.callerName = callerName;
|
||||||
|
call.state = "ringing";
|
||||||
|
call.offerSdp = sdp;
|
||||||
|
call.createdAt = OSUtils::now();
|
||||||
|
|
||||||
|
Mutex::Lock _l(g_commsMutex);
|
||||||
|
g_calls[call.callId] = call;
|
||||||
|
trackCallForPeer(call.callerId, call.callId);
|
||||||
|
trackCallForPeer(call.calleeId, call.callId);
|
||||||
|
|
||||||
|
nlohmann::json out = nlohmann::json::object();
|
||||||
|
out["callId"] = call.callId;
|
||||||
|
out["state"] = call.state;
|
||||||
|
setContent(req, res, out.dump());
|
||||||
|
} catch ( ... ) {
|
||||||
|
res.status = 400;
|
||||||
|
setContent(req, res, "{\"error\":\"invalid JSON\"}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
s.Post("/controller/comms/call", callCreate);
|
||||||
|
sv6.Post("/controller/comms/call", callCreate);
|
||||||
|
|
||||||
|
auto callIncoming = [setContent](const httplib::Request &req, httplib::Response &res) {
|
||||||
|
const std::string peerId = lowerId(req.has_param("peerId") ? req.get_param_value("peerId") : "");
|
||||||
|
if (peerId.empty()) {
|
||||||
|
res.status = 400;
|
||||||
|
setContent(req, res, "{\"error\":\"peerId query parameter is required\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nlohmann::json calls = nlohmann::json::array();
|
||||||
|
Mutex::Lock _l(g_commsMutex);
|
||||||
|
std::map<std::string, std::set<std::string> >::const_iterator it = g_callsByPeer.find(peerId);
|
||||||
|
if (it != g_callsByPeer.end()) {
|
||||||
|
for (std::set<std::string>::const_iterator c = it->second.begin(); c != it->second.end(); ++c) {
|
||||||
|
std::map<std::string, CallState>::const_iterator callIt = g_calls.find(*c);
|
||||||
|
if (callIt == g_calls.end()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lowerId(callIt->second.calleeId) != peerId || callIt->second.state != "ringing") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
calls.push_back(callToIncomingJson(callIt->second));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nlohmann::json out = nlohmann::json::object();
|
||||||
|
out["calls"] = calls;
|
||||||
|
setContent(req, res, out.dump());
|
||||||
|
};
|
||||||
|
s.Get("/controller/comms/call/incoming", callIncoming);
|
||||||
|
sv6.Get("/controller/comms/call/incoming", callIncoming);
|
||||||
|
|
||||||
|
auto callAnswer = [setContent](const httplib::Request &req, httplib::Response &res) {
|
||||||
|
const std::string callId = req.matches[1];
|
||||||
|
try {
|
||||||
|
nlohmann::json body = OSUtils::jsonParse(req.body);
|
||||||
|
const std::string sdp = body.contains("sdp") && body["sdp"].is_string()
|
||||||
|
? body["sdp"].get<std::string>() : "";
|
||||||
|
if (sdp.empty()) {
|
||||||
|
res.status = 400;
|
||||||
|
setContent(req, res, "{\"error\":\"sdp is required\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Mutex::Lock _l(g_commsMutex);
|
||||||
|
std::map<std::string, CallState>::iterator it = g_calls.find(callId);
|
||||||
|
if (it == g_calls.end()) {
|
||||||
|
res.status = 404;
|
||||||
|
setContent(req, res, "{\"error\":\"call not found\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
it->second.answerSdp = sdp;
|
||||||
|
it->second.state = "accepted";
|
||||||
|
nlohmann::json out = nlohmann::json::object();
|
||||||
|
out["callId"] = callId;
|
||||||
|
out["state"] = "accepted";
|
||||||
|
out["sdp"] = sdp;
|
||||||
|
setContent(req, res, out.dump());
|
||||||
|
} catch ( ... ) {
|
||||||
|
res.status = 400;
|
||||||
|
setContent(req, res, "{\"error\":\"invalid JSON\"}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
s.Post("/controller/comms/call/([^/]+)/answer", callAnswer);
|
||||||
|
sv6.Post("/controller/comms/call/([^/]+)/answer", callAnswer);
|
||||||
|
|
||||||
|
auto callReject = [setContent](const httplib::Request &req, httplib::Response &res) {
|
||||||
|
const std::string callId = req.matches[1];
|
||||||
|
Mutex::Lock _l(g_commsMutex);
|
||||||
|
purgeCall(callId);
|
||||||
|
nlohmann::json out = nlohmann::json::object();
|
||||||
|
out["callId"] = callId;
|
||||||
|
out["state"] = "rejected";
|
||||||
|
setContent(req, res, out.dump());
|
||||||
|
};
|
||||||
|
s.Post("/controller/comms/call/([^/]+)/reject", callReject);
|
||||||
|
sv6.Post("/controller/comms/call/([^/]+)/reject", callReject);
|
||||||
|
|
||||||
|
auto callEnd = [setContent](const httplib::Request &req, httplib::Response &res) {
|
||||||
|
const std::string callId = req.matches[1];
|
||||||
|
Mutex::Lock _l(g_commsMutex);
|
||||||
|
purgeCall(callId);
|
||||||
|
nlohmann::json out = nlohmann::json::object();
|
||||||
|
out["callId"] = callId;
|
||||||
|
out["state"] = "ended";
|
||||||
|
setContent(req, res, out.dump());
|
||||||
|
};
|
||||||
|
s.Post("/controller/comms/call/([^/]+)/end", callEnd);
|
||||||
|
sv6.Post("/controller/comms/call/([^/]+)/end", callEnd);
|
||||||
|
|
||||||
|
auto callIcePost = [setContent](const httplib::Request &req, httplib::Response &res) {
|
||||||
|
const std::string callId = req.matches[1];
|
||||||
|
try {
|
||||||
|
nlohmann::json body = OSUtils::jsonParse(req.body);
|
||||||
|
IceEntry entry;
|
||||||
|
entry.fromPeerId = body.contains("fromPeerId") && body["fromPeerId"].is_string()
|
||||||
|
? lowerId(body["fromPeerId"].get<std::string>()) : "";
|
||||||
|
entry.candidate = body.contains("candidate") && body["candidate"].is_string()
|
||||||
|
? body["candidate"].get<std::string>() : "";
|
||||||
|
entry.sdpMid = body.contains("sdpMid") && body["sdpMid"].is_string()
|
||||||
|
? body["sdpMid"].get<std::string>() : "";
|
||||||
|
entry.sdpMLineIndex = body.contains("sdpMLineIndex") && body["sdpMLineIndex"].is_number_integer()
|
||||||
|
? (int)body["sdpMLineIndex"].get<int64_t>() : 0;
|
||||||
|
entry.at = OSUtils::now();
|
||||||
|
if (entry.candidate.empty()) {
|
||||||
|
res.status = 400;
|
||||||
|
setContent(req, res, "{\"error\":\"candidate is required\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Mutex::Lock _l(g_commsMutex);
|
||||||
|
g_iceByCall[callId].push_back(entry);
|
||||||
|
nlohmann::json out = nlohmann::json::object();
|
||||||
|
out["ok"] = true;
|
||||||
|
setContent(req, res, out.dump());
|
||||||
|
} catch ( ... ) {
|
||||||
|
res.status = 400;
|
||||||
|
setContent(req, res, "{\"error\":\"invalid JSON\"}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
s.Post("/controller/comms/call/([^/]+)/ice", callIcePost);
|
||||||
|
sv6.Post("/controller/comms/call/([^/]+)/ice", callIcePost);
|
||||||
|
|
||||||
|
auto callIceGet = [setContent](const httplib::Request &req, httplib::Response &res) {
|
||||||
|
const std::string callId = req.matches[1];
|
||||||
|
int64_t since = 0;
|
||||||
|
if (req.has_param("since")) {
|
||||||
|
try {
|
||||||
|
since = std::stoll(req.get_param_value("since"));
|
||||||
|
} catch ( ... ) {
|
||||||
|
since = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const std::string peerId = lowerId(req.has_param("peerId") ? req.get_param_value("peerId") : "");
|
||||||
|
nlohmann::json candidates = nlohmann::json::array();
|
||||||
|
Mutex::Lock _l(g_commsMutex);
|
||||||
|
std::map<std::string, std::vector<IceEntry> >::const_iterator it = g_iceByCall.find(callId);
|
||||||
|
if (it != g_iceByCall.end()) {
|
||||||
|
for (std::vector<IceEntry>::const_iterator e = it->second.begin(); e != it->second.end(); ++e) {
|
||||||
|
if (e->at <= since) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!peerId.empty() && e->fromPeerId == peerId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nlohmann::json item = nlohmann::json::object();
|
||||||
|
item["candidate"] = e->candidate;
|
||||||
|
item["sdpMid"] = e->sdpMid;
|
||||||
|
item["sdpMLineIndex"] = e->sdpMLineIndex;
|
||||||
|
item["at"] = e->at;
|
||||||
|
candidates.push_back(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nlohmann::json out = nlohmann::json::object();
|
||||||
|
out["candidates"] = candidates;
|
||||||
|
setContent(req, res, out.dump());
|
||||||
|
};
|
||||||
|
s.Get("/controller/comms/call/([^/]+)/ice", callIceGet);
|
||||||
|
sv6.Get("/controller/comms/call/([^/]+)/ice", callIceGet);
|
||||||
|
|
||||||
|
auto callGet = [setContent](const httplib::Request &req, httplib::Response &res) {
|
||||||
|
const std::string callId = req.matches[1];
|
||||||
|
Mutex::Lock _l(g_commsMutex);
|
||||||
|
std::map<std::string, CallState>::const_iterator it = g_calls.find(callId);
|
||||||
|
if (it == g_calls.end()) {
|
||||||
|
res.status = 404;
|
||||||
|
setContent(req, res, "{\"error\":\"call not found\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nlohmann::json out = callToIncomingJson(it->second);
|
||||||
|
if (it->second.state == "accepted" && !it->second.answerSdp.empty()) {
|
||||||
|
out["answerSdp"] = it->second.answerSdp;
|
||||||
|
}
|
||||||
|
setContent(req, res, out.dump());
|
||||||
|
};
|
||||||
|
s.Get("/controller/comms/call/([^/]+)", callGet);
|
||||||
|
sv6.Get("/controller/comms/call/([^/]+)", callGet);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ZeroTier
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c)2019 ZeroTier, Inc.
|
||||||
|
*
|
||||||
|
* Use of this software is governed by the Business Source License included
|
||||||
|
* in the LICENSE.TXT file in the project's root directory.
|
||||||
|
*/
|
||||||
|
/****/
|
||||||
|
|
||||||
|
#ifndef ZT_COMMSCONTROLLER_HPP
|
||||||
|
#define ZT_COMMSCONTROLLER_HPP
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <cpp-httplib/httplib.h>
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
namespace ZeroTier {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ephemeral in-memory WebRTC signaling fallback and webrtc-config API.
|
||||||
|
*/
|
||||||
|
class CommsController
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void configureRoutes(
|
||||||
|
httplib::Server &s,
|
||||||
|
httplib::Server &sv6,
|
||||||
|
const std::function<nlohmann::json()> &localConfigGetter,
|
||||||
|
const std::function<void(const httplib::Request&, httplib::Response&, std::string)> &setContent);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ZeroTier
|
||||||
|
|
||||||
|
#endif
|
||||||
Vendored
+1
-1
@@ -4,7 +4,7 @@ case "$1" in
|
|||||||
if [ -x /usr/lib/backone/addons/updater/backone-updater ]; then
|
if [ -x /usr/lib/backone/addons/updater/backone-updater ]; then
|
||||||
/usr/lib/backone/addons/updater/backone-updater rebuild-manifest || true
|
/usr/lib/backone/addons/updater/backone-updater rebuild-manifest || true
|
||||||
elif [ -x /usr/lib/backone/addons/updater/rebuild-addon-manifest.sh ]; then
|
elif [ -x /usr/lib/backone/addons/updater/rebuild-addon-manifest.sh ]; then
|
||||||
BACKONE_HOME=/var/lib/backone /usr/lib/backone/addons/updater/rebuild-addon-manifest.sh || true
|
/usr/lib/backone/addons/updater/rebuild-addon-manifest.sh || true
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
Package: backone-addon-secure-chat
|
||||||
|
Source: backone
|
||||||
|
Version: 1.17.1
|
||||||
|
Architecture: all
|
||||||
|
Maintainer: Dedy Sutanto <dsutanto@backone.cloud>
|
||||||
|
Installed-Size: 24
|
||||||
|
Depends: backone, backone-addon-comms-core, backone-app-shell
|
||||||
|
Section: net
|
||||||
|
Priority: optional
|
||||||
|
Homepage: https://backone.cloud
|
||||||
|
Description: BackOne secure chat addon
|
||||||
|
Text-only secure chat over BackOne with a dedicated authenticated API.
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh -e
|
||||||
|
case "$1" in
|
||||||
|
configure)
|
||||||
|
if [ -x /usr/lib/backone/addons/updater/backone-updater ]; then
|
||||||
|
/usr/lib/backone/addons/updater/backone-updater rebuild-manifest || true
|
||||||
|
elif [ -x /usr/lib/backone/addons/updater/rebuild-addon-manifest.sh ]; then
|
||||||
|
/usr/lib/backone/addons/updater/rebuild-addon-manifest.sh || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
Vendored
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh -e
|
||||||
|
case "$1" in
|
||||||
|
configure)
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
systemctl enable backone-webhub.service 2>/dev/null || true
|
||||||
|
systemctl restart backone-webhub.service 2>/dev/null || true
|
||||||
|
if [ -x /usr/lib/backone/addons/updater/backone-updater ]; then
|
||||||
|
/usr/lib/backone/addons/updater/backone-updater migrate --addon webhub || true
|
||||||
|
fi
|
||||||
|
if [ -x /usr/lib/backone/addons/updater/rebuild-addon-manifest.sh ]; then
|
||||||
|
BACKONE_HOME=/var/lib/backone /usr/lib/backone/addons/updater/rebuild-addon-manifest.sh || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
#DEBHELPER#
|
||||||
|
exit 0
|
||||||
Vendored
+16
-4
@@ -14,7 +14,7 @@ Depends: ${shlibs:Depends}, ${misc:Depends}, adduser, openssl, systemd
|
|||||||
Replaces: zerotier-one
|
Replaces: zerotier-one
|
||||||
Conflicts: zerotier-one
|
Conflicts: zerotier-one
|
||||||
Provides: zerotier-one
|
Provides: zerotier-one
|
||||||
Suggests: backone-app-shell, backone-app-tray, backone-addon-comms-core, backone-addon-updater
|
Suggests: backone-app-shell, backone-app-tray, backone-addon-comms-core, backone-addon-webhub, backone-addon-updater
|
||||||
Description: BackOne network virtualization service (core)
|
Description: BackOne network virtualization service (core)
|
||||||
BackOne provides software-defined virtual networking. This package
|
BackOne provides software-defined virtual networking. This package
|
||||||
contains the core daemon only. Install addon packages for comms features.
|
contains the core daemon only. Install addon packages for comms features.
|
||||||
@@ -41,24 +41,36 @@ Recommends: postgresql-client, redis-tools
|
|||||||
Description: BackOne comms core addon (signaling backend)
|
Description: BackOne comms core addon (signaling backend)
|
||||||
Shared signaling backend for voice, video, and chat addons.
|
Shared signaling backend for voice, video, and chat addons.
|
||||||
|
|
||||||
|
Package: backone-addon-webhub
|
||||||
|
Architecture: all
|
||||||
|
Depends: ${misc:Depends}, backone, backone-addon-comms-core, python3, python3-websockets
|
||||||
|
Description: BackOne WebSocket signaling hub (webhub)
|
||||||
|
Ephemeral in-memory WebSocket sidecar for real-time call and chat signaling.
|
||||||
|
|
||||||
Package: backone-addon-voice
|
Package: backone-addon-voice
|
||||||
Architecture: all
|
Architecture: all
|
||||||
Depends: ${misc:Depends}, backone, backone-addon-comms-core, backone-app-shell
|
Depends: ${misc:Depends}, backone, backone-addon-comms-core, backone-addon-webhub, backone-app-shell
|
||||||
Description: BackOne voice call P2P addon
|
Description: BackOne voice call P2P addon
|
||||||
P2P voice calling with ringing popup over the BackOne overlay.
|
P2P voice calling with ringing popup over the BackOne overlay.
|
||||||
|
|
||||||
Package: backone-addon-video
|
Package: backone-addon-video
|
||||||
Architecture: all
|
Architecture: all
|
||||||
Depends: ${misc:Depends}, backone, backone-addon-comms-core, backone-addon-voice, backone-app-shell
|
Depends: ${misc:Depends}, backone, backone-addon-comms-core, backone-addon-webhub, backone-addon-voice, backone-app-shell
|
||||||
Description: BackOne video call P2P addon
|
Description: BackOne video call P2P addon
|
||||||
P2P video calling with ringing popup over the BackOne overlay.
|
P2P video calling with ringing popup over the BackOne overlay.
|
||||||
|
|
||||||
Package: backone-addon-chat
|
Package: backone-addon-chat
|
||||||
Architecture: all
|
Architecture: all
|
||||||
Depends: ${misc:Depends}, backone, backone-addon-comms-core, backone-app-shell
|
Depends: ${misc:Depends}, backone, backone-addon-comms-core, backone-addon-webhub, backone-app-shell
|
||||||
Description: BackOne chat P2P addon
|
Description: BackOne chat P2P addon
|
||||||
Text chat with image and document attachments over BackOne.
|
Text chat with image and document attachments over BackOne.
|
||||||
|
|
||||||
|
Package: backone-addon-secure-chat
|
||||||
|
Architecture: all
|
||||||
|
Depends: ${misc:Depends}, backone, backone-addon-comms-core, backone-addon-webhub, backone-app-shell
|
||||||
|
Description: BackOne secure chat addon
|
||||||
|
Text-only secure chat over BackOne with a dedicated authenticated API.
|
||||||
|
|
||||||
Package: backone-addon-updater
|
Package: backone-addon-updater
|
||||||
Architecture: all
|
Architecture: all
|
||||||
Depends: ${misc:Depends}, backone
|
Depends: ${misc:Depends}, backone
|
||||||
|
|||||||
Vendored
+3
@@ -16,9 +16,11 @@ override_dh_auto_install:
|
|||||||
$(MAKE) install-app-shell DESTDIR=$(CURDIR)/debian/backone-app-shell
|
$(MAKE) install-app-shell DESTDIR=$(CURDIR)/debian/backone-app-shell
|
||||||
$(MAKE) install-app-tray DESTDIR=$(CURDIR)/debian/backone-app-tray
|
$(MAKE) install-app-tray DESTDIR=$(CURDIR)/debian/backone-app-tray
|
||||||
$(MAKE) install-addon-comms-core DESTDIR=$(CURDIR)/debian/backone-addon-comms-core
|
$(MAKE) install-addon-comms-core DESTDIR=$(CURDIR)/debian/backone-addon-comms-core
|
||||||
|
$(MAKE) install-addon-webhub DESTDIR=$(CURDIR)/debian/backone-addon-webhub
|
||||||
$(MAKE) install-addon-voice DESTDIR=$(CURDIR)/debian/backone-addon-voice
|
$(MAKE) install-addon-voice DESTDIR=$(CURDIR)/debian/backone-addon-voice
|
||||||
$(MAKE) install-addon-video DESTDIR=$(CURDIR)/debian/backone-addon-video
|
$(MAKE) install-addon-video DESTDIR=$(CURDIR)/debian/backone-addon-video
|
||||||
$(MAKE) install-addon-chat DESTDIR=$(CURDIR)/debian/backone-addon-chat
|
$(MAKE) install-addon-chat DESTDIR=$(CURDIR)/debian/backone-addon-chat
|
||||||
|
$(MAKE) install-addon-secure-chat DESTDIR=$(CURDIR)/debian/backone-addon-secure-chat
|
||||||
$(MAKE) install-addon-updater DESTDIR=$(CURDIR)/debian/backone-addon-updater
|
$(MAKE) install-addon-updater DESTDIR=$(CURDIR)/debian/backone-addon-updater
|
||||||
|
|
||||||
override_dh_install:
|
override_dh_install:
|
||||||
@@ -27,6 +29,7 @@ override_dh_installdocs:
|
|||||||
|
|
||||||
override_dh_systemd_install:
|
override_dh_systemd_install:
|
||||||
dh_systemd_install --name=backone
|
dh_systemd_install --name=backone
|
||||||
|
dh_systemd_install --name=backone-webhub backone-addon-webhub
|
||||||
|
|
||||||
override_dh_systemd_start:
|
override_dh_systemd_start:
|
||||||
dh_systemd_start --name=backone --restart-after-upgrade
|
dh_systemd_start --name=backone --restart-after-upgrade
|
||||||
|
|||||||
+32
-15
@@ -13,19 +13,16 @@ ifndef BACKONE_PLATFORM
|
|||||||
endif
|
endif
|
||||||
endif
|
endif
|
||||||
|
|
||||||
ifeq ($(BACKONE_PLATFORM),linux)
|
RESOLVE_INSTALL_PATH := BACKONE_PLATFORM='$(BACKONE_PLATFORM)' bash pkg/common/resolve-install-path.sh
|
||||||
BACKONE_HOME_PREFIX ?= $(DESTDIR)/var/lib/backone
|
|
||||||
BACKONE_ADDONS_PREFIX ?= $(DESTDIR)/usr/lib/backone/addons
|
BACKONE_HOME_DEFAULT := $(shell $(RESOLVE_INSTALL_PATH) home)
|
||||||
BACKONE_BIN_PREFIX ?= $(DESTDIR)/usr/bin
|
BACKONE_ADDONS_DEFAULT := $(shell $(RESOLVE_INSTALL_PATH) addons)
|
||||||
else ifeq ($(BACKONE_PLATFORM),mac)
|
BACKONE_BIN_DEFAULT := $(shell $(RESOLVE_INSTALL_PATH) bin)
|
||||||
BACKONE_HOME_PREFIX ?= $(DESTDIR)/Library/Application Support/BackOne
|
|
||||||
BACKONE_ADDONS_PREFIX ?= $(DESTDIR)/Library/Application Support/BackOne/addons
|
# DESTDIR prefixes packaging staging; empty DESTDIR installs to live BackOne paths.
|
||||||
BACKONE_BIN_PREFIX ?= $(DESTDIR)/usr/local/bin
|
BACKONE_HOME_PREFIX ?= $(DESTDIR)/$(patsubst /%,%,$(BACKONE_HOME_DEFAULT))
|
||||||
else ifeq ($(BACKONE_PLATFORM),windows)
|
BACKONE_ADDONS_PREFIX ?= $(DESTDIR)/$(patsubst /%,%,$(BACKONE_ADDONS_DEFAULT))
|
||||||
BACKONE_HOME_PREFIX ?= $(DESTDIR)/ProgramData/BackOne
|
BACKONE_BIN_PREFIX ?= $(DESTDIR)/$(patsubst /%,%,$(BACKONE_BIN_DEFAULT))
|
||||||
BACKONE_ADDONS_PREFIX ?= $(DESTDIR)/Program Files/BackOne/addons
|
|
||||||
BACKONE_BIN_PREFIX ?= $(DESTDIR)/Program Files/BackOne/bin
|
|
||||||
endif
|
|
||||||
|
|
||||||
ADDON_VERSION ?= $(shell cat debian/version 2>/dev/null || echo 1.17.1)
|
ADDON_VERSION ?= $(shell cat debian/version 2>/dev/null || echo 1.17.1)
|
||||||
|
|
||||||
@@ -55,6 +52,16 @@ install-addon-comms-core: FORCE
|
|||||||
cp addons/comms-core/addon.json "$(BACKONE_ADDONS_PREFIX)/comms-core/"
|
cp addons/comms-core/addon.json "$(BACKONE_ADDONS_PREFIX)/comms-core/"
|
||||||
cp addons/comms-core/schema.sql "$(BACKONE_ADDONS_PREFIX)/comms-core/"
|
cp addons/comms-core/schema.sql "$(BACKONE_ADDONS_PREFIX)/comms-core/"
|
||||||
|
|
||||||
|
install-addon-webhub: FORCE
|
||||||
|
mkdir -p "$(BACKONE_ADDONS_PREFIX)/webhub"
|
||||||
|
cp addons/webhub/addon.json "$(BACKONE_ADDONS_PREFIX)/webhub/"
|
||||||
|
cp addons/webhub/backone-webhub "$(BACKONE_ADDONS_PREFIX)/webhub/"
|
||||||
|
chmod 755 "$(BACKONE_ADDONS_PREFIX)/webhub/backone-webhub"
|
||||||
|
ifeq ($(BACKONE_PLATFORM),linux)
|
||||||
|
mkdir -p "$(DESTDIR)/lib/systemd/system"
|
||||||
|
cp addons/webhub/backone-webhub.service "$(DESTDIR)/lib/systemd/system/backone-webhub.service"
|
||||||
|
endif
|
||||||
|
|
||||||
install-addon-voice: FORCE
|
install-addon-voice: FORCE
|
||||||
mkdir -p "$(BACKONE_ADDONS_PREFIX)/voice"
|
mkdir -p "$(BACKONE_ADDONS_PREFIX)/voice"
|
||||||
mkdir -p "$(BACKONE_HOME_PREFIX)/app/addons/voice"
|
mkdir -p "$(BACKONE_HOME_PREFIX)/app/addons/voice"
|
||||||
@@ -74,6 +81,12 @@ install-addon-chat: FORCE
|
|||||||
cp addons/chat/addon.json "$(BACKONE_ADDONS_PREFIX)/chat/"
|
cp addons/chat/addon.json "$(BACKONE_ADDONS_PREFIX)/chat/"
|
||||||
cp addons/chat/frontend/index.js "$(BACKONE_HOME_PREFIX)/app/addons/chat/"
|
cp addons/chat/frontend/index.js "$(BACKONE_HOME_PREFIX)/app/addons/chat/"
|
||||||
|
|
||||||
|
install-addon-secure-chat: FORCE
|
||||||
|
mkdir -p "$(BACKONE_ADDONS_PREFIX)/secure-chat"
|
||||||
|
mkdir -p "$(BACKONE_HOME_PREFIX)/app/addons/secure-chat"
|
||||||
|
cp addons/secure-chat/addon.json "$(BACKONE_ADDONS_PREFIX)/secure-chat/"
|
||||||
|
cp addons/secure-chat/frontend/index.js "$(BACKONE_HOME_PREFIX)/app/addons/secure-chat/"
|
||||||
|
|
||||||
install-addon-updater: FORCE
|
install-addon-updater: FORCE
|
||||||
mkdir -p "$(BACKONE_ADDONS_PREFIX)/updater/migrations"
|
mkdir -p "$(BACKONE_ADDONS_PREFIX)/updater/migrations"
|
||||||
cp addons/updater/addon.json "$(BACKONE_ADDONS_PREFIX)/updater/"
|
cp addons/updater/addon.json "$(BACKONE_ADDONS_PREFIX)/updater/"
|
||||||
@@ -95,8 +108,12 @@ else
|
|||||||
chmod 755 "$(BACKONE_BIN_PREFIX)/backone-updater"
|
chmod 755 "$(BACKONE_BIN_PREFIX)/backone-updater"
|
||||||
endif
|
endif
|
||||||
|
|
||||||
install-addon-manifest: install-addon-comms-core install-addon-voice install-addon-video install-addon-chat install-addon-updater FORCE
|
install-addon-manifest: install-addon-comms-core install-addon-webhub install-addon-voice install-addon-video install-addon-chat install-addon-secure-chat install-addon-updater FORCE
|
||||||
|
ifeq ($(DESTDIR),)
|
||||||
|
bash addons/updater/rebuild-addon-manifest.sh || true
|
||||||
|
else
|
||||||
BACKONE_HOME="$(BACKONE_HOME_PREFIX)" BACKONE_ADDONS_DIR="$(BACKONE_ADDONS_PREFIX)" \
|
BACKONE_HOME="$(BACKONE_HOME_PREFIX)" BACKONE_ADDONS_DIR="$(BACKONE_ADDONS_PREFIX)" \
|
||||||
bash addons/updater/rebuild-addon-manifest.sh || true
|
bash addons/updater/rebuild-addon-manifest.sh || true
|
||||||
|
endif
|
||||||
|
|
||||||
install-addons: install-app-shell install-app-tray install-addon-comms-core install-addon-voice install-addon-video install-addon-chat install-addon-updater install-addon-manifest FORCE
|
install-addons: install-app-shell install-app-tray install-addon-comms-core install-addon-webhub install-addon-voice install-addon-video install-addon-chat install-addon-secure-chat install-addon-updater install-addon-manifest FORCE
|
||||||
@@ -34,6 +34,7 @@ CORE_OBJS=\
|
|||||||
|
|
||||||
ONE_OBJS=\
|
ONE_OBJS=\
|
||||||
controller/EmbeddedNetworkController.o \
|
controller/EmbeddedNetworkController.o \
|
||||||
|
controller/CommsController.o \
|
||||||
controller/DBMirrorSet.o \
|
controller/DBMirrorSet.o \
|
||||||
controller/DB.o \
|
controller/DB.o \
|
||||||
controller/FileDB.o \
|
controller/FileDB.o \
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
# Source this file; do not execute directly.
|
# Source this file; do not execute directly.
|
||||||
|
|
||||||
_backone_detect_platform() {
|
_backone_detect_platform() {
|
||||||
|
if [ -n "${BACKONE_PLATFORM:-}" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
case "$(uname -s 2>/dev/null)" in
|
case "$(uname -s 2>/dev/null)" in
|
||||||
Darwin) BACKONE_PLATFORM=mac ;;
|
Darwin) BACKONE_PLATFORM=mac ;;
|
||||||
Linux) BACKONE_PLATFORM=linux ;;
|
Linux) BACKONE_PLATFORM=linux ;;
|
||||||
@@ -42,8 +45,39 @@ _backone_default_addons_dir() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_backone_default_bin_dir() {
|
||||||
|
case "${BACKONE_PLATFORM}" in
|
||||||
|
mac) printf '%s\n' "/usr/local/bin" ;;
|
||||||
|
windows)
|
||||||
|
if [ -n "${ProgramFiles:-}" ]; then
|
||||||
|
printf '%s\n' "${ProgramFiles}/BackOne/bin"
|
||||||
|
elif [ -n "${PROGRAMFILES:-}" ]; then
|
||||||
|
printf '%s\n' "${PROGRAMFILES}/BackOne/bin"
|
||||||
|
else
|
||||||
|
printf '%s\n' "/c/Program Files/BackOne/bin"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*) printf '%s\n' "/usr/bin" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
backone_resolve_paths() {
|
backone_resolve_paths() {
|
||||||
_backone_detect_platform
|
_backone_detect_platform
|
||||||
BACKONE_HOME="${BACKONE_HOME:-$(_backone_default_home)}"
|
BACKONE_HOME="${BACKONE_HOME:-$(_backone_default_home)}"
|
||||||
BACKONE_ADDONS_DIR="${BACKONE_ADDONS_DIR:-$(_backone_default_addons_dir)}"
|
BACKONE_ADDONS_DIR="${BACKONE_ADDONS_DIR:-$(_backone_default_addons_dir)}"
|
||||||
|
BACKONE_BIN_DIR="${BACKONE_BIN_DIR:-$(_backone_default_bin_dir)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prefix DESTDIR (packaging staging) onto an absolute install path.
|
||||||
|
backone_install_path() {
|
||||||
|
local destdir="${1:-}"
|
||||||
|
local target="${2:-}"
|
||||||
|
if [ -z "$destdir" ]; then
|
||||||
|
printf '%s' "$target"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "$target" in
|
||||||
|
/*) printf '%s%s' "$destdir" "$target" ;;
|
||||||
|
*) printf '%s/%s' "$destdir" "$target" ;;
|
||||||
|
esac
|
||||||
}
|
}
|
||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Print default BackOne addon install paths for make (see make/install-addons.mk).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=platform-paths.sh
|
||||||
|
. "${ROOT}/platform-paths.sh"
|
||||||
|
backone_resolve_paths
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
home) printf '%s' "$BACKONE_HOME" ;;
|
||||||
|
addons) printf '%s' "$BACKONE_ADDONS_DIR" ;;
|
||||||
|
bin) printf '%s' "$BACKONE_BIN_DIR" ;;
|
||||||
|
*)
|
||||||
|
echo "usage: resolve-install-path.sh home|addons|bin" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -41,9 +41,11 @@ build_component_pkg() {
|
|||||||
|
|
||||||
build_component_pkg backone-app-shell install-app-shell com.backone.app-shell
|
build_component_pkg backone-app-shell install-app-shell com.backone.app-shell
|
||||||
build_component_pkg backone-addon-comms-core install-addon-comms-core com.backone.addon.comms-core
|
build_component_pkg backone-addon-comms-core install-addon-comms-core com.backone.addon.comms-core
|
||||||
|
build_component_pkg backone-addon-webhub install-addon-webhub com.backone.addon.webhub
|
||||||
build_component_pkg backone-addon-voice install-addon-voice com.backone.addon.voice
|
build_component_pkg backone-addon-voice install-addon-voice com.backone.addon.voice
|
||||||
build_component_pkg backone-addon-video install-addon-video com.backone.addon.video
|
build_component_pkg backone-addon-video install-addon-video com.backone.addon.video
|
||||||
build_component_pkg backone-addon-chat install-addon-chat com.backone.addon.chat
|
build_component_pkg backone-addon-chat install-addon-chat com.backone.addon.chat
|
||||||
|
build_component_pkg backone-addon-secure-chat install-addon-secure-chat com.backone.addon.secure-chat
|
||||||
build_component_pkg backone-addon-updater install-addon-updater com.backone.addon.updater
|
build_component_pkg backone-addon-updater install-addon-updater com.backone.addon.updater
|
||||||
|
|
||||||
echo "All macOS addon packages are in ${PKGOUT}"
|
echo "All macOS addon packages are in ${PKGOUT}"
|
||||||
@@ -52,9 +52,11 @@ New-Item -ItemType Directory -Path $OutDir | Out-Null
|
|||||||
$Packages = @(
|
$Packages = @(
|
||||||
@{ Name = "backone-app-shell"; Target = "install-app-shell" },
|
@{ Name = "backone-app-shell"; Target = "install-app-shell" },
|
||||||
@{ Name = "backone-addon-comms-core"; Target = "install-addon-comms-core" },
|
@{ Name = "backone-addon-comms-core"; Target = "install-addon-comms-core" },
|
||||||
|
@{ Name = "backone-addon-webhub"; Target = "install-addon-webhub" },
|
||||||
@{ Name = "backone-addon-voice"; Target = "install-addon-voice" },
|
@{ Name = "backone-addon-voice"; Target = "install-addon-voice" },
|
||||||
@{ Name = "backone-addon-video"; Target = "install-addon-video" },
|
@{ Name = "backone-addon-video"; Target = "install-addon-video" },
|
||||||
@{ Name = "backone-addon-chat"; Target = "install-addon-chat" },
|
@{ Name = "backone-addon-chat"; Target = "install-addon-chat" },
|
||||||
|
@{ Name = "backone-addon-secure-chat"; Target = "install-addon-secure-chat" },
|
||||||
@{ Name = "backone-addon-updater"; Target = "install-addon-updater" }
|
@{ Name = "backone-addon-updater"; Target = "install-addon-updater" }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
---
|
||||||
|
name: Production WebRTC
|
||||||
|
overview: Production voice/video WebRTC with ephemeral in-memory signaling only, overlay-first ICE + STUN/TURN from local.conf, no history/persistence in any addon, and no database or messaging server.
|
||||||
|
todos:
|
||||||
|
- id: comms-controller
|
||||||
|
content: Add CommsController (hpp/cpp) with in-memory only call/ICE state + HTTP routes; wire into OneService.cpp and objects.mk
|
||||||
|
status: pending
|
||||||
|
- id: ice-config
|
||||||
|
content: Add local.conf iceServers/icePreferOverlay defaults via migration 004 + GET webrtc-config endpoint
|
||||||
|
status: pending
|
||||||
|
- id: webrtc-session
|
||||||
|
content: Create app-shell/dist/webrtc-session.js with full caller/callee/ICE flow and overlay-first fallback
|
||||||
|
status: pending
|
||||||
|
- id: shell-incoming
|
||||||
|
content: Wire main.js global poll + BackOneRingingPopup accept/reject; add script to index.html
|
||||||
|
status: pending
|
||||||
|
- id: refactor-addons
|
||||||
|
content: Refactor voice/video addons to use BackOneWebRtcSession; remove call history; add voice remote audio
|
||||||
|
status: pending
|
||||||
|
- id: strip-persistence
|
||||||
|
content: Remove localStorage/history from chat and voice; remove server message storage from secure-chat AddonHost handlers
|
||||||
|
status: pending
|
||||||
|
- id: package-test
|
||||||
|
content: Update postinst/migration hooks and verify two-peer call flow end-to-end
|
||||||
|
status: pending
|
||||||
|
isProject: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Production WebRTC for BackOne Voice/Video (no persistence)
|
||||||
|
|
||||||
|
## Design constraints (user requirements)
|
||||||
|
|
||||||
|
- **No history** in any addon — no saved messages, no saved call logs
|
||||||
|
- **No database server** — do not use PostgreSQL, Redis, or [`addons/comms-core/schema.sql`](addons/comms-core/schema.sql)
|
||||||
|
- **No messaging server** — no durable chat/call storage on the backend
|
||||||
|
- **External infra: STUN/TURN only** — operators configure `iceServers` in `local.conf`; coturn is deployed separately, not bundled
|
||||||
|
|
||||||
|
Signaling exists only to **relay live SDP/ICE during an active call**, then state is discarded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current gaps (verified)
|
||||||
|
|
||||||
|
- Voice/video use bare `new RTCPeerConnection()` with no `iceServers`, no ICE trickle, no answer SDP ([`addons/voice/frontend/index.js`](addons/voice/frontend/index.js), [`addons/video/frontend/index.js`](addons/video/frontend/index.js))
|
||||||
|
- `POST /controller/comms/call` is **not implemented** in C++
|
||||||
|
- `BackOneRingingPopup.showIncomingCall` exists but nothing invokes it
|
||||||
|
- Voice/chat currently persist to `localStorage`; voice has call history UI; secure-chat writes `messages.json` via [`service/AddonHost.cpp`](service/AddonHost.cpp) — **all to be removed**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Caller as VoiceVideoAddon
|
||||||
|
participant WRS as BackOneWebRtcSession
|
||||||
|
participant API as CommsController
|
||||||
|
participant Shell as main.js
|
||||||
|
participant Callee as CalleeBrowser
|
||||||
|
|
||||||
|
Caller->>WRS: startOutgoingCall(peer)
|
||||||
|
WRS->>WRS: createOffer + gather ICE
|
||||||
|
WRS->>API: POST /controller/comms/call
|
||||||
|
Note over API: in-memory call map only
|
||||||
|
Callee->>API: GET /controller/comms/call/incoming (poll)
|
||||||
|
API-->>Shell: incoming_call event
|
||||||
|
Shell->>Shell: BackOneRingingPopup.showIncomingCall
|
||||||
|
Shell->>WRS: acceptIncomingCall(callId)
|
||||||
|
WRS->>API: POST /call/{id}/answer + ICE
|
||||||
|
API-->>WRS: answer SDP + remote ICE
|
||||||
|
WRS->>Caller: setRemoteDescription + addIceCandidate
|
||||||
|
Note over WRS,Callee: overlay first, then STUN/TURN from local.conf
|
||||||
|
WRS-->>Caller: connectionState connected
|
||||||
|
Note over API: on end/reject/timeout purge call from memory
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Ephemeral in-memory signaling (no files, no DB)
|
||||||
|
|
||||||
|
Add [`controller/CommsController.hpp`](controller/CommsController.hpp) + [`controller/CommsController.cpp`](controller/CommsController.cpp).
|
||||||
|
|
||||||
|
**Storage**: in-process only (`std::map` + mutex). No `calls.json`, no `messages.json`, no PostgreSQL.
|
||||||
|
|
||||||
|
| Data | Lifetime |
|
||||||
|
|------|----------|
|
||||||
|
| Active call (offer, answer, state, caller/callee IDs) | Until `end`, `reject`, or ring timeout (~30s) |
|
||||||
|
| ICE candidates per callId | Purged with call |
|
||||||
|
| Incoming-call poll buffer | Derived from active calls for matching callee |
|
||||||
|
|
||||||
|
**HTTP routes** (v4 + v6, auth via `X-ZT1-Auth`):
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| POST | `/controller/comms/call` | Create live call → `{ callId, state: "ringing" }` |
|
||||||
|
| GET | `/controller/comms/call/incoming` | Poll ringing calls for this node as callee |
|
||||||
|
| GET | `/controller/comms/call/{callId}` | Live state + SDP for caller/callee |
|
||||||
|
| POST | `/controller/comms/call/{callId}/answer` | Callee answer SDP |
|
||||||
|
| POST | `/controller/comms/call/{callId}/reject` | Callee reject → purge |
|
||||||
|
| POST | `/controller/comms/call/{callId}/end` | Hang up → purge |
|
||||||
|
| POST | `/controller/comms/call/{callId}/ice` | Trickle ICE candidate |
|
||||||
|
| GET | `/controller/comms/call/{callId}/ice` | Fetch remote ICE since timestamp |
|
||||||
|
| GET | `/app/api/webrtc-config` | Return `iceServers`, `icePreferOverlay`, `iceFallbackTimeoutMs` from `local.conf` |
|
||||||
|
|
||||||
|
**Wire-up** in [`service/OneService.cpp`](service/OneService.cpp) + [`objects.mk`](objects.mk).
|
||||||
|
|
||||||
|
**Remove / do not implement**:
|
||||||
|
- Chat `POST /controller/comms/network/{nwid}/chat` persistence routes
|
||||||
|
- Secure-chat file handlers in [`service/AddonHost.cpp`](service/AddonHost.cpp) (`secure-chat/messages.json`) — replace with session-only or remove server POST entirely
|
||||||
|
|
||||||
|
**No WebSocket** — HTTP polling for active calls only (lightweight; no event log on disk).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — STUN/TURN config (only external server dependency)
|
||||||
|
|
||||||
|
### `local.conf` (migration [`004_webrtc_ice_defaults.sh`](addons/updater/migrations/004_webrtc_ice_defaults.sh))
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"icePreferOverlay": true,
|
||||||
|
"iceServers": [
|
||||||
|
{ "urls": "stun:stun.l.google.com:19302" }
|
||||||
|
],
|
||||||
|
"iceFallbackTimeoutMs": 8000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Operators add TURN (coturn or managed) as needed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "urls": "turn:turn.example.com:3478", "username": "...", "credential": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Overlay-first ICE (JS)
|
||||||
|
|
||||||
|
1. Use `peer.ip` from [`peer-directory.js`](app-shell/dist/peer-directory.js) to prioritize overlay paths
|
||||||
|
2. Standard ICE trickle via in-memory `/ice` endpoints
|
||||||
|
3. If not `connected` within `iceFallbackTimeoutMs`, `pc.restartIce()` with full STUN/TURN list
|
||||||
|
|
||||||
|
No database or messaging server involved — only STUN/TURN endpoints configured in `local.conf`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Shared `BackOneWebRtcSession` (app-shell)
|
||||||
|
|
||||||
|
Create [`app-shell/dist/webrtc-session.js`](app-shell/dist/webrtc-session.js); load in [`index.html`](app-shell/dist/index.html) before `addon-loader.js`.
|
||||||
|
|
||||||
|
- Full caller/callee SDP + ICE exchange via ephemeral CommsController
|
||||||
|
- `subscribe()` for live `incoming_call`, `call_state`, `connected`, `ended`
|
||||||
|
- Global `pollLoop()` for incoming calls (works when voice/video tab not mounted)
|
||||||
|
- **No** call history, **no** localStorage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Addon changes (no history, no saved data)
|
||||||
|
|
||||||
|
### Voice — [`addons/voice/frontend/index.js`](addons/voice/frontend/index.js)
|
||||||
|
|
||||||
|
- Remove `callHistory`, `localStorage`, call history panel (75% main area → active call status + controls only)
|
||||||
|
- Delegate WebRTC to `BackOneWebRtcSession`
|
||||||
|
- Add `<audio id="voice-remote" autoplay>` for remote audio
|
||||||
|
|
||||||
|
### Video — [`addons/video/frontend/index.js`](addons/video/frontend/index.js)
|
||||||
|
|
||||||
|
- Delegate to `BackOneWebRtcSession`; keep remote video layout (no change to video stage design)
|
||||||
|
|
||||||
|
### Chat — [`addons/chat/frontend/index.js`](addons/chat/frontend/index.js)
|
||||||
|
|
||||||
|
- Remove `localStorage` / `saveHistory`
|
||||||
|
- **Session-only** thread in memory (`messagesByPeer` cleared on peer/network change or page reload)
|
||||||
|
- Remove server `POST /chat` calls (no messaging server); optional future: WebRTC data channel for live P2P text (out of scope unless needed for send)
|
||||||
|
|
||||||
|
### Secure-chat — [`addons/secure-chat/frontend/index.js`](addons/secure-chat/frontend/index.js)
|
||||||
|
|
||||||
|
- Remove server poll/POST to `/secure-chat`
|
||||||
|
- Session-only in-memory messages like chat addon
|
||||||
|
- Remove secure-chat file routes from [`service/AddonHost.cpp`](service/AddonHost.cpp)
|
||||||
|
|
||||||
|
### App shell — [`app-shell/dist/main.js`](app-shell/dist/main.js)
|
||||||
|
|
||||||
|
- `BackOneWebRtcSession.init()` + global incoming-call → `BackOneRingingPopup`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Packaging
|
||||||
|
|
||||||
|
- Migration `004_webrtc_ice_defaults.sh` via [`debian/postinst`](debian/postinst)
|
||||||
|
- Document coturn as operator-deployed STUN/TURN (not in repo)
|
||||||
|
- `comms-core` remains a manifest dependency only; **schema.sql unused**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Explicitly out of scope
|
||||||
|
|
||||||
|
- PostgreSQL / Redis / any DB server
|
||||||
|
- Message or call history (browser or server)
|
||||||
|
- File-backed comms storage
|
||||||
|
- WebSocket signaling hub
|
||||||
|
- Chat attachment upload server
|
||||||
|
- coturn deb package in-repo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
|
||||||
|
1. Two peers on same BackOne network — voice call rings, accepts, connects, audio plays
|
||||||
|
2. Video call — remote video in main stage
|
||||||
|
3. Hang up / reject — call purged from memory; no history visible after refresh
|
||||||
|
4. Chat / secure-chat — messages visible during session only; gone after reload
|
||||||
|
5. Restrictive NAT — TURN in `local.conf` connects after overlay/STUN timeout
|
||||||
|
6. `curl` smoke tests on live `/controller/comms/call/*` routes with `X-ZT1-Auth`
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
---
|
||||||
|
name: Secure Chat Addon Plan
|
||||||
|
overview: "Cursor plan document for the BackOne Secure Chat addon: a minimal text-only UI addon bundled as `backone-addon-secure-chat`, using WebHub WebSocket signaling (same network/auth as backone-cli) with auto-resolved install paths."
|
||||||
|
todos:
|
||||||
|
- id: verify-implementation
|
||||||
|
content: Verify addons/secure-chat source, packaging targets, and main.js navOrder match this plan
|
||||||
|
status: pending
|
||||||
|
- id: align-debian-deps
|
||||||
|
content: Align debian/backone-addon-secure-chat/DEBIAN/control Depends with debian/control (add backone-addon-webhub)
|
||||||
|
status: pending
|
||||||
|
- id: deploy-test
|
||||||
|
content: Run install-addon-secure-chat + rebuild-manifest + restart backone/webhub and execute test checklist
|
||||||
|
status: pending
|
||||||
|
isProject: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Secure Chat Addon Plan
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Add a **Secure Chat** BackOne addon (`secure-chat`) as a separate installable package (`backone-addon-secure-chat`). It provides a **minimal text-only chat UI** modeled on [`addons/chat`](addons/chat), sharing the same BackOne core network and auth token as `backone-cli`, but without attachments or connection-bar chrome.
|
||||||
|
|
||||||
|
This document is the **Cursor plan reference** for the addon (implementation is largely in place).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph ui [SecureChatUI]
|
||||||
|
A["addons/secure-chat/frontend/index.js"]
|
||||||
|
end
|
||||||
|
subgraph shell [AppShell]
|
||||||
|
B["addon-loader.js"]
|
||||||
|
C["main.js nav"]
|
||||||
|
D["webhub-client.js"]
|
||||||
|
end
|
||||||
|
subgraph signaling [WebHubSidecar]
|
||||||
|
E["backone-webhub :9994"]
|
||||||
|
end
|
||||||
|
subgraph core [BackOneCore]
|
||||||
|
F["backone / backone-cli"]
|
||||||
|
end
|
||||||
|
A -->|subscribe chat_message| D
|
||||||
|
A -->|send chat_message| D
|
||||||
|
D <-->|WebSocket authtoken| E
|
||||||
|
B -->|dynamic import| A
|
||||||
|
C -->|mount| A
|
||||||
|
F -->|peers networks status| A
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key design choices (current codebase):**
|
||||||
|
|
||||||
|
| Aspect | Secure Chat | Chat P2P |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Signaling | WebHub `chat_message` via [`BackOneWebHubClient`](app-shell/dist/webhub-client.js) | Same |
|
||||||
|
| UI | Text-only, no file input | Text + attachments |
|
||||||
|
| Connection bar | Omitted | `BackOneCommLayout` bar |
|
||||||
|
| Message storage | In-memory session only | In-memory session only |
|
||||||
|
| Dependencies | `comms-core`, `webhub` | `comms-core`, `webhub` |
|
||||||
|
| Nav order | First (`secure-chat`) | Second (`chat`) |
|
||||||
|
|
||||||
|
> **Note:** An earlier design used a dedicated HTTP route `POST/GET /controller/comms/network/{nwid}/secure-chat` in `AddonHost.cpp`. The current implementation uses **WebHub** instead (aligned with voice/video/chat signaling). No separate C++ comms route is required for secure-chat today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Source layout
|
||||||
|
|
||||||
|
```
|
||||||
|
addons/secure-chat/
|
||||||
|
├── addon.json # manifest (id, requires, frontend, backend metadata)
|
||||||
|
└── frontend/
|
||||||
|
└── index.js # window.BackOneAddons['secure-chat'] factory
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manifest — [`addons/secure-chat/addon.json`](addons/secure-chat/addon.json)
|
||||||
|
|
||||||
|
- `id`: `"secure-chat"`
|
||||||
|
- `name`: `"Secure Chat"`
|
||||||
|
- `requires`: `["comms-core", "webhub"]`
|
||||||
|
- `frontend.entry`: `/app/addons/secure-chat/index.js`
|
||||||
|
- `frontend.navLabel`: `"Secure Chat"`
|
||||||
|
- `backend.websocketEvents`: `["secure_chat_message"]` (declared; uses shared `chat_message` over WebHub at runtime)
|
||||||
|
|
||||||
|
### Frontend — [`addons/secure-chat/frontend/index.js`](addons/secure-chat/frontend/index.js)
|
||||||
|
|
||||||
|
Minimal UI pattern (from chat reference):
|
||||||
|
|
||||||
|
- **Keep:** `comm-split-layout`, `BackOnePeerPicker`, `BackOneNetworkSession`, `escapeHtml`, message meta/time styling, `BackOneAuth` / core session
|
||||||
|
- **Omit:** file attachment input, `BackOneCommLayout` connection bar, persisted localStorage history
|
||||||
|
|
||||||
|
**Send path:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
BackOneWebHubClient.send({
|
||||||
|
type: 'chat_message',
|
||||||
|
networkId,
|
||||||
|
targetId: selectedPeer.peerId,
|
||||||
|
senderName: name,
|
||||||
|
body
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Receive path:** `BackOneWebHubClient.subscribe('chat_message', handler)` — filters by `networkId` and selected peer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Runtime registration flow
|
||||||
|
|
||||||
|
1. `backone` service loads addons from `/usr/lib/backone/addons/` via [`service/AddonHost.cpp`](service/AddonHost.cpp)
|
||||||
|
2. [`addons/updater/rebuild-addon-manifest.sh`](addons/updater/rebuild-addon-manifest.sh) writes `$BACKONE_HOME/app/addons/manifest.json`
|
||||||
|
3. App shell [`app-shell/dist/addon-loader.js`](app-shell/dist/addon-loader.js) fetches manifest and `import()`s each `frontend.entry`
|
||||||
|
4. [`app-shell/dist/main.js`](app-shell/dist/main.js) builds nav (`navOrder: ['secure-chat', 'chat', 'video', 'voice']`) and calls `mount(panel)`
|
||||||
|
5. [`app-shell/dist/main.js`](app-shell/dist/main.js) connects `BackOneWebHubClient` on init; WebHub sidecar started by `backone-webhub.service`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bundling and packaging
|
||||||
|
|
||||||
|
### Make install — [`make/install-addons.mk`](make/install-addons.mk)
|
||||||
|
|
||||||
|
| Target | Installs to (Linux defaults) |
|
||||||
|
|--------|------------------------------|
|
||||||
|
| `install-addon-secure-chat` | `addon.json` → `/usr/lib/backone/addons/secure-chat/` |
|
||||||
|
| | `index.js` → `/var/lib/backone/app/addons/secure-chat/` |
|
||||||
|
|
||||||
|
Install paths **auto-resolve** from [`pkg/common/platform-paths.sh`](pkg/common/platform-paths.sh) via [`pkg/common/resolve-install-path.sh`](pkg/common/resolve-install-path.sh):
|
||||||
|
|
||||||
|
- Empty `DESTDIR` → live system paths (same defaults as `backone-cli` / updater)
|
||||||
|
- `DESTDIR=/tmp/stage` → packaging staging prefix
|
||||||
|
|
||||||
|
### Debian — [`debian/control`](debian/control)
|
||||||
|
|
||||||
|
- Package: `backone-addon-secure-chat`
|
||||||
|
- Depends: `backone`, `backone-addon-comms-core`, `backone-addon-webhub`, `backone-app-shell`
|
||||||
|
- Rules: [`debian/rules`](debian/rules) → `install-addon-secure-chat`
|
||||||
|
- Metadata: [`debian/backone-addon-secure-chat/DEBIAN/`](debian/backone-addon-secure-chat/DEBIAN/)
|
||||||
|
|
||||||
|
### macOS / Windows
|
||||||
|
|
||||||
|
- [`pkg/mac/build-addon-pkgs.sh`](pkg/mac/build-addon-pkgs.sh) — `backone-addon-secure-chat`
|
||||||
|
- [`pkg/windows/build-addon-packages.ps1`](pkg/windows/build-addon-packages.ps1) — `backone-addon-secure-chat`
|
||||||
|
|
||||||
|
### Version bump
|
||||||
|
|
||||||
|
- [`ci/scripts/bump_version.sh`](ci/scripts/bump_version.sh) includes `secure-chat` in addon version loop
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Install and deploy
|
||||||
|
|
||||||
|
**Live install (no DESTDIR):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo make install-addon-secure-chat
|
||||||
|
sudo make install-addon-manifest # or: sudo backone-updater rebuild-manifest
|
||||||
|
sudo systemctl restart backone
|
||||||
|
sudo systemctl restart backone-webhub # if webhub not running
|
||||||
|
```
|
||||||
|
|
||||||
|
**Package staging:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make install-addon-secure-chat DESTDIR=/tmp/backone-stage
|
||||||
|
make install-addon-manifest DESTDIR=/tmp/backone-stage
|
||||||
|
```
|
||||||
|
|
||||||
|
**Debian package:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dpkg -i backone-addon-secure-chat_*.deb
|
||||||
|
sudo backone-updater rebuild-manifest
|
||||||
|
sudo systemctl restart backone backone-webhub
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test checklist
|
||||||
|
|
||||||
|
1. Web UI shows **Secure Chat** nav tab (first in order)
|
||||||
|
2. Join a BackOne network (same as `backone-cli join`)
|
||||||
|
3. Select a peer, send text — message appears locally
|
||||||
|
4. Peer receives `chat_message` over WebHub (if second client connected)
|
||||||
|
5. Regular **Chat P2P** addon still works (attachments, connection bar)
|
||||||
|
6. `backone-addon-secure-chat` absent → empty-state hint mentions package name
|
||||||
|
7. `manifest.json` lists `secure-chat` after install + rebuild-manifest
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope boundaries
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- New `secure-chat` addon source + `backone-addon-secure-chat` package
|
||||||
|
- Minimal UI (chat reference, text-only)
|
||||||
|
- WebHub real-time signaling
|
||||||
|
- Make / Debian / macOS / Windows bundle entries
|
||||||
|
- Auto-resolved install paths via `platform-paths.sh`
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
|
||||||
|
- Client-side E2E encryption (Web Crypto)
|
||||||
|
- Postgres / file-backed message persistence
|
||||||
|
- Dedicated `/controller/comms/.../secure-chat` HTTP API (superseded by WebHub)
|
||||||
|
- Changes to voice/video addons
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known gap (optional follow-up)
|
||||||
|
|
||||||
|
[`debian/backone-addon-secure-chat/DEBIAN/control`](debian/backone-addon-secure-chat/DEBIAN/control) binary metadata lists `backone-addon-comms-core` + `backone-app-shell` but omits `backone-addon-webhub`; source [`debian/control`](debian/control) includes webhub. Align binary `Depends` with source control for consistent `dpkg` dependency resolution.
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
#include "AddonHost.hpp"
|
#include "AddonHost.hpp"
|
||||||
|
|
||||||
#include "../node/Constants.hpp"
|
#include "../node/Constants.hpp"
|
||||||
|
#include "../node/Utils.hpp"
|
||||||
#include "../osdep/OSUtils.hpp"
|
#include "../osdep/OSUtils.hpp"
|
||||||
|
|
||||||
namespace ZeroTier {
|
namespace ZeroTier {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
#include "OneService.hpp"
|
#include "OneService.hpp"
|
||||||
#include "SoftwareUpdater.hpp"
|
#include "SoftwareUpdater.hpp"
|
||||||
#include "AddonHost.hpp"
|
#include "AddonHost.hpp"
|
||||||
|
#include "../controller/CommsController.hpp"
|
||||||
|
|
||||||
#include <cpp-httplib/httplib.h>
|
#include <cpp-httplib/httplib.h>
|
||||||
|
|
||||||
@@ -813,6 +814,7 @@ public:
|
|||||||
bool _enableWebServer;
|
bool _enableWebServer;
|
||||||
|
|
||||||
AddonHost _addonHost;
|
AddonHost _addonHost;
|
||||||
|
CommsController _commsController;
|
||||||
|
|
||||||
unsigned int _primaryPort;
|
unsigned int _primaryPort;
|
||||||
unsigned int _secondaryPort;
|
unsigned int _secondaryPort;
|
||||||
@@ -1623,6 +1625,12 @@ public:
|
|||||||
}
|
}
|
||||||
_addonHost.configureRoutes(_controlPlane, _controlPlaneV6, setContent);
|
_addonHost.configureRoutes(_controlPlane, _controlPlaneV6, setContent);
|
||||||
|
|
||||||
|
auto localConfigGetter = [this]() -> json {
|
||||||
|
Mutex::Lock lc(_localConfig_m);
|
||||||
|
return _localConfig;
|
||||||
|
};
|
||||||
|
_commsController.configureRoutes(_controlPlane, _controlPlaneV6, localConfigGetter, setContent);
|
||||||
|
|
||||||
//
|
//
|
||||||
// static file server for app ui'
|
// static file server for app ui'
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in new issue
Block a user