Files
BetterDesk/web-nodejs/views/remote-viewer.ejs
T
UNITRONIX 447c22af48 SELinux volume helper, folder fixes, Synology docs
Add create_data_directory helper to prepare Docker data volumes with proper permissions and optional SELinux (svirt_sandbox_file_t) support, and use it in install/migrate flows with user guidance on failures. Add comprehensive SYNOLOGY_INSTALLATION.md with Synology/Portainer/container-manager/docker-compose setup, firewall, troubleshooting and update instructions. Update DB adapters (SQLite/Postgres) to treat device_folder_assignments as the single source of truth: clear assignments on folder delete, stop writing folder_id into peer table, and make getUnassignedDeviceCount resilient by trying 'peers' then 'peer' schemas and returning -1 on error. Minor UI tweaks: add console.error on folder assignment failure and add WIP banners/margin adjustments to remote viewer pages.
2026-03-13 00:45:53 +01:00

510 lines
17 KiB
HTML

<%- include('layouts/viewer', {
title: (device && device.hostname) ? device.hostname : deviceId,
pageScripts: [],
body: `
<!-- WIP Banner -->
<div style="position: fixed; top: 0; left: 0; right: 0; z-index: 9999; background: linear-gradient(90deg, #f59e0b, #d97706); color: #fff; text-align: center; padding: 10px 15px; font-weight: 600; font-size: 14px; box-shadow: 0 2px 8px rgba(0,0,0,0.3);">
🚧 Web Remote Client is under active development and may not function correctly. 🚧
</div>
<!-- BetterDesk Native Remote Desktop Viewer -->
<div class="bdv-container" id="bdv-container" style="margin-top: 48px;">
<!-- Overlay shown before connection -->
<div class="bdv-overlay" id="bdv-overlay">
<div class="bdv-overlay-card">
<div class="bdv-overlay-icon">
<span class="material-icons">connected_tv</span>
</div>
<h2 class="bdv-overlay-title">Remote Desktop</h2>
<p class="bdv-overlay-device">` + (device && device.hostname ? device.hostname : '') + `</p>
<p class="bdv-overlay-id">` + deviceId + `</p>
<div class="bdv-overlay-status" id="bdv-status-area">
<div class="spinner"></div>
<span id="bdv-status-text">Connecting to agent…</span>
</div>
<div class="bdv-overlay-actions" id="bdv-overlay-actions" style="display:none;">
<button class="btn btn-primary" id="btnReconnect">
<span class="material-icons">refresh</span> Reconnect
</button>
<a href="/devices" class="btn btn-secondary">
<span class="material-icons">arrow_back</span> Back
</a>
</div>
</div>
</div>
<!-- Main canvas — shows JPEG frames -->
<canvas id="bdv-canvas" class="bdv-canvas" tabindex="0"></canvas>
<!-- Top toolbar (visible on hover / always when not streaming) -->
<div class="bdv-toolbar" id="bdv-toolbar">
<div class="bdv-toolbar-left">
<span class="material-icons">connected_tv</span>
<span id="bdv-toolbar-title">` + (device && device.hostname ? device.hostname : deviceId) + `</span>
<span class="bdv-toolbar-sep"></span>
<span class="bdv-badge" id="bdv-fps-badge"></span>
</div>
<div class="bdv-toolbar-right">
<button class="bdv-btn" id="btnChat" title="Open Chat">
<span class="material-icons">chat</span>
</button>
<button class="bdv-btn" id="btnFullscreen" title="Fullscreen">
<span class="material-icons">fullscreen</span>
</button>
<button class="bdv-btn bdv-btn--danger" id="btnStop" title="Stop session">
<span class="material-icons">stop_circle</span>
</button>
</div>
</div>
<!-- Chat sidebar -->
<div class="bdv-chat" id="bdv-chat">
<div class="bdv-chat-header">
<span>Chat — ` + deviceId + `</span>
<button class="bdv-btn" id="btnCloseChat">
<span class="material-icons">close</span>
</button>
</div>
<div class="bdv-chat-messages" id="bdv-chat-messages"></div>
<div class="bdv-chat-input-row">
<input type="text" id="bdv-chat-input" class="bdv-chat-inp" placeholder="Type a message…" autocomplete="off" />
<button class="bdv-btn" id="btnSendChat">
<span class="material-icons">send</span>
</button>
</div>
</div>
</div>
<script>
(function() {
'use strict';
// ---- Config ----
const DEVICE_ID = ${JSON.stringify(deviceId)};
const WS_BASE = (location.protocol === 'https:' ? 'wss' : 'ws') + '://' + location.host;
const REMOTE_URL = WS_BASE + '/ws/remote-viewer/' + encodeURIComponent(DEVICE_ID);
const CHAT_URL = WS_BASE + '/ws/chat-operator/' + encodeURIComponent(DEVICE_ID);
// ---- DOM ----
const canvas = document.getElementById('bdv-canvas');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('bdv-overlay');
const statusTxt = document.getElementById('bdv-status-text');
const actions = document.getElementById('bdv-overlay-actions');
const fpsBadge = document.getElementById('bdv-fps-badge');
const chatSide = document.getElementById('bdv-chat');
const chatMsgs = document.getElementById('bdv-chat-messages');
const chatInp = document.getElementById('bdv-chat-input');
const toolbar = document.getElementById('bdv-toolbar');
// ---- State ----
let remoteWs = null;
let chatWs = null;
let streaming = false;
let frameCount = 0;
let lastFpsTick = performance.now();
let chatOpen = false;
let mouseButtonsDown = 0;
// ---- FPS Counter ----
setInterval(() => {
const now = performance.now();
const dt = (now - lastFpsTick) / 1000;
const fps = (frameCount / dt).toFixed(1);
fpsBadge.textContent = streaming ? fps + ' fps' : '—';
frameCount = 0;
lastFpsTick = now;
}, 2000);
// ---- Remote WebSocket ----
function connectRemote() {
setStatus('Connecting to server…');
remoteWs = new WebSocket(REMOTE_URL);
remoteWs.binaryType = 'arraybuffer';
remoteWs.onopen = () => {
setStatus('Waiting for agent…');
};
remoteWs.onclose = (ev) => {
streaming = false;
setStatus('Disconnected — ' + (ev.reason || 'connection closed'));
showActions();
};
remoteWs.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) {
// Binary = JPEG frame
handleJpegFrame(ev.data);
} else {
handleJsonFrame(JSON.parse(ev.data));
}
};
remoteWs.onerror = () => {
setStatus('Connection error');
showActions();
};
}
function handleJsonFrame(frame) {
switch (frame.type) {
case 'session-info':
if (frame.agent_ready) {
setStatus(frame.streaming ? 'Streaming…' : 'Agent ready — starting stream…');
} else {
setStatus('Waiting for agent to connect…');
}
streaming = frame.streaming;
break;
case 'agent-ready':
setStatus('Agent connected — starting stream…');
break;
case 'stream-started':
streaming = true;
overlay.style.display = 'none';
canvas.focus();
break;
case 'stream-stopped':
streaming = false;
setStatus('Stream stopped — ' + (frame.reason || ''));
overlay.style.display = 'flex';
showActions();
break;
case 'agent-disconnected':
streaming = false;
setStatus('Agent disconnected');
overlay.style.display = 'flex';
showActions();
break;
}
}
function handleJpegFrame(buffer) {
const blob = new Blob([buffer], { type: 'image/jpeg' });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
if (canvas.width !== img.width || canvas.height !== img.height) {
canvas.width = img.width;
canvas.height = img.height;
}
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
frameCount++;
};
img.onerror = () => URL.revokeObjectURL(url);
img.src = url;
}
// ---- Input forwarding ----
canvas.addEventListener('mousemove', (e) => {
if (!streaming) return;
const { rx, ry } = canvasCoords(e);
sendRemote({ type: 'input', event_type: 'mouse_move', x: rx, y: ry });
});
canvas.addEventListener('mousedown', (e) => {
if (!streaming) return;
mouseButtonsDown |= (1 << e.button);
const { rx, ry } = canvasCoords(e);
sendRemote({ type: 'input', event_type: 'mouse_down', x: rx, y: ry, button: btnName(e.button) });
});
canvas.addEventListener('mouseup', (e) => {
if (!streaming) return;
mouseButtonsDown &= ~(1 << e.button);
const { rx, ry } = canvasCoords(e);
sendRemote({ type: 'input', event_type: 'mouse_up', x: rx, y: ry, button: btnName(e.button) });
});
canvas.addEventListener('wheel', (e) => {
if (!streaming) return;
e.preventDefault();
sendRemote({ type: 'input', event_type: 'wheel', delta_x: Math.round(e.deltaX), delta_y: Math.round(e.deltaY) });
}, { passive: false });
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
canvas.addEventListener('keydown', (e) => {
if (!streaming) return;
e.preventDefault();
sendRemote({ type: 'input', event_type: 'key_down', key: e.key });
});
canvas.addEventListener('keyup', (e) => {
if (!streaming) return;
e.preventDefault();
sendRemote({ type: 'input', event_type: 'key_up', key: e.key });
});
function canvasCoords(e) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
rx: Math.round((e.clientX - rect.left) * scaleX),
ry: Math.round((e.clientY - rect.top) * scaleY),
};
}
function btnName(b) {
return b === 2 ? 'right' : b === 1 ? 'middle' : 'left';
}
function sendRemote(obj) {
if (remoteWs && remoteWs.readyState === WebSocket.OPEN) {
remoteWs.send(JSON.stringify(obj));
}
}
// ---- Toolbar / UI ----
function setStatus(msg) { statusTxt.textContent = msg; }
function showActions() { actions.style.display = 'flex'; }
document.getElementById('btnReconnect').onclick = () => {
actions.style.display = 'none';
if (remoteWs) remoteWs.close();
setTimeout(connectRemote, 200);
};
document.getElementById('btnStop').onclick = () => {
sendRemote({ type: 'stop' });
if (remoteWs) remoteWs.close();
};
document.getElementById('btnFullscreen').onclick = () => {
if (!document.fullscreenElement) {
document.getElementById('bdv-container').requestFullscreen();
} else {
document.exitFullscreen();
}
};
document.addEventListener('fullscreenchange', () => {
const icon = document.getElementById('btnFullscreen').querySelector('span');
icon.textContent = document.fullscreenElement ? 'fullscreen_exit' : 'fullscreen';
});
// Toolbar auto-hide
let hideTimer;
document.getElementById('bdv-container').addEventListener('mousemove', () => {
toolbar.classList.add('bdv-toolbar--visible');
clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
if (streaming) toolbar.classList.remove('bdv-toolbar--visible');
}, 2500);
});
// ---- Chat ----
function connectChat() {
chatWs = new WebSocket(CHAT_URL);
chatWs.onopen = () => {};
chatWs.onclose = () => {};
chatWs.onmessage = (ev) => {
const frame = JSON.parse(ev.data);
if (frame.type === 'message') appendChatMsg(frame);
if (frame.type === 'history') frame.messages.forEach(appendChatMsg);
};
}
function appendChatMsg(msg) {
const div = document.createElement('div');
div.className = 'bdv-chat-msg bdv-chat-msg--' + (msg.from === 'operator' ? 'out' : 'in');
div.innerHTML = '<span class="bdv-chat-msg-text">' + escHtml(msg.text) + '</span>' +
'<span class="bdv-chat-msg-time">' + new Date(msg.timestamp).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) + '</span>';
chatMsgs.appendChild(div);
chatMsgs.scrollTop = chatMsgs.scrollHeight;
}
function escHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function sendChatMsg() {
const text = chatInp.value.trim();
if (!text || !chatWs || chatWs.readyState !== WebSocket.OPEN) return;
chatWs.send(JSON.stringify({ type: 'message', text }));
chatInp.value = '';
}
document.getElementById('btnChat').onclick = () => {
chatOpen = !chatOpen;
chatSide.classList.toggle('bdv-chat--open', chatOpen);
};
document.getElementById('btnCloseChat').onclick = () => {
chatOpen = false;
chatSide.classList.remove('bdv-chat--open');
};
document.getElementById('btnSendChat').onclick = sendChatMsg;
chatInp.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendChatMsg(); });
// ---- Init ----
connectRemote();
connectChat();
})();
</script>
<style>
/* ---- BetterDesk Remote Viewer styles (scope: this page only) ---- */
.bdv-container {
position: fixed; inset: 0;
background: #111;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.bdv-canvas {
display: block;
max-width: 100%; max-height: 100%;
object-fit: contain;
cursor: none;
outline: none;
}
.bdv-overlay {
position: absolute; inset: 0;
background: rgba(0,0,0,.82);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.bdv-overlay-card {
background: #1a1a2e;
border: 1px solid #2d2d4a;
border-radius: 16px;
padding: 40px 48px;
text-align: center;
max-width: 400px;
width: 90%;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: #e8e8f0;
}
.bdv-overlay-icon .material-icons { font-size: 56px; color: #4f6ef7; }
.bdv-overlay-title { font-size: 1.4rem; font-weight: 700; }
.bdv-overlay-device { font-size: 1rem; color: #9898b0; }
.bdv-overlay-id { font-size: 0.8rem; color: #606078; font-family: monospace; }
.bdv-overlay-status {
display: flex; align-items: center; gap: 10px;
color: #9898b0; font-size: 0.9rem;
}
.bdv-overlay-actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
.bdv-toolbar {
position: absolute;
top: 0; left: 0; right: 0;
height: 48px;
background: linear-gradient(to bottom, rgba(0,0,0,.8), transparent);
display: flex;
align-items: center;
padding: 0 16px;
gap: 10px;
z-index: 20;
opacity: 0;
transition: opacity .2s;
}
.bdv-toolbar--visible { opacity: 1; }
.bdv-toolbar-left, .bdv-toolbar-right {
display: flex; align-items: center; gap: 8px; color: #e8e8f0;
}
.bdv-toolbar-right { margin-left: auto; }
.bdv-toolbar-sep { width: 1px; height: 16px; background: #444; }
.bdv-badge { background: rgba(255,255,255,.1); border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; color: #ccc; }
.bdv-btn {
background: none; border: none; cursor: pointer; color: #e8e8f0;
padding: 4px; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
transition: background .15s;
}
.bdv-btn:hover { background: rgba(255,255,255,.15); }
.bdv-btn--danger:hover { background: rgba(239,68,68,.3); }
.bdv-chat {
position: absolute;
top: 0; right: -320px; bottom: 0;
width: 320px;
background: #1a1a2e;
border-left: 1px solid #2d2d4a;
z-index: 30;
display: flex; flex-direction: column;
transition: right .25s ease;
}
.bdv-chat--open { right: 0; }
.bdv-chat-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #2d2d4a;
color: #e8e8f0; font-size: 0.9rem; font-weight: 600;
}
.bdv-chat-messages {
flex: 1; overflow-y: auto;
padding: 12px 14px;
display: flex; flex-direction: column; gap: 8px;
}
.bdv-chat-msg { display: flex; flex-direction: column; max-width: 85%; }
.bdv-chat-msg--out { align-self: flex-end; }
.bdv-chat-msg--in { align-self: flex-start; }
.bdv-chat-msg-text {
padding: 8px 12px;
border-radius: 12px;
font-size: 0.875rem;
word-break: break-word;
}
.bdv-chat-msg--out .bdv-chat-msg-text { background: #4f6ef7; color: #fff; border-bottom-right-radius: 4px; }
.bdv-chat-msg--in .bdv-chat-msg-text { background: #222240; color: #e8e8f0; border-bottom-left-radius: 4px; }
.bdv-chat-msg-time { font-size: 0.7rem; color: #606078; padding: 2px 4px; }
.bdv-chat-msg--out .bdv-chat-msg-time { text-align: right; }
.bdv-chat-input-row {
display: flex; gap: 6px; padding: 10px 12px;
border-top: 1px solid #2d2d4a;
}
.bdv-chat-inp {
flex: 1;
background: #222240; border: 1px solid #2d2d4a; border-radius: 8px;
padding: 8px 12px; color: #e8e8f0; font-size: 0.875rem;
outline: none;
}
.bdv-chat-inp:focus { border-color: #4f6ef7; }
/* Spinner reuse from theme.css (fallback inline) */
.spinner {
width: 20px; height: 20px;
border: 2px solid #2d2d4a;
border-top-color: #4f6ef7;
border-radius: 50%;
animation: spin .8s linear infinite;
display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
`
}) %>