mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-11 13:49:03 +00:00
423c40ff08
- Updated CSS to ensure the monitors menu displays as a flex container only when open, improving layout consistency. - Removed unnecessary JavaScript that displayed the monitors button by default, enhancing code clarity and functionality.
2103 lines
85 KiB
JavaScript
2103 lines
85 KiB
JavaScript
/**
|
|
* BetterDesk Console - Remote Desktop Session Manager
|
|
* Multi-tab remote desktop viewer with shared toolbar
|
|
* Supports multiple concurrent RDClient sessions
|
|
*/
|
|
|
|
/* global RDClient, RDVideo, CDAPSession */
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
// ---- i18n helper ----
|
|
// window.t (from i18n-client.js) returns the key itself when missing;
|
|
// wrap it so callers can provide an English fallback string.
|
|
function t(key, fallback) {
|
|
if (typeof window.t === 'function') {
|
|
const val = window.t(key);
|
|
if (val && val !== key) return val;
|
|
}
|
|
return fallback !== undefined ? fallback : key;
|
|
}
|
|
|
|
// ---- Transport selection (PR 2.3) ----
|
|
// The unified web client picks the right transport per-device based
|
|
// on `window.__capabilities.transport` (set server-side from the Go
|
|
// peer record). RustDesk peers go through `RDClient`; OS-agent /
|
|
// CDAP-connected peers use `CDAPSession`, which exposes the same
|
|
// public surface so the rest of the session manager is transport-
|
|
// agnostic.
|
|
function getTransportName() {
|
|
const caps = window.__capabilities || {};
|
|
const t = String(caps.transport || 'rd').toLowerCase();
|
|
if (t === 'mesh') return 'mesh';
|
|
if (t === 'cdap') return 'cdap';
|
|
return 'rd';
|
|
}
|
|
function createTransportClient(canvas, opts) {
|
|
const name = getTransportName();
|
|
if (name === 'mesh' && typeof MeshSession === 'function') {
|
|
return new MeshSession(canvas, opts);
|
|
}
|
|
if (name === 'cdap' && typeof CDAPSession === 'function') {
|
|
return new CDAPSession(canvas, opts);
|
|
}
|
|
return new RDClient(canvas, opts);
|
|
}
|
|
|
|
// ---- Simple toast notification ----
|
|
function showToast(message, type) {
|
|
const toast = document.createElement('div');
|
|
toast.className = 'rd-toast rd-toast-' + (type || 'info');
|
|
toast.textContent = message;
|
|
document.body.appendChild(toast);
|
|
requestAnimationFrame(() => toast.classList.add('show'));
|
|
setTimeout(() => {
|
|
toast.classList.remove('show');
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 3500);
|
|
}
|
|
|
|
// ---- Session storage ----
|
|
const sessions = new Map(); // deviceId → SessionInfo
|
|
let activeSessionId = null;
|
|
|
|
const Prefs = window.RemoteViewerPrefs || {};
|
|
const globalViewerPrefs = typeof Prefs.loadRemoteViewerPrefs === 'function'
|
|
? Prefs.loadRemoteViewerPrefs(window.BetterDesk?.user?.id)
|
|
: { quality: 'Best', scale: 'fit', codec: 'Auto', adaptiveQuality: true, backgroundFps: 1 };
|
|
|
|
function cloneViewerPrefs(prefs) {
|
|
return Object.assign({
|
|
quality: 'Best',
|
|
scale: 'fit',
|
|
codec: 'Auto',
|
|
adaptiveQuality: true,
|
|
backgroundFps: 1,
|
|
keyboardMode: 'Auto',
|
|
}, prefs || globalViewerPrefs);
|
|
}
|
|
|
|
function persistGlobalViewerPrefs(prefs) {
|
|
Object.assign(globalViewerPrefs, prefs);
|
|
if (typeof Prefs.saveRemoteViewerPrefs === 'function') {
|
|
Prefs.saveRemoteViewerPrefs(window.BetterDesk?.user?.id, globalViewerPrefs);
|
|
}
|
|
}
|
|
|
|
function buildClientOpts(session) {
|
|
const prefs = session.viewerPrefs || globalViewerPrefs;
|
|
const userName = (window.BetterDesk.user && (window.BetterDesk.user.display_name || window.BetterDesk.user.username)) || 'BetterDesk Web';
|
|
const activeFps = typeof Prefs.getActiveFpsForQuality === 'function'
|
|
? Prefs.getActiveFpsForQuality(prefs.quality)
|
|
: 60;
|
|
return {
|
|
deviceId: session.deviceId,
|
|
serverPubKey: window.BetterDesk.serverPubKey || '',
|
|
myName: userName,
|
|
scaleMode: prefs.scale || 'fit',
|
|
fps: activeFps,
|
|
imageQuality: prefs.quality || 'Best',
|
|
qualityPreset: typeof Prefs.getPresetForQuality === 'function'
|
|
? Prefs.getPresetForQuality(prefs.quality)
|
|
: 'best',
|
|
adaptiveQuality: prefs.adaptiveQuality !== false,
|
|
preferCodec: prefs.codec || 'Auto',
|
|
disableAudio: false,
|
|
serverRecord: session.meshServerRecord || false,
|
|
};
|
|
}
|
|
|
|
function applyViewerPrefsToClient(client, prefs) {
|
|
if (!client || !prefs) return;
|
|
if (typeof client.setBackgroundFps === 'function') {
|
|
client.setBackgroundFps(prefs.backgroundFps || 1);
|
|
}
|
|
if (client._state === 'streaming' || client.state === 'streaming') {
|
|
const preset = typeof Prefs.getPresetForQuality === 'function'
|
|
? Prefs.getPresetForQuality(prefs.quality)
|
|
: ({ Best: 'best', Balanced: 'balanced', Low: 'speed' }[prefs.quality] || 'best');
|
|
if (typeof client.setQualityPreset === 'function') {
|
|
client.setQualityPreset(preset);
|
|
}
|
|
if (typeof client.setScaleMode === 'function') {
|
|
client.setScaleMode(prefs.scale || 'fit');
|
|
}
|
|
if (prefs.codec && prefs.codec !== 'Auto' && typeof client.setCodec === 'function') {
|
|
client.setCodec(prefs.codec);
|
|
}
|
|
if (typeof client.setKeyboardMode === 'function' && prefs.keyboardMode) {
|
|
client.setKeyboardMode(prefs.keyboardMode);
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateSessionViewerPref(session, patch) {
|
|
if (!session) return;
|
|
session.viewerPrefs = Object.assign({}, session.viewerPrefs || cloneViewerPrefs(), patch);
|
|
persistGlobalViewerPrefs(session.viewerPrefs);
|
|
}
|
|
|
|
/**
|
|
* Session info wrapper for a single remote connection
|
|
*/
|
|
class SessionInfo {
|
|
constructor(deviceId, deviceName, panel) {
|
|
this.deviceId = deviceId;
|
|
this.deviceName = deviceName || '';
|
|
this.panel = panel;
|
|
this.canvas = panel.querySelector('.session-canvas');
|
|
this.connectionOverlay = panel.querySelector('.session-connection-overlay');
|
|
this.passwordOverlay = panel.querySelector('.session-password-overlay');
|
|
this.passwordInput = panel.querySelector('.session-password-input');
|
|
this.rememberPeerCheckbox = panel.querySelector('.session-remember-peer-checkbox');
|
|
this.loginError = panel.querySelector('.session-login-error');
|
|
this.statusText = panel.querySelector('.session-status-text');
|
|
this.overlayActions = panel.querySelector('.session-overlay-actions');
|
|
this.chatPanel = panel.querySelector('.session-chat-panel');
|
|
this.chatMessages = panel.querySelector('.session-chat-messages');
|
|
this.chatInput = panel.querySelector('.session-chat-input');
|
|
this.tfaOverlay = panel.querySelector('.session-2fa-overlay');
|
|
this.tfaInput = panel.querySelector('.session-2fa-input');
|
|
this.tfaError = panel.querySelector('.session-2fa-error');
|
|
this.cdapFallbackBtn = panel.querySelector('.session-btn-cdap-fallback');
|
|
this.client = null;
|
|
this.state = 'idle';
|
|
this.meshServerRecord = false;
|
|
this.latency = 0;
|
|
this.lastStats = null;
|
|
this.audioMuted = false;
|
|
this.viewerPrefs = cloneViewerPrefs();
|
|
this.mediaRecorder = null;
|
|
this.recordedChunks = [];
|
|
}
|
|
}
|
|
|
|
// ---- DOM References (shared) ----
|
|
const viewerContainer = document.getElementById('viewer-container');
|
|
const viewerShell = document.getElementById('rd-viewer-shell');
|
|
const toolbar = document.getElementById('viewer-toolbar');
|
|
const toolbarHandle = document.getElementById('toolbar-handle');
|
|
const toolbarNotchSlot = document.getElementById('toolbar-notch-slot');
|
|
const toolbarStatus = document.getElementById('toolbar-status');
|
|
const toolbarDeviceId = document.getElementById('toolbar-device-id');
|
|
const tabBar = document.getElementById('session-tabs');
|
|
|
|
// ---- Notch toolbar (integrated in session tab bar) ----
|
|
// Compact handle pulls the action drawer open; fullscreen stays on the handle only.
|
|
let toolbarPinned = false;
|
|
let drawerPullTimer = null;
|
|
|
|
function syncToolbarHandleAria(expanded) {
|
|
if (!toolbarHandle) return;
|
|
toolbarHandle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
|
}
|
|
|
|
function setDrawerPulling(active) {
|
|
toolbar?.classList.toggle('drawer-pulling', active);
|
|
if (!active) {
|
|
clearTimeout(drawerPullTimer);
|
|
drawerPullTimer = null;
|
|
return;
|
|
}
|
|
clearTimeout(drawerPullTimer);
|
|
drawerPullTimer = setTimeout(() => toolbar?.classList.remove('drawer-pulling'), 440);
|
|
}
|
|
|
|
function syncExpandControlIcon(expanded) {
|
|
const exp = document.getElementById('btn-toolbar-expand');
|
|
if (!exp) return;
|
|
exp.classList.toggle('active', expanded);
|
|
const ic = exp.querySelector('.material-icons');
|
|
if (ic) ic.textContent = expanded ? 'expand_less' : 'expand_more';
|
|
}
|
|
|
|
function expandToolbar() {
|
|
if (toolbarNotchSlot?.classList.contains('toolbar-chrome-hidden')) return;
|
|
toolbar.classList.remove('hover-preview');
|
|
toolbar.classList.add('expanded');
|
|
syncToolbarHandleAria(true);
|
|
syncExpandControlIcon(true);
|
|
setDrawerPulling(true);
|
|
}
|
|
|
|
function collapseToolbar() {
|
|
if (toolbarPinned) return;
|
|
if (document.querySelector('.toolbar-dropdown-menu.open')) return;
|
|
setDrawerPulling(true);
|
|
toolbar.classList.remove('expanded');
|
|
toolbar.classList.remove('hover-preview');
|
|
syncToolbarHandleAria(false);
|
|
syncExpandControlIcon(false);
|
|
}
|
|
|
|
function toggleToolbar() {
|
|
if (toolbar.classList.contains('expanded') || toolbar.classList.contains('pinned')) {
|
|
toolbar.classList.remove('expanded', 'hover-preview');
|
|
syncExpandControlIcon(false);
|
|
syncToolbarHandleAria(false);
|
|
setDrawerPulling(true);
|
|
if (toolbarPinned) {
|
|
toolbarPinned = false;
|
|
toolbar.classList.remove('pinned');
|
|
document.getElementById('btn-pin')?.classList.remove('active');
|
|
}
|
|
} else {
|
|
expandToolbar();
|
|
}
|
|
}
|
|
|
|
if (toolbarHandle && toolbar) {
|
|
toolbarHandle.addEventListener('mouseenter', () => {
|
|
if (!toolbar.classList.contains('expanded') && !toolbarPinned) {
|
|
toolbar.classList.add('hover-preview');
|
|
}
|
|
});
|
|
toolbarHandle.addEventListener('mouseleave', () => {
|
|
if (!toolbar.classList.contains('expanded')) {
|
|
toolbar.classList.remove('hover-preview');
|
|
}
|
|
});
|
|
toolbarHandle.addEventListener('focusin', () => {
|
|
if (!toolbar.classList.contains('expanded') && !toolbarPinned) {
|
|
toolbar.classList.add('hover-preview');
|
|
}
|
|
});
|
|
toolbarHandle.addEventListener('focusout', () => {
|
|
if (!toolbar.classList.contains('expanded')) {
|
|
toolbar.classList.remove('hover-preview');
|
|
}
|
|
});
|
|
toolbarHandle.addEventListener('click', (e) => {
|
|
if (toolbarNotchSlot?.classList.contains('toolbar-chrome-hidden')) return;
|
|
if (e.target.closest('#btn-handle-fullscreen')) return;
|
|
e.preventDefault();
|
|
toggleToolbar();
|
|
});
|
|
toolbarHandle.addEventListener('keydown', (e) => {
|
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
if (e.target.closest('#btn-handle-fullscreen')) return;
|
|
e.preventDefault();
|
|
toggleToolbar();
|
|
});
|
|
}
|
|
|
|
// Legacy compatibility shims — older code paths call these to surface the
|
|
// status while overlays are visible. They now drive expand/collapse only,
|
|
// never an auto-hide timer.
|
|
function showToolbar() {
|
|
if (toolbarNotchSlot?.classList.contains('toolbar-chrome-hidden')) return;
|
|
expandToolbar();
|
|
}
|
|
|
|
let toolbarChromeExiting = false;
|
|
|
|
function forceCollapseToolbar() {
|
|
toolbarPinned = false;
|
|
toolbar.classList.remove('expanded', 'pinned', 'hover-preview', 'drawer-pulling');
|
|
document.getElementById('btn-pin')?.classList.remove('active');
|
|
syncToolbarHandleAria(false);
|
|
syncExpandControlIcon(false);
|
|
clearTimeout(drawerPullTimer);
|
|
drawerPullTimer = null;
|
|
}
|
|
|
|
function setToolbarChromeVisible(visible) {
|
|
if (!toolbarNotchSlot) return;
|
|
const wasHidden = toolbarNotchSlot.classList.contains('toolbar-chrome-hidden');
|
|
|
|
if (!visible) {
|
|
if (wasHidden || toolbarChromeExiting) return;
|
|
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
if (reducedMotion) {
|
|
toolbarNotchSlot.classList.add('toolbar-chrome-hidden');
|
|
forceCollapseToolbar();
|
|
toolbarNotchSlot.classList.remove('toolbar-chrome-enter', 'toolbar-chrome-exit');
|
|
return;
|
|
}
|
|
toolbarChromeExiting = true;
|
|
playToolbarChromeExit(() => {
|
|
toolbarNotchSlot.classList.add('toolbar-chrome-hidden');
|
|
forceCollapseToolbar();
|
|
toolbarNotchSlot.classList.remove('toolbar-chrome-enter', 'toolbar-chrome-exit');
|
|
toolbarChromeExiting = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
toolbarChromeExiting = false;
|
|
toolbarNotchSlot.classList.remove('toolbar-chrome-hidden', 'toolbar-chrome-exit');
|
|
if (wasHidden) playToolbarChromeEnter();
|
|
}
|
|
|
|
function playToolbarChromeExit(done) {
|
|
if (!toolbarNotchSlot) {
|
|
done?.();
|
|
return;
|
|
}
|
|
toolbarNotchSlot.classList.remove('toolbar-chrome-enter');
|
|
void toolbarNotchSlot.offsetWidth;
|
|
toolbarNotchSlot.classList.add('toolbar-chrome-exit');
|
|
|
|
let finished = false;
|
|
const finish = () => {
|
|
if (finished) return;
|
|
finished = true;
|
|
toolbarNotchSlot.classList.remove('toolbar-chrome-exit');
|
|
toolbarNotchSlot.removeEventListener('animationend', onEnd);
|
|
clearTimeout(fallback);
|
|
done?.();
|
|
};
|
|
|
|
const onEnd = (ev) => {
|
|
if (ev.target !== toolbarNotchSlot) return;
|
|
finish();
|
|
};
|
|
|
|
toolbarNotchSlot.addEventListener('animationend', onEnd);
|
|
const fallback = setTimeout(finish, 420);
|
|
}
|
|
|
|
function playToolbarChromeEnter() {
|
|
if (!toolbarNotchSlot) return;
|
|
toolbarNotchSlot.classList.remove('toolbar-chrome-enter');
|
|
void toolbarNotchSlot.offsetWidth;
|
|
toolbarNotchSlot.classList.add('toolbar-chrome-enter');
|
|
const onEnd = (ev) => {
|
|
if (ev.target !== toolbarNotchSlot && !ev.target.classList.contains('toolbar-handle')) return;
|
|
toolbarNotchSlot.classList.remove('toolbar-chrome-enter');
|
|
toolbarNotchSlot.removeEventListener('animationend', onEnd);
|
|
};
|
|
toolbarNotchSlot.addEventListener('animationend', onEnd);
|
|
setTimeout(() => toolbarNotchSlot.classList.remove('toolbar-chrome-enter'), 500);
|
|
}
|
|
|
|
/** Show notch only during active streaming; hidden on login/connect overlays. */
|
|
function syncToolbarChrome(session) {
|
|
if (!session || !isActive(session) || session.state !== 'streaming') {
|
|
setToolbarChromeVisible(false);
|
|
return;
|
|
}
|
|
setToolbarChromeVisible(true);
|
|
if (!toolbarPinned) collapseToolbar();
|
|
}
|
|
|
|
// Keep toolbar chrome in sync when session ends (disconnect / error / idle).
|
|
function hideToolbarChromeForSession(session) {
|
|
if (!session || !isActive(session)) return;
|
|
setToolbarChromeVisible(false);
|
|
}
|
|
|
|
function setToolbarAutoHide(enable) {
|
|
if (enable) {
|
|
syncToolbarChrome(getActiveSession());
|
|
} else {
|
|
setToolbarChromeVisible(false);
|
|
}
|
|
}
|
|
|
|
// ---- Independent "back to devices" navigation ----
|
|
// The rdclient page is opened as a script-launched tab (window.open) from
|
|
// the web panel. Navigating THIS tab to /devices spawned duplicate panel
|
|
// tabs that accumulated over time. Instead, close this tab and re-focus the
|
|
// opener; only fall back to navigation when there is no opener (e.g. the
|
|
// page was opened directly via URL).
|
|
function isRdClientDesktop() {
|
|
if (window.__BETTERDESK_RDCLIENT_DESKTOP__) return true;
|
|
return !!(window.__TAURI__ && window.__TAURI__.core && typeof window.__TAURI__.core.invoke === 'function');
|
|
}
|
|
|
|
function returnToDevices() {
|
|
if (isRdClientDesktop()) {
|
|
const invoke = window.__TAURI__?.core?.invoke;
|
|
if (invoke) {
|
|
invoke('close_current_window').catch(() => {
|
|
window.__TAURI__?.window?.getCurrentWindow?.()?.close?.().catch(() => {});
|
|
});
|
|
return;
|
|
}
|
|
// Desktop shell injected flag but IPC not ready — never navigate to /devices.
|
|
if (window.__BETTERDESK_RDCLIENT_DESKTOP__ && /\/remote\/[^/?#]+/.test(location.pathname)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
if (window.opener && !window.opener.closed) {
|
|
try { window.opener.focus(); } catch { /* ignore */ }
|
|
window.close();
|
|
// If the browser blocked window.close() (not script-opened),
|
|
// fall through to navigation after a short delay.
|
|
setTimeout(() => {
|
|
if (!window.closed) window.location.href = '/devices';
|
|
}, 150);
|
|
return;
|
|
}
|
|
} catch { /* ignore */ }
|
|
window.location.href = '/devices';
|
|
}
|
|
|
|
// ---- Automatic clipboard sync (local -> remote) ----
|
|
// Native RustDesk pushes the controlling side's clipboard to the peer
|
|
// automatically, so a plain Ctrl+V on the remote pastes local content.
|
|
// The browser cannot observe clipboard changes, but it can read the
|
|
// clipboard once the tab regains focus (with transient activation), so we
|
|
// push the current local clipboard to the active streaming session then.
|
|
let _lastSyncedClipboard = '';
|
|
async function syncLocalClipboardToRemote() {
|
|
const session = getActiveSession();
|
|
if (!session || !session.client || session.state !== 'streaming') return;
|
|
if (session.client.viewOnly) return;
|
|
if (!navigator.clipboard || !navigator.clipboard.readText) return;
|
|
try {
|
|
const text = await navigator.clipboard.readText();
|
|
if (text && text !== _lastSyncedClipboard) {
|
|
_lastSyncedClipboard = text;
|
|
session.client.sendClipboard(text);
|
|
}
|
|
} catch {
|
|
// Permission denied or not focused — ignore, the manual paste
|
|
// button remains available as a fallback.
|
|
}
|
|
}
|
|
window.addEventListener('focus', syncLocalClipboardToRemote);
|
|
if (viewerContainer) {
|
|
viewerContainer.addEventListener('mousedown', syncLocalClipboardToRemote);
|
|
}
|
|
|
|
|
|
// ---- Tab Bar (slim + expandable cards) ----
|
|
|
|
const sessionTabBarEl = document.getElementById('session-tab-bar');
|
|
let expandedTabId = null;
|
|
|
|
function collapseExpandedTabBarItems() {
|
|
tabBar.querySelectorAll('.session-tab-card.is-expanded').forEach((t) => t.classList.remove('is-expanded'));
|
|
document.getElementById('btn-back-devices')?.classList.remove('is-expanded');
|
|
document.getElementById('btn-add-session')?.classList.remove('is-expanded');
|
|
expandedTabId = null;
|
|
}
|
|
|
|
function tabStatusClass(state) {
|
|
switch (state) {
|
|
case 'streaming': return 'status-online';
|
|
case 'connecting':
|
|
case 'authenticating':
|
|
case 'waiting_password': return 'status-connecting';
|
|
case 'error': return 'status-error';
|
|
default: return 'status-offline';
|
|
}
|
|
}
|
|
|
|
function createTab(deviceId, deviceName, platform) {
|
|
const tab = document.createElement('div');
|
|
tab.className = 'session-tab session-tab-card status-offline';
|
|
tab.dataset.sessionId = deviceId;
|
|
|
|
const inner = document.createElement('div');
|
|
inner.className = 'session-tab-card-inner';
|
|
|
|
const osIcon = document.createElement('span');
|
|
osIcon.className = 'session-tab-os material-icons';
|
|
const iconName = (window.RemoteAddressBook && window.RemoteAddressBook.platformIcon)
|
|
? window.RemoteAddressBook.platformIcon(platform || '')
|
|
: 'devices';
|
|
osIcon.textContent = iconName;
|
|
inner.appendChild(osIcon);
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'session-tab-label';
|
|
label.textContent = deviceName || deviceId;
|
|
label.title = deviceId;
|
|
inner.appendChild(label);
|
|
|
|
const dot = document.createElement('span');
|
|
dot.className = 'session-tab-dot dot-offline';
|
|
inner.appendChild(dot);
|
|
|
|
const closeBtn = document.createElement('button');
|
|
closeBtn.type = 'button';
|
|
closeBtn.className = 'session-tab-close';
|
|
closeBtn.innerHTML = '<span class="material-icons" style="font-size:14px">close</span>';
|
|
closeBtn.title = t('actions.close', 'Close');
|
|
closeBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
closeSession(deviceId);
|
|
});
|
|
inner.appendChild(closeBtn);
|
|
|
|
tab.appendChild(inner);
|
|
|
|
const glow = document.createElement('div');
|
|
glow.className = 'session-tab-glow';
|
|
glow.setAttribute('aria-hidden', 'true');
|
|
tab.appendChild(glow);
|
|
|
|
tab.addEventListener('click', (e) => {
|
|
if (e.target.closest('.session-tab-close')) return;
|
|
switchSession(deviceId);
|
|
if (window.matchMedia('(hover: none)').matches) {
|
|
collapseExpandedTabBarItems();
|
|
tab.classList.add('is-expanded');
|
|
expandedTabId = deviceId;
|
|
}
|
|
});
|
|
|
|
tabBar.appendChild(tab);
|
|
}
|
|
|
|
function updateTabState(deviceId, state) {
|
|
const tab = findTab(deviceId);
|
|
if (!tab) return;
|
|
tab.classList.remove('status-online', 'status-connecting', 'status-error', 'status-offline');
|
|
tab.classList.add(tabStatusClass(state));
|
|
const dot = tab.querySelector('.session-tab-dot');
|
|
if (!dot) return;
|
|
dot.className = 'session-tab-dot';
|
|
switch (state) {
|
|
case 'streaming': dot.classList.add('dot-online'); break;
|
|
case 'connecting':
|
|
case 'authenticating':
|
|
case 'waiting_password': dot.classList.add('dot-connecting'); break;
|
|
case 'error': dot.classList.add('dot-error'); break;
|
|
default: dot.classList.add('dot-offline'); break;
|
|
}
|
|
}
|
|
|
|
function setActiveTab(deviceId) {
|
|
tabBar.querySelectorAll('.session-tab').forEach((t) => t.classList.remove('active'));
|
|
const tab = findTab(deviceId);
|
|
if (tab) tab.classList.add('active');
|
|
}
|
|
|
|
function findTab(deviceId) {
|
|
return tabBar.querySelector('[data-session-id="' + CSS.escape(deviceId) + '"]');
|
|
}
|
|
|
|
function bindSlimTabBar() {
|
|
if (!sessionTabBarEl) return;
|
|
|
|
['btn-back-devices', 'btn-add-session'].forEach((id) => {
|
|
const btn = document.getElementById(id);
|
|
if (!btn) return;
|
|
btn.addEventListener('click', () => {
|
|
if (window.matchMedia('(hover: none)').matches) {
|
|
collapseExpandedTabBarItems();
|
|
btn.classList.add('is-expanded');
|
|
}
|
|
}, { capture: true });
|
|
});
|
|
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest('#session-tab-bar')) {
|
|
collapseExpandedTabBarItems();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---- Session Lifecycle ----
|
|
|
|
function createSession(deviceId, deviceName, platform) {
|
|
if (sessions.has(deviceId)) {
|
|
switchSession(deviceId);
|
|
return;
|
|
}
|
|
|
|
// Clone template
|
|
const template = document.getElementById('session-panel-template');
|
|
const panel = template.content.firstElementChild.cloneNode(true);
|
|
|
|
// Fill device labels
|
|
panel.querySelector('.session-device-label').textContent = deviceId;
|
|
panel.querySelector('.session-device-name').textContent = deviceName || '';
|
|
panel.querySelector('.session-password-label').textContent =
|
|
(_('remote.enter_password_for') || 'Enter password for') + ' ' + deviceId;
|
|
|
|
viewerContainer.appendChild(panel);
|
|
|
|
const session = new SessionInfo(deviceId, deviceName, panel);
|
|
|
|
// Show HTTP / WebCodecs warning once
|
|
if (!RDVideo.isSupported()) {
|
|
const isInsecure = window.location.protocol === 'http:' &&
|
|
window.location.hostname !== 'localhost' &&
|
|
window.location.hostname !== '127.0.0.1';
|
|
if (isInsecure) showHttpWarningBanner();
|
|
}
|
|
|
|
// Create transport client from saved operator prefs (Best = 60fps).
|
|
wireNewClient(session);
|
|
sessions.set(deviceId, session);
|
|
createTab(deviceId, deviceName, platform);
|
|
switchSession(deviceId);
|
|
|
|
session.client.renderer.resize();
|
|
session.client.connect().catch(err => {
|
|
setSessionStatus(session, 'error', err.message);
|
|
showSessionActions(session);
|
|
});
|
|
}
|
|
|
|
function wireNewClient(session) {
|
|
session.client = createTransportClient(session.canvas, buildClientOpts(session));
|
|
if (typeof session.client.setBackgroundFps === 'function') {
|
|
session.client.setBackgroundFps((session.viewerPrefs || globalViewerPrefs).backgroundFps || 1);
|
|
}
|
|
wireSessionEvents(session);
|
|
wireSessionDomEvents(session);
|
|
attachMobileTouch(session);
|
|
}
|
|
|
|
function switchSession(deviceId) {
|
|
if (!sessions.has(deviceId)) return;
|
|
activeSessionId = deviceId;
|
|
const session = sessions.get(deviceId);
|
|
|
|
// Hide all, show active
|
|
viewerContainer.querySelectorAll('.session-panel').forEach(p => {
|
|
p.style.display = 'none';
|
|
});
|
|
session.panel.style.display = '';
|
|
|
|
setActiveTab(deviceId);
|
|
toolbarDeviceId.textContent = deviceId;
|
|
|
|
if (window.__fileTransferModal?.isOpen()) {
|
|
window.__fileTransferModal.close();
|
|
}
|
|
|
|
// Sync toolbar state
|
|
syncToolbarToSession(session);
|
|
syncToolbarFromSession(session);
|
|
|
|
if (session.state === 'streaming') {
|
|
session.canvas.focus();
|
|
session.client.renderer.resize();
|
|
syncToolbarChrome(session);
|
|
} else {
|
|
syncToolbarChrome(session);
|
|
}
|
|
|
|
syncSessionMediaCapture();
|
|
|
|
if (session.state === 'streaming' && session.client) {
|
|
session.client.setAudioMuted(session.audioMuted);
|
|
}
|
|
syncMobileTouchForActive();
|
|
}
|
|
|
|
function closeSession(deviceId, options) {
|
|
options = options || {};
|
|
const session = sessions.get(deviceId);
|
|
if (!session) {
|
|
const tab = findTab(deviceId);
|
|
if (tab) tab.remove();
|
|
return;
|
|
}
|
|
|
|
try { if (session.client) session.client.disconnect(); } catch { /* ignore */ }
|
|
if (window.__fileTransferModal?.isOpen() &&
|
|
window.__fileTransferModal._session?.deviceId === deviceId) {
|
|
window.__fileTransferModal.close();
|
|
}
|
|
if (session.mediaRecorder && session.mediaRecorder.state === 'recording') {
|
|
try { session.mediaRecorder.stop(); } catch { /* ignore */ }
|
|
}
|
|
|
|
if (session.panel) session.panel.remove();
|
|
const tab = findTab(deviceId);
|
|
if (tab) tab.remove();
|
|
sessions.delete(deviceId);
|
|
|
|
if (expandedTabId === deviceId) expandedTabId = null;
|
|
|
|
if (activeSessionId === deviceId) {
|
|
activeSessionId = null;
|
|
if (sessions.size > 0) {
|
|
switchSession(sessions.keys().next().value);
|
|
} else if (!options.suppressReturn) {
|
|
returnToDevices();
|
|
}
|
|
} else {
|
|
syncSessionMediaCapture();
|
|
}
|
|
}
|
|
|
|
function closeAllSessions() {
|
|
Array.from(sessions.keys()).forEach((id) => closeSession(id, { suppressReturn: true }));
|
|
}
|
|
|
|
function reconnectSession(session) {
|
|
if (session.client) session.client.disconnect();
|
|
session.connectionOverlay.style.display = 'flex';
|
|
session.passwordOverlay.style.display = 'none';
|
|
session.overlayActions.style.display = 'none';
|
|
if (session.cdapFallbackBtn) session.cdapFallbackBtn.style.display = 'none';
|
|
const spinner = session.connectionOverlay.querySelector('.spinner');
|
|
if (spinner) spinner.style.display = 'block';
|
|
session.statusText.textContent = _('remote.connecting');
|
|
|
|
wireNewClient(session);
|
|
session.client.renderer.resize();
|
|
session.client.connect().catch(err => {
|
|
setSessionStatus(session, 'error', err.message);
|
|
showSessionActions(session);
|
|
});
|
|
}
|
|
|
|
function getActiveSession() {
|
|
return activeSessionId ? sessions.get(activeSessionId) : null;
|
|
}
|
|
|
|
// ---- Wire session events ----
|
|
|
|
function wireSessionEvents(session) {
|
|
const c = session.client;
|
|
|
|
c.on('state', (state) => {
|
|
session.state = state;
|
|
updateTabState(session.deviceId, state);
|
|
if (isActive(session)) {
|
|
if (state !== 'streaming') hideToolbarChromeForSession(session);
|
|
syncToolbarToSession(session);
|
|
}
|
|
handleSessionState(session, state);
|
|
});
|
|
|
|
c.on('log', (msg) => {
|
|
if (isActive(session) && (session.state === 'connecting' || session.state === 'authenticating')) {
|
|
session.statusText.textContent = msg;
|
|
}
|
|
});
|
|
|
|
c.on('error', (msg, meta) => {
|
|
setSessionStatus(session, 'error', msg);
|
|
showSessionActions(session);
|
|
if (meta && meta.cdapFallback) showCdapFallback(session);
|
|
if (isActive(session)) setToolbarChromeVisible(false);
|
|
});
|
|
|
|
c.on('cdap_fallback_available', () => {
|
|
showCdapFallback(session);
|
|
});
|
|
|
|
c.on('disconnected', (reason) => {
|
|
setSessionStatus(session, 'info', reason || _('remote.disconnected'));
|
|
showSessionActions(session);
|
|
if (isActive(session)) setToolbarChromeVisible(false);
|
|
if (typeof window.BillingReport !== 'undefined') {
|
|
window.BillingReport.promptAfterSession(session.deviceId, session.deviceName)
|
|
.then((submitted) => {
|
|
if (submitted) showToast(t('commercialization.report.saved', 'Work report saved'), 'success');
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
});
|
|
|
|
c.on('password_required', () => {
|
|
session.connectionOverlay.style.display = 'none';
|
|
session.passwordOverlay.style.display = 'flex';
|
|
session.loginError.style.display = 'none';
|
|
session.passwordInput.value = '';
|
|
if (window.RdClientSecureStore && session.deviceId) {
|
|
window.RdClientSecureStore.loadPeerPassword(session.deviceId).then(function (saved) {
|
|
if (saved) {
|
|
session.passwordInput.value = saved;
|
|
if (session.rememberPeerCheckbox) session.rememberPeerCheckbox.checked = true;
|
|
}
|
|
}).catch(function () { /* ignore */ });
|
|
}
|
|
if (isActive(session)) session.passwordInput.focus();
|
|
if (isActive(session)) setToolbarChromeVisible(false);
|
|
});
|
|
|
|
c.on('login_error', (error) => {
|
|
if (session.state === 'waiting_2fa' || session.tfaOverlay.style.display === 'flex') {
|
|
session.tfaError.textContent = error;
|
|
session.tfaError.style.display = 'block';
|
|
session.tfaInput.value = '';
|
|
if (isActive(session)) session.tfaInput.focus();
|
|
return;
|
|
}
|
|
session.loginError.textContent = error;
|
|
session.loginError.style.display = 'block';
|
|
session.passwordInput.value = '';
|
|
if (isActive(session)) session.passwordInput.focus();
|
|
});
|
|
|
|
c.on('2fa_error', (error) => {
|
|
session.tfaError.textContent = error || (_('remote.2fa_invalid') || 'Invalid code');
|
|
session.tfaError.style.display = 'block';
|
|
session.tfaInput.value = '';
|
|
session.tfaOverlay.style.display = 'flex';
|
|
if (isActive(session)) session.tfaInput.focus();
|
|
if (isActive(session)) setToolbarChromeVisible(false);
|
|
});
|
|
|
|
c.on('2fa_required', () => {
|
|
session.passwordOverlay.style.display = 'none';
|
|
session.connectionOverlay.style.display = 'none';
|
|
session.tfaOverlay.style.display = 'flex';
|
|
session.tfaError.style.display = 'none';
|
|
session.tfaInput.value = '';
|
|
if (isActive(session)) session.tfaInput.focus();
|
|
if (isActive(session)) setToolbarChromeVisible(false);
|
|
});
|
|
|
|
c.on('login_success', () => {
|
|
session.passwordOverlay.style.display = 'none';
|
|
session.tfaOverlay.style.display = 'none';
|
|
session.passwordInput.blur();
|
|
if (window.RdClientSecureStore && session.rememberPeerCheckbox) {
|
|
var pw = session.passwordInput.value;
|
|
if (session.rememberPeerCheckbox.checked && pw) {
|
|
window.RdClientSecureStore.savePeerPassword(session.deviceId, pw).catch(function () { /* ignore */ });
|
|
} else if (!session.rememberPeerCheckbox.checked) {
|
|
window.RdClientSecureStore.clearPeerPassword(session.deviceId).catch(function () { /* ignore */ });
|
|
}
|
|
}
|
|
});
|
|
|
|
c.on('session_start', () => {
|
|
session.connectionOverlay.style.display = 'none';
|
|
session.passwordOverlay.style.display = 'none';
|
|
session.client.renderer.resize();
|
|
if (isActive(session)) {
|
|
applyViewerPrefsToClient(session.client, session.viewerPrefs);
|
|
}
|
|
syncSessionMediaCapture();
|
|
if (isActive(session)) {
|
|
session.canvas.focus();
|
|
syncToolbarChrome(session);
|
|
try { refreshMonitorButton(session); } catch { /* not ready */ }
|
|
}
|
|
if (session.client.video) {
|
|
session.client.video.onAutoplayBlocked = () => {
|
|
if (isActive(session)) showAutoplayOverlay(session);
|
|
};
|
|
}
|
|
if (window.__openFilePanelOnConnect && isActive(session)) {
|
|
window.__openFilePanelOnConnect = false;
|
|
window.__fileTransferModal?.open(session);
|
|
}
|
|
});
|
|
|
|
c.on('stats', (stats) => {
|
|
session.lastStats = stats;
|
|
});
|
|
|
|
c.on('latency', (rtt) => { session.latency = rtt; });
|
|
|
|
c.on('chat', (text) => addChatMessage(session, text, 'received'));
|
|
|
|
// CDAP transport: agent emits `monitors` after `monitor_list`. Show
|
|
// the toolbar dropdown on multi-display agents and refresh contents.
|
|
c.on('monitors', (list) => {
|
|
const btn = document.getElementById('btn-monitors');
|
|
if (btn) btn.style.display = (Array.isArray(list) && list.length > 1) ? '' : 'none';
|
|
if (isActive(session)) {
|
|
try { updateMonitorMenu(); } catch { /* menu not yet built */ }
|
|
}
|
|
});
|
|
|
|
// Native RustDesk transport: the rdclient emits `peer_info` once the
|
|
// login completes. Reveal the monitors dropdown when the peer has more
|
|
// than one display OR supports virtual displays, then rebuild it.
|
|
c.on('peer_info', () => {
|
|
if (isActive(session)) {
|
|
try { refreshMonitorButton(session); } catch { /* not ready */ }
|
|
try { updateWindowsSessionMenu(session); } catch { /* not ready */ }
|
|
}
|
|
});
|
|
c.on('display_switched', () => {
|
|
if (isActive(session)) {
|
|
try { updateMonitorMenu(); } catch { /* menu not yet built */ }
|
|
}
|
|
});
|
|
c.on('virtual_display_toggled', () => {
|
|
if (isActive(session)) {
|
|
try { updateMonitorMenu(); } catch { /* menu not yet built */ }
|
|
}
|
|
});
|
|
|
|
// Security events: show warnings for E2E encryption issues
|
|
c.on('signature_warning', (msg) => {
|
|
console.warn('[Remote] Signature warning:', msg);
|
|
showSecurityWarning(session, msg, 'warning');
|
|
});
|
|
c.on('encryption_warning', (msg) => {
|
|
console.warn('[Remote] Encryption warning:', msg);
|
|
showSecurityWarning(session, msg, 'error');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Show a security warning banner in the session panel
|
|
*/
|
|
function showSecurityWarning(session, message, level) {
|
|
let banner = session.panel.querySelector('.security-warning-banner');
|
|
if (!banner) {
|
|
banner = document.createElement('div');
|
|
banner.className = 'security-warning-banner security-' + level;
|
|
banner.innerHTML = '<span class="material-icons">'
|
|
+ (level === 'error' ? 'lock_open' : 'warning')
|
|
+ '</span> <span class="security-warning-text"></span>'
|
|
+ '<button class="security-warning-dismiss" title="Dismiss">×</button>';
|
|
banner.querySelector('.security-warning-dismiss').addEventListener('click', () => banner.remove());
|
|
session.panel.appendChild(banner);
|
|
}
|
|
banner.querySelector('.security-warning-text').textContent = message;
|
|
}
|
|
|
|
function wireSessionDomEvents(session) {
|
|
session.panel.querySelector('.session-btn-reconnect')
|
|
?.addEventListener('click', () => reconnectSession(session));
|
|
|
|
session.panel.querySelector('.session-btn-cdap-fallback')
|
|
?.addEventListener('click', () => {
|
|
window.location.href = '/remote-cdap/' + encodeURIComponent(session.deviceId);
|
|
});
|
|
|
|
session.panel.querySelector('.session-btn-authenticate')
|
|
?.addEventListener('click', () => {
|
|
const pw = session.passwordInput.value;
|
|
if (!pw) { session.passwordInput.focus(); return; }
|
|
if (session.client) session.client.authenticate(pw);
|
|
});
|
|
|
|
session.passwordInput?.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
session.panel.querySelector('.session-btn-authenticate')?.click();
|
|
}
|
|
});
|
|
|
|
// 2FA verification
|
|
session.panel.querySelector('.session-btn-verify-2fa')
|
|
?.addEventListener('click', () => {
|
|
const code = session.tfaInput.value.replace(/\s/g, '');
|
|
if (!code || code.length !== 6) {
|
|
session.tfaError.textContent = _('remote.2fa_invalid') || 'Enter a valid 6-digit code';
|
|
session.tfaError.style.display = 'block';
|
|
session.tfaInput.focus();
|
|
return;
|
|
}
|
|
session.tfaError.style.display = 'none';
|
|
if (session.client) session.client.submit2FA(code);
|
|
});
|
|
|
|
session.tfaInput?.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
session.panel.querySelector('.session-btn-verify-2fa')?.click();
|
|
}
|
|
});
|
|
|
|
// Only allow digits in 2FA input
|
|
session.tfaInput?.addEventListener('input', () => {
|
|
session.tfaInput.value = session.tfaInput.value.replace(/[^0-9]/g, '');
|
|
});
|
|
|
|
session.panel.querySelector('.session-btn-chat-close')
|
|
?.addEventListener('click', () => {
|
|
session.chatPanel.style.display = 'none';
|
|
document.getElementById('btn-chat')?.classList.remove('active');
|
|
});
|
|
|
|
session.panel.querySelector('.session-btn-chat-send')
|
|
?.addEventListener('click', () => sendChat(session));
|
|
|
|
session.chatInput?.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') sendChat(session);
|
|
e.stopPropagation();
|
|
});
|
|
}
|
|
|
|
// ---- Session state helpers ----
|
|
|
|
function isActive(session) {
|
|
return session.deviceId === activeSessionId;
|
|
}
|
|
|
|
/**
|
|
* Keep keyboard/mouse capture, inbound clipboard, and audio scoped to the
|
|
* active streaming tab so background sessions cannot receive input.
|
|
*/
|
|
function syncSessionMediaCapture() {
|
|
if (typeof window.syncSessionMediaCapture === 'function') {
|
|
window.syncSessionMediaCapture(sessions, activeSessionId);
|
|
}
|
|
}
|
|
|
|
function handleSessionState(session, state) {
|
|
switch (state) {
|
|
case 'connecting':
|
|
session.connectionOverlay.style.display = 'flex';
|
|
session.passwordOverlay.style.display = 'none';
|
|
setSessionStatus(session, 'loading', _('remote.connecting'));
|
|
if (isActive(session)) setToolbarChromeVisible(false);
|
|
break;
|
|
case 'streaming':
|
|
session.connectionOverlay.style.display = 'none';
|
|
session.passwordOverlay.style.display = 'none';
|
|
session.panel.classList.add('streaming');
|
|
syncSessionMediaCapture();
|
|
attachMobileTouch(session);
|
|
syncMobileTouchForActive();
|
|
if (isActive(session)) syncToolbarChrome(session);
|
|
break;
|
|
case 'disconnected':
|
|
case 'error':
|
|
session.panel.classList.remove('streaming');
|
|
session.connectionOverlay.style.display = 'flex';
|
|
setSessionStatus(session, state === 'error' ? 'error' : 'info',
|
|
state === 'error' ? _('remote.error') : _('remote.disconnected'));
|
|
showSessionActions(session);
|
|
if (isActive(session)) setToolbarChromeVisible(false);
|
|
break;
|
|
}
|
|
}
|
|
|
|
function setSessionStatus(session, type, text) {
|
|
session.statusText.textContent = text;
|
|
const statusEl = session.connectionOverlay.querySelector('.overlay-status');
|
|
if (statusEl) statusEl.className = 'overlay-status ' + type;
|
|
}
|
|
|
|
function showSessionActions(session) {
|
|
session.overlayActions.style.display = 'flex';
|
|
const spinner = session.connectionOverlay.querySelector('.spinner');
|
|
if (spinner) spinner.style.display = 'none';
|
|
}
|
|
|
|
function showCdapFallback(session) {
|
|
if (session.cdapFallbackBtn) session.cdapFallbackBtn.style.display = 'inline-flex';
|
|
}
|
|
|
|
function syncToolbarToSession(session) {
|
|
const isStreaming = session.state === 'streaming';
|
|
toolbar.classList.toggle('toolbar-streaming', isStreaming);
|
|
if (toolbarStatus) toolbarStatus.style.display = 'none';
|
|
document.querySelector('.toolbar-status-sep')?.style.setProperty('display', 'none');
|
|
|
|
if (isStreaming) {
|
|
syncToolbarChrome(session);
|
|
}
|
|
|
|
// Audio icon
|
|
const audioBtn = document.getElementById('btn-audio');
|
|
if (audioBtn) {
|
|
audioBtn.querySelector('.material-icons').textContent =
|
|
session.audioMuted ? 'volume_off' : 'volume_up';
|
|
}
|
|
|
|
// Recording icon
|
|
const recBtn = document.getElementById('btn-record');
|
|
if (recBtn) {
|
|
recBtn.classList.toggle('recording',
|
|
session.mediaRecorder && session.mediaRecorder.state === 'recording');
|
|
}
|
|
}
|
|
|
|
function syncToolbarFromSession(session) {
|
|
if (!session || !session.viewerPrefs) return;
|
|
const p = session.viewerPrefs;
|
|
document.querySelectorAll('.quality-item').forEach(function (btn) {
|
|
btn.classList.toggle('active', btn.dataset.quality === p.quality);
|
|
});
|
|
document.querySelectorAll('.scale-item').forEach(function (btn) {
|
|
btn.classList.toggle('active', btn.dataset.scale === p.scale);
|
|
});
|
|
document.querySelectorAll('.codec-item').forEach(function (btn) {
|
|
btn.classList.toggle('active', btn.dataset.codec === p.codec);
|
|
});
|
|
document.querySelectorAll('.keyboard-mode-item').forEach(function (btn) {
|
|
btn.classList.toggle('active', btn.dataset.keyboardMode === (p.keyboardMode || 'Auto'));
|
|
});
|
|
}
|
|
|
|
// ---- Autoplay blocked overlay ----
|
|
|
|
function showAutoplayOverlay(session) {
|
|
let overlay = session.panel.querySelector('.autoplay-overlay');
|
|
if (!overlay) {
|
|
overlay = document.createElement('div');
|
|
overlay.className = 'viewer-overlay autoplay-overlay';
|
|
overlay.innerHTML = `
|
|
<div class="overlay-card autoplay-card">
|
|
<div class="overlay-icon"><span class="material-icons">play_circle</span></div>
|
|
<h2 class="overlay-title">${_('remote.click_to_start') || 'Click to Start'}</h2>
|
|
<p class="overlay-hint">${_('remote.autoplay_blocked') || 'Browser requires user interaction to start video and audio playback.'}</p>
|
|
<button class="btn btn-primary btn-full autoplay-start-btn">
|
|
<span class="material-icons">play_arrow</span>
|
|
${_('remote.start_playback') || 'Start Playback'}
|
|
</button>
|
|
</div>`;
|
|
session.panel.appendChild(overlay);
|
|
}
|
|
overlay.style.display = 'flex';
|
|
const dismiss = () => {
|
|
overlay.style.display = 'none';
|
|
if (session.client && session.client.video) session.client.video.retryPlay();
|
|
if (session.client && session.client.audio && session.client.audio.audioCtx &&
|
|
session.client.audio.audioCtx.state === 'suspended') {
|
|
session.client.audio.audioCtx.resume();
|
|
}
|
|
};
|
|
overlay.querySelector('.autoplay-start-btn')?.addEventListener('click', dismiss, { once: true });
|
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) dismiss(); }, { once: true });
|
|
}
|
|
|
|
// ---- Chat ----
|
|
|
|
function sendChat(session) {
|
|
const text = session.chatInput?.value?.trim();
|
|
if (!text || !session.client) return;
|
|
session.client.sendChat(text);
|
|
addChatMessage(session, text, 'sent');
|
|
session.chatInput.value = '';
|
|
}
|
|
|
|
function addChatMessage(session, text, type) {
|
|
const div = document.createElement('div');
|
|
div.className = 'chat-msg ' + type;
|
|
div.textContent = text;
|
|
session.chatMessages?.appendChild(div);
|
|
if (session.chatMessages) session.chatMessages.scrollTop = session.chatMessages.scrollHeight;
|
|
}
|
|
|
|
// ---- Shared Toolbar Handlers ----
|
|
// All toolbar buttons delegate to the active session's client
|
|
|
|
function withClient(fn) {
|
|
const session = getActiveSession();
|
|
if (session && session.client) fn(session.client, session);
|
|
}
|
|
|
|
function toggleViewerShellFullscreen() {
|
|
const target = viewerShell || viewerContainer;
|
|
if (!target) return;
|
|
if (!document.fullscreenElement) {
|
|
target.requestFullscreen().catch(function () { /* ignore */ });
|
|
if (navigator.keyboard && navigator.keyboard.lock) {
|
|
navigator.keyboard.lock().catch(function () { /* permission / unsupported */ });
|
|
}
|
|
} else {
|
|
if (navigator.keyboard && navigator.keyboard.unlock) {
|
|
navigator.keyboard.unlock();
|
|
}
|
|
document.exitFullscreen().catch(function () { /* ignore */ });
|
|
}
|
|
}
|
|
|
|
function applyTransportCapabilities() {
|
|
const fileBtn = document.getElementById('btn-file-transfer');
|
|
if (!fileBtn) return;
|
|
if (getTransportName() === 'cdap') {
|
|
fileBtn.disabled = true;
|
|
fileBtn.classList.add('disabled');
|
|
fileBtn.title = t('remote.file_transfer_unavailable_cdap',
|
|
'File transfer is not available for CDAP snapshot sessions.');
|
|
}
|
|
}
|
|
|
|
// Disconnect
|
|
document.getElementById('btn-disconnect')?.addEventListener('click', () => {
|
|
withClient(c => c.disconnect());
|
|
});
|
|
|
|
// Audio toggle
|
|
document.getElementById('btn-audio')?.addEventListener('click', function () {
|
|
const session = getActiveSession();
|
|
if (!session) return;
|
|
session.audioMuted = !session.audioMuted;
|
|
if (session.client) session.client.setAudioMuted(session.audioMuted);
|
|
this.querySelector('.material-icons').textContent =
|
|
session.audioMuted ? 'volume_off' : 'volume_up';
|
|
});
|
|
|
|
// Ctrl+Alt+Del
|
|
document.getElementById('btn-cad')?.addEventListener('click', () => {
|
|
withClient(c => c.sendCtrlAltDel());
|
|
closeAllDropdowns();
|
|
});
|
|
|
|
// Lock Screen
|
|
document.getElementById('btn-lock')?.addEventListener('click', () => {
|
|
withClient(c => c.sendLockScreen());
|
|
closeAllDropdowns();
|
|
});
|
|
|
|
// Restart Remote
|
|
document.getElementById('btn-restart-remote')?.addEventListener('click', () => {
|
|
withClient((c) => {
|
|
if (confirm(_('remote.confirm_restart'))) c.sendRestartRemoteDevice();
|
|
});
|
|
closeAllDropdowns();
|
|
});
|
|
|
|
// Refresh Screen
|
|
document.getElementById('btn-refresh-screen')?.addEventListener('click', () => {
|
|
withClient(c => c.sendRefreshScreen());
|
|
closeAllDropdowns();
|
|
});
|
|
|
|
// Reset keyboard (release stuck modifiers on remote)
|
|
document.getElementById('btn-reset-keyboard')?.addEventListener('click', () => {
|
|
withClient(c => {
|
|
if (typeof c.resetKeyboard === 'function') {
|
|
c.resetKeyboard();
|
|
} else if (c.input && typeof c.input.resetKeyboard === 'function') {
|
|
c.input.resetKeyboard();
|
|
}
|
|
});
|
|
showToast(_('remote.reset_keyboard_done') || 'Keyboard state reset on remote', 'success');
|
|
closeAllDropdowns();
|
|
});
|
|
|
|
// Clipboard Paste
|
|
document.getElementById('btn-clipboard-paste')?.addEventListener('click', async () => {
|
|
const session = getActiveSession();
|
|
if (!session || !session.client) return;
|
|
if (session.client.viewOnly) {
|
|
showToast(_('remote.view_only') || 'View only mode', 'warning');
|
|
closeAllDropdowns();
|
|
return;
|
|
}
|
|
try {
|
|
const text = await navigator.clipboard.readText();
|
|
if (text) {
|
|
session.client.sendClipboard(text);
|
|
} else {
|
|
showToast(_('remote.clipboard_empty') || 'Clipboard is empty', 'warning');
|
|
}
|
|
} catch {
|
|
// Clipboard API requires HTTPS or user permission
|
|
showToast(_('remote.clipboard_denied') || 'Clipboard access denied. HTTPS required or permission not granted.', 'error');
|
|
}
|
|
closeAllDropdowns();
|
|
});
|
|
|
|
// Block Input toggle
|
|
setupToggle('btn-block-input', (on) => withClient(c => c.setBlockInput(on)));
|
|
|
|
// Quality items — map HTML data-quality to client presets (includes FPS adjustment)
|
|
var qualityToPreset = { 'Best': 'best', 'Balanced': 'balanced', 'Low': 'speed' };
|
|
document.querySelectorAll('.quality-item').forEach(btn => {
|
|
btn.addEventListener('click', function () {
|
|
var preset = qualityToPreset[this.dataset.quality] || 'balanced';
|
|
var session = getActiveSession();
|
|
withClient(c => c.setQualityPreset(preset));
|
|
if (session) updateSessionViewerPref(session, { quality: this.dataset.quality });
|
|
document.querySelectorAll('.quality-item').forEach(b => b.classList.remove('active'));
|
|
this.classList.add('active');
|
|
});
|
|
});
|
|
|
|
// Keyboard mode (Legacy / Map / Auto)
|
|
document.querySelectorAll('.keyboard-mode-item').forEach(btn => {
|
|
btn.addEventListener('click', function () {
|
|
var mode = this.dataset.keyboardMode || 'Auto';
|
|
var session = getActiveSession();
|
|
withClient(c => {
|
|
if (typeof c.setKeyboardMode === 'function') c.setKeyboardMode(mode);
|
|
});
|
|
if (session) updateSessionViewerPref(session, { keyboardMode: mode });
|
|
document.querySelectorAll('.keyboard-mode-item').forEach(b => b.classList.remove('active'));
|
|
this.classList.add('active');
|
|
});
|
|
});
|
|
|
|
// Scale items
|
|
document.querySelectorAll('.scale-item').forEach(btn => {
|
|
btn.addEventListener('click', function () {
|
|
var session = getActiveSession();
|
|
withClient(c => c.setScaleMode(this.dataset.scale));
|
|
if (session) updateSessionViewerPref(session, { scale: this.dataset.scale });
|
|
document.querySelectorAll('.scale-item').forEach(b => b.classList.remove('active'));
|
|
this.classList.add('active');
|
|
});
|
|
});
|
|
|
|
// Codec items — request the peer to (re)encode with a specific codec (GPU-friendly).
|
|
document.querySelectorAll('.codec-item').forEach(btn => {
|
|
btn.addEventListener('click', function () {
|
|
if (this.classList.contains('disabled')) return;
|
|
var session = getActiveSession();
|
|
withClient(c => c.setCodec(this.dataset.codec));
|
|
if (session) updateSessionViewerPref(session, { codec: this.dataset.codec });
|
|
document.querySelectorAll('.codec-item').forEach(b => b.classList.remove('active'));
|
|
this.classList.add('active');
|
|
});
|
|
});
|
|
|
|
// Disable codec options the browser's decoder cannot handle.
|
|
(function probeCodecMenu() {
|
|
if (!window.RDVideo || typeof RDVideo.getSupportedCodecs !== 'function') return;
|
|
RDVideo.getSupportedCodecs().then(function (support) {
|
|
document.querySelectorAll('.codec-item').forEach(function (btn) {
|
|
var codec = (btn.dataset.codec || '').toLowerCase();
|
|
if (codec === 'auto') return; // always allowed
|
|
if (support && support[codec] === false) {
|
|
btn.classList.add('disabled');
|
|
btn.setAttribute('disabled', 'disabled');
|
|
btn.title = 'Not supported by this browser';
|
|
}
|
|
});
|
|
}).catch(function () { /* ignore */ });
|
|
})();
|
|
|
|
// Toggle helpers
|
|
setupToggle('btn-show-cursor', (on) => withClient(c => c.setShowRemoteCursor(on)));
|
|
setupToggle('btn-lock-session', (on) => withClient(c => c.setLockAfterSession(on)));
|
|
setupToggle('btn-privacy-mode', (on) => withClient(c => c.setPrivacyMode(on)));
|
|
setupToggle('btn-disable-clipboard', (on) => withClient(c => c.setDisableClipboard(on)));
|
|
|
|
// Dropdown toggles
|
|
document.getElementById('btn-actions')?.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
closeAllDropdowns('actions-menu');
|
|
document.getElementById('actions-menu')?.classList.toggle('open');
|
|
});
|
|
|
|
document.getElementById('btn-display')?.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
closeAllDropdowns('display-menu');
|
|
document.getElementById('display-menu')?.classList.toggle('open');
|
|
});
|
|
|
|
document.getElementById('btn-monitors')?.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
closeAllDropdowns('monitors-menu');
|
|
updateMonitorMenu();
|
|
document.getElementById('monitors-menu')?.classList.toggle('open');
|
|
});
|
|
|
|
function refreshMonitorButton(session) {
|
|
session = session || getActiveSession();
|
|
if (!session || !session.client) return;
|
|
const btn = document.getElementById('btn-monitors');
|
|
if (!btn) return;
|
|
let monitors = [];
|
|
let vd = { supported: false };
|
|
try { monitors = session.client.getMonitors() || []; } catch { monitors = []; }
|
|
try {
|
|
vd = (typeof session.client.getVirtualDisplaySupport === 'function')
|
|
? session.client.getVirtualDisplaySupport()
|
|
: { supported: false };
|
|
} catch { vd = { supported: false }; }
|
|
const useStrip = monitors.length >= 2 && monitors.length <= 4;
|
|
const showDropdown = (monitors.length > 1 && !useStrip) || (vd && vd.supported);
|
|
btn.style.display = showDropdown ? '' : 'none';
|
|
updateMonitorStrip(session, monitors, useStrip);
|
|
if (showDropdown) updateMonitorMenu();
|
|
}
|
|
|
|
function updateMonitorStrip(session, monitors, useStrip) {
|
|
const strip = document.getElementById('toolbar-monitor-strip');
|
|
if (!strip) return;
|
|
strip.innerHTML = '';
|
|
if (!useStrip || !session || !session.client || monitors.length < 2) {
|
|
strip.hidden = true;
|
|
return;
|
|
}
|
|
strip.hidden = false;
|
|
monitors.forEach(function (m, i) {
|
|
const item = document.createElement('button');
|
|
item.type = 'button';
|
|
item.className = 'toolbar-monitor-btn' + (m.current ? ' active' : '');
|
|
item.title = m.name || ('Monitor ' + (i + 1));
|
|
item.textContent = String(i + 1);
|
|
item.addEventListener('click', function (e) {
|
|
e.stopPropagation();
|
|
session.client.switchMonitor(m.idx);
|
|
strip.querySelectorAll('.toolbar-monitor-btn').forEach(function (b) {
|
|
b.classList.remove('active');
|
|
});
|
|
item.classList.add('active');
|
|
updateMonitorMenu();
|
|
});
|
|
strip.appendChild(item);
|
|
});
|
|
}
|
|
|
|
function updateWindowsSessionMenu(session) {
|
|
session = session || getActiveSession();
|
|
const menu = document.getElementById('actions-menu');
|
|
if (!menu || !session || !session.client) return;
|
|
menu.querySelectorAll('.windows-session-item, .windows-session-sep').forEach(function (el) {
|
|
el.remove();
|
|
});
|
|
let ws = { sessions: [], currentSid: 0 };
|
|
try {
|
|
ws = (typeof session.client.getWindowsSessions === 'function')
|
|
? session.client.getWindowsSessions()
|
|
: { sessions: [], currentSid: 0 };
|
|
} catch { ws = { sessions: [], currentSid: 0 }; }
|
|
if (!ws.sessions || ws.sessions.length < 2) return;
|
|
|
|
const sep = document.createElement('div');
|
|
sep.className = 'dropdown-separator windows-session-sep';
|
|
menu.appendChild(sep);
|
|
|
|
const label = document.createElement('div');
|
|
label.className = 'dropdown-label windows-session-sep';
|
|
label.textContent = t('remote.windows_sessions', 'Windows sessions');
|
|
menu.appendChild(label);
|
|
|
|
ws.sessions.forEach(function (s) {
|
|
const item = document.createElement('button');
|
|
item.type = 'button';
|
|
item.className = 'dropdown-item windows-session-item' +
|
|
(s.sid === ws.currentSid ? ' active' : '');
|
|
item.innerHTML = '<span class="material-icons">desktop_windows</span> ' + escapeHtml(s.name);
|
|
item.addEventListener('click', function () {
|
|
session.client.selectWindowsSession(s.sid);
|
|
menu.querySelectorAll('.windows-session-item').forEach(function (b) {
|
|
b.classList.remove('active');
|
|
});
|
|
item.classList.add('active');
|
|
closeAllDropdowns();
|
|
});
|
|
menu.appendChild(item);
|
|
});
|
|
}
|
|
|
|
function updateMonitorMenu() {
|
|
const session = getActiveSession();
|
|
if (!session || !session.client) return;
|
|
const monitors = session.client.getMonitors() || [];
|
|
const menu = document.getElementById('monitors-menu');
|
|
if (!menu) return;
|
|
|
|
let vd = { supported: false };
|
|
try {
|
|
vd = (typeof session.client.getVirtualDisplaySupport === 'function')
|
|
? session.client.getVirtualDisplaySupport()
|
|
: { supported: false };
|
|
} catch { vd = { supported: false }; }
|
|
|
|
// Nothing meaningful to show: a single physical display and no virtual
|
|
// display support.
|
|
if (monitors.length < 2 && !(vd && vd.supported)) return;
|
|
|
|
const label = menu.querySelector('.dropdown-label');
|
|
menu.innerHTML = '';
|
|
if (label) menu.appendChild(label);
|
|
|
|
const listWrap = document.createElement('div');
|
|
listWrap.className = 'monitors-menu-list';
|
|
|
|
// Physical monitors
|
|
monitors.forEach(m => {
|
|
const item = document.createElement('button');
|
|
item.className = 'dropdown-item monitor-item' + (m.current ? ' active' : '');
|
|
item.dataset.idx = m.idx;
|
|
const res = (m.width && m.height)
|
|
? '<span class="monitor-item-meta">' + m.width + '\u00d7' + m.height + '</span>'
|
|
: '';
|
|
item.innerHTML = '<span class="material-icons">' +
|
|
(m.primary ? 'desktop_windows' : 'monitor') +
|
|
'</span><span class="monitor-item-text">' +
|
|
'<span class="monitor-item-name">' + escapeHtml(m.name) + '</span>' +
|
|
res +
|
|
'</span>';
|
|
item.addEventListener('click', () => {
|
|
session.client.switchMonitor(m.idx);
|
|
menu.querySelectorAll('.monitor-item').forEach(i => i.classList.remove('active'));
|
|
item.classList.add('active');
|
|
refreshMonitorButton(session);
|
|
});
|
|
listWrap.appendChild(item);
|
|
});
|
|
|
|
menu.appendChild(listWrap);
|
|
|
|
// Virtual displays (RustDesk IDD / Amyuni IDD)
|
|
if (vd && vd.supported) {
|
|
const vdWrap = document.createElement('div');
|
|
vdWrap.className = 'monitors-menu-vd';
|
|
|
|
const vdLabel = document.createElement('div');
|
|
vdLabel.className = 'dropdown-label';
|
|
vdLabel.textContent = t('remote.virtual_displays', 'Virtual displays');
|
|
vdWrap.appendChild(vdLabel);
|
|
|
|
if (vd.impl === 'rustdesk_idd') {
|
|
const active = Array.isArray(vd.rustdeskDisplays) ? vd.rustdeskDisplays : [];
|
|
for (let i = 0; i < 4; i++) {
|
|
const idx = i + 1;
|
|
const on = active.indexOf(idx) !== -1;
|
|
const item = document.createElement('button');
|
|
item.className = 'dropdown-item virtual-display-item' + (on ? ' active' : '');
|
|
item.innerHTML = '<span class="material-icons">' +
|
|
(on ? 'check_box' : 'check_box_outline_blank') + '</span> ' +
|
|
escapeHtml(t('remote.virtual_display', 'Virtual display') + ' ' + idx);
|
|
item.addEventListener('click', () => {
|
|
session.client.toggleVirtualDisplay(idx, !on);
|
|
});
|
|
vdWrap.appendChild(item);
|
|
}
|
|
} else if (vd.impl === 'amyuni_idd') {
|
|
const count = (typeof vd.amyuniCount === 'number') ? vd.amyuniCount : 0;
|
|
const row = document.createElement('div');
|
|
row.className = 'virtual-display-counter';
|
|
|
|
const minus = document.createElement('button');
|
|
minus.className = 'vd-count-btn';
|
|
minus.innerHTML = '<span class="material-icons">remove</span>';
|
|
minus.disabled = count <= 0;
|
|
minus.addEventListener('click', (ev) => {
|
|
ev.stopPropagation();
|
|
session.client.toggleVirtualDisplay(0, false);
|
|
});
|
|
|
|
const num = document.createElement('span');
|
|
num.className = 'vd-count-value';
|
|
num.textContent = String(count);
|
|
|
|
const plus = document.createElement('button');
|
|
plus.className = 'vd-count-btn';
|
|
plus.innerHTML = '<span class="material-icons">add</span>';
|
|
plus.disabled = count >= 4;
|
|
plus.addEventListener('click', (ev) => {
|
|
ev.stopPropagation();
|
|
session.client.toggleVirtualDisplay(0, true);
|
|
});
|
|
|
|
row.appendChild(minus);
|
|
row.appendChild(num);
|
|
row.appendChild(plus);
|
|
vdWrap.appendChild(row);
|
|
}
|
|
|
|
// Plug out all
|
|
const plugOut = document.createElement('button');
|
|
plugOut.className = 'dropdown-item virtual-display-item';
|
|
plugOut.innerHTML = '<span class="material-icons">power_off</span> ' +
|
|
escapeHtml(t('remote.plug_out_all', 'Plug out all'));
|
|
plugOut.addEventListener('click', () => {
|
|
session.client.toggleVirtualDisplay(-1, false);
|
|
});
|
|
vdWrap.appendChild(plugOut);
|
|
menu.appendChild(vdWrap);
|
|
}
|
|
}
|
|
|
|
// Chat toggle
|
|
document.getElementById('btn-chat')?.addEventListener('click', function () {
|
|
const session = getActiveSession();
|
|
if (!session) return;
|
|
const isOpen = session.chatPanel.style.display !== 'none';
|
|
session.chatPanel.style.display = isOpen ? 'none' : 'flex';
|
|
this.classList.toggle('active', !isOpen);
|
|
if (!isOpen && session.chatInput) session.chatInput.focus();
|
|
});
|
|
|
|
// File transfer modal toggle
|
|
document.getElementById('btn-file-transfer')?.addEventListener('click', function () {
|
|
const session = getActiveSession();
|
|
if (!session || !session.client?.fileTransfer) return;
|
|
if (getTransportName() === 'cdap') return;
|
|
const modal = window.__fileTransferModal;
|
|
if (!modal) return;
|
|
if (modal.isOpen()) {
|
|
modal.close();
|
|
} else {
|
|
modal.open(session);
|
|
}
|
|
});
|
|
|
|
// Recording
|
|
document.getElementById('btn-record')?.addEventListener('click', function () {
|
|
const session = getActiveSession();
|
|
if (!session) return;
|
|
|
|
if (session.mediaRecorder && session.mediaRecorder.state === 'recording') {
|
|
session.mediaRecorder.stop();
|
|
this.classList.remove('recording');
|
|
} else if (window.__capabilities && window.__capabilities.transport === 'mesh') {
|
|
session.meshServerRecord = !session.meshServerRecord;
|
|
this.classList.toggle('recording', session.meshServerRecord);
|
|
Notifications.info(session.meshServerRecord
|
|
? (_('mesh.recording_server_on') || 'Server recording enabled — reconnecting…')
|
|
: (_('mesh.recording_server_off') || 'Server recording disabled — reconnecting…'));
|
|
try { session.client.disconnect(); } catch { /* ignore */ }
|
|
wireNewClient(session);
|
|
session.client.connect().catch((err) => setSessionStatus(session, 'error', err.message));
|
|
} else {
|
|
try {
|
|
const stream = session.canvas.captureStream(30);
|
|
session.recordedChunks = [];
|
|
const mimeTypes = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'];
|
|
let mimeType = '';
|
|
for (const mt of mimeTypes) {
|
|
if (MediaRecorder.isTypeSupported(mt)) { mimeType = mt; break; }
|
|
}
|
|
session.mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : {});
|
|
session.mediaRecorder.ondataavailable = (e) => {
|
|
if (e.data.size > 0) session.recordedChunks.push(e.data);
|
|
};
|
|
session.mediaRecorder.onstop = () => {
|
|
const blob = new Blob(session.recordedChunks, { type: mimeType || 'video/webm' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'betterdesk-' + session.deviceId + '-' + Date.now() + '.webm';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
session.mediaRecorder = null;
|
|
};
|
|
session.mediaRecorder.start(1000);
|
|
this.classList.add('recording');
|
|
} catch (err) {
|
|
console.warn('[Remote] Recording not supported:', err);
|
|
}
|
|
}
|
|
});
|
|
|
|
// View Only toggle
|
|
document.getElementById('btn-viewonly')?.addEventListener('click', function () {
|
|
const isViewOnly = !this.classList.contains('active');
|
|
this.classList.toggle('active', isViewOnly);
|
|
withClient(c => c.setViewOnly(isViewOnly));
|
|
syncMobileTouchForActive();
|
|
});
|
|
|
|
// Pin Toolbar toggle — keeps the expanded action pill open
|
|
document.getElementById('btn-pin')?.addEventListener('click', function () {
|
|
if (toolbarNotchSlot?.classList.contains('toolbar-chrome-hidden')) return;
|
|
toolbarPinned = !toolbarPinned;
|
|
toolbar.classList.toggle('pinned', toolbarPinned);
|
|
this.classList.toggle('active', toolbarPinned);
|
|
if (toolbarPinned) expandToolbar();
|
|
});
|
|
|
|
// ---- Compact handle: fullscreen (drawer toggle is on the handle itself) ----
|
|
document.getElementById('btn-handle-fullscreen')?.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
toggleViewerShellFullscreen();
|
|
});
|
|
|
|
// ---- Back to devices — close all sessions first ----
|
|
document.getElementById('btn-back-devices')?.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
closeAllSessions();
|
|
returnToDevices();
|
|
});
|
|
|
|
function closeAllDropdowns(exceptId) {
|
|
document.querySelectorAll('.toolbar-dropdown-menu.open').forEach(m => {
|
|
if (m.id !== exceptId) m.classList.remove('open');
|
|
});
|
|
}
|
|
|
|
function setupToggle(btnId, onChange) {
|
|
document.getElementById(btnId)?.addEventListener('click', function () {
|
|
const active = this.dataset.active !== 'true';
|
|
this.dataset.active = active.toString();
|
|
onChange(active);
|
|
});
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str || '';
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Close dropdowns on outside click
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest('.toolbar-dropdown')) {
|
|
closeAllDropdowns();
|
|
}
|
|
});
|
|
|
|
// Fullscreen handler
|
|
document.addEventListener('fullscreenchange', () => {
|
|
const fsIcon = document.fullscreenElement ? 'fullscreen_exit' : 'fullscreen';
|
|
const handleIcon = document.getElementById('btn-handle-fullscreen')?.querySelector('.material-icons');
|
|
if (handleIcon) handleIcon.textContent = fsIcon;
|
|
setTimeout(() => {
|
|
const session = getActiveSession();
|
|
if (session && session.client && session.client.renderer) session.client.renderer.resize();
|
|
}, 100);
|
|
});
|
|
|
|
// Escape to show toolbar; F11 toggles viewer shell fullscreen
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'F11') {
|
|
e.preventDefault();
|
|
toggleViewerShellFullscreen();
|
|
return;
|
|
}
|
|
if (e.key === 'Escape' && !document.fullscreenElement) showToolbar();
|
|
});
|
|
|
|
// Window resize
|
|
window.addEventListener('resize', () => {
|
|
const session = getActiveSession();
|
|
if (session && session.client && session.client.renderer) session.client.renderer.resize();
|
|
});
|
|
|
|
// ---- Session picker (address book) ----
|
|
|
|
let sessionPicker = null;
|
|
let sessionPickerLoaded = false;
|
|
const sessionPickerBackdrop = document.getElementById('session-picker-backdrop');
|
|
|
|
function openSessionPicker() {
|
|
if (!sessionPickerBackdrop) return;
|
|
sessionPickerBackdrop.hidden = false;
|
|
sessionPickerBackdrop.classList.add('open');
|
|
if (!sessionPickerLoaded && sessionPicker) {
|
|
sessionPicker.loadAll(false).then(() => { sessionPickerLoaded = true; });
|
|
} else if (sessionPicker) {
|
|
sessionPicker.renderDevices();
|
|
}
|
|
document.getElementById('session-picker-search')?.focus();
|
|
}
|
|
|
|
function closeSessionPicker() {
|
|
if (!sessionPickerBackdrop) return;
|
|
sessionPickerBackdrop.classList.remove('open');
|
|
sessionPickerBackdrop.hidden = true;
|
|
}
|
|
|
|
function toggleSessionPicker() {
|
|
if (sessionPickerBackdrop?.classList.contains('open')) closeSessionPicker();
|
|
else openSessionPicker();
|
|
}
|
|
|
|
function initSessionPicker() {
|
|
if (!window.RemoteAddressBook || !document.getElementById('session-picker-panel')) return;
|
|
|
|
sessionPicker = window.RemoteAddressBook.create({
|
|
classPrefix: 'rd-picker',
|
|
compact: true,
|
|
ids: {
|
|
sidebar: 'session-picker-sidebar',
|
|
contentRoot: 'session-picker-content',
|
|
foldersCustom: 'session-picker-folders-custom',
|
|
groups: 'session-picker-groups',
|
|
groupsEmpty: 'session-picker-groups-empty',
|
|
tags: 'session-picker-tags',
|
|
tagsEmpty: 'session-picker-tags-empty',
|
|
grid: 'session-picker-grid',
|
|
tableWrap: 'session-picker-table-wrap',
|
|
tbody: 'session-picker-tbody',
|
|
loading: 'session-picker-loading',
|
|
error: 'session-picker-error',
|
|
errorText: 'session-picker-error-text',
|
|
empty: 'session-picker-empty',
|
|
search: 'session-picker-search',
|
|
sectionTitle: 'session-picker-section-title',
|
|
deviceCount: 'session-picker-count',
|
|
countAll: 'rd-picker-count-all',
|
|
countUnassigned: 'rd-picker-count-unassigned',
|
|
viewGrid: 'session-picker-view-grid',
|
|
viewList: 'session-picker-view-list',
|
|
refreshBtn: 'session-picker-refresh',
|
|
retry: 'session-picker-retry',
|
|
quickId: 'session-picker-quick-id'
|
|
},
|
|
getConnectedIds: function () {
|
|
return new Set(sessions.keys());
|
|
},
|
|
onConnect: function (deviceId, deviceName) {
|
|
closeSessionPicker();
|
|
createSession(deviceId, deviceName || '');
|
|
if (sessionPicker) sessionPicker.renderDevices();
|
|
}
|
|
});
|
|
|
|
sessionPicker.bindUi(document.getElementById('session-picker-panel'));
|
|
|
|
document.getElementById('btn-add-session')?.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
toggleSessionPicker();
|
|
});
|
|
|
|
document.getElementById('session-picker-close')?.addEventListener('click', closeSessionPicker);
|
|
|
|
document.getElementById('session-picker-quick-btn')?.addEventListener('click', () => {
|
|
if (sessionPicker?.quickConnect('session-picker-quick-id')) {
|
|
closeSessionPicker();
|
|
}
|
|
});
|
|
|
|
document.getElementById('session-picker-quick-id')?.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
document.getElementById('session-picker-quick-btn')?.click();
|
|
}
|
|
});
|
|
|
|
sessionPickerBackdrop?.addEventListener('click', (e) => {
|
|
if (e.target === sessionPickerBackdrop) closeSessionPicker();
|
|
});
|
|
|
|
const sessionPickerPanel = document.getElementById('session-picker-panel');
|
|
sessionPickerPanel?.addEventListener('click', (e) => e.stopPropagation());
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && sessionPickerBackdrop?.classList.contains('open')) {
|
|
closeSessionPicker();
|
|
}
|
|
});
|
|
}
|
|
|
|
bindSlimTabBar();
|
|
initSessionPicker();
|
|
|
|
// ---- HTTP Warning Banner ----
|
|
|
|
let httpWarningShown = false;
|
|
function showHttpWarningBanner() {
|
|
if (httpWarningShown) return;
|
|
httpWarningShown = true;
|
|
const banner = document.createElement('div');
|
|
banner.className = 'http-warning-banner';
|
|
banner.innerHTML = '<span class="material-icons">warning</span> ' +
|
|
'<span>' + (_('remote.http_warning') || 'HTTP mode: limited to H.264 software decode (~15 FPS). Use HTTPS for full performance (WebCodecs, VP9, 60 FPS).') + '</span>' +
|
|
'<button class="http-warning-dismiss" title="Dismiss">×</button>';
|
|
document.body.appendChild(banner);
|
|
banner.querySelector('.http-warning-dismiss').addEventListener('click', () => banner.remove());
|
|
}
|
|
|
|
// ---- Translation helper fallback ----
|
|
if (typeof window._ === 'undefined') {
|
|
window._ = function (key) {
|
|
const parts = key.split('.');
|
|
let val = window.BetterDesk?.translations;
|
|
for (const p of parts) {
|
|
if (!val) return key;
|
|
val = val[p];
|
|
}
|
|
return val || key;
|
|
};
|
|
}
|
|
|
|
// ---- Initialize ----
|
|
|
|
// ---- Mobile / tablet rdclient ----
|
|
const touchHandlers = new Map();
|
|
let mobileTouchMode = 'direct';
|
|
|
|
function attachMobileTouch(session) {
|
|
if (!window.RdClientMobile || !window.RdClientMobile.isMobileRdClient()) return;
|
|
if (typeof RDTouch !== 'function' || !session.client) return;
|
|
let touch = touchHandlers.get(session.deviceId);
|
|
if (!touch) {
|
|
touch = new RDTouch(session.canvas, session.client.renderer, function(msg) {
|
|
if (session.client && session.client.input) {
|
|
session.client.input.sendMessage(msg);
|
|
}
|
|
});
|
|
touchHandlers.set(session.deviceId, touch);
|
|
}
|
|
touch.setMode(mobileTouchMode);
|
|
if (session.deviceId === activeSessionId && session.state === 'streaming' && !(session.client && session.client.viewOnly)) {
|
|
touch.start();
|
|
} else {
|
|
touch.stop();
|
|
}
|
|
}
|
|
|
|
function syncMobileTouchForActive() {
|
|
if (!window.RdClientMobile || !window.RdClientMobile.isMobileRdClient()) return;
|
|
touchHandlers.forEach(function(touch, id) {
|
|
var session = sessions.get(id);
|
|
if (id === activeSessionId && session && session.state === 'streaming' && !(session.client && session.client.viewOnly)) {
|
|
touch.setMode(mobileTouchMode);
|
|
touch.start();
|
|
} else {
|
|
touch.stop();
|
|
}
|
|
});
|
|
}
|
|
|
|
function resizeViewerForViewport() {
|
|
sessions.forEach(function(session) {
|
|
if (session.client && session.client.renderer) {
|
|
session.client.renderer.resize();
|
|
}
|
|
});
|
|
}
|
|
|
|
function initMobileViewerBindings() {
|
|
var kbBridge = document.getElementById('rd-keyboard-bridge');
|
|
var specialPanel = document.getElementById('rd-special-keys-panel');
|
|
|
|
document.getElementById('rd-mob-input-touch')?.addEventListener('click', function() {
|
|
mobileTouchMode = 'direct';
|
|
document.querySelectorAll('#rd-mobile-toolbar [data-mode]').forEach(function(b) {
|
|
b.classList.toggle('active', b.dataset.mode === 'direct');
|
|
});
|
|
syncMobileTouchForActive();
|
|
});
|
|
|
|
document.getElementById('rd-mob-input-touchpad')?.addEventListener('click', function() {
|
|
mobileTouchMode = 'touchpad';
|
|
document.querySelectorAll('#rd-mobile-toolbar [data-mode]').forEach(function(b) {
|
|
b.classList.toggle('active', b.dataset.mode === 'touchpad');
|
|
});
|
|
syncMobileTouchForActive();
|
|
});
|
|
|
|
document.getElementById('rd-mob-keyboard')?.addEventListener('click', function() {
|
|
if (window.RdClientMobile) window.RdClientMobile.focusKeyboardBridge(kbBridge);
|
|
});
|
|
|
|
document.getElementById('rd-mob-special')?.addEventListener('click', function() {
|
|
if (specialPanel) specialPanel.classList.toggle('open');
|
|
});
|
|
|
|
document.getElementById('rd-mob-cad')?.addEventListener('click', function() {
|
|
withClient(function(c) { c.sendCtrlAltDel(); });
|
|
});
|
|
|
|
document.getElementById('rd-mob-fullscreen')?.addEventListener('click', function() {
|
|
toggleViewerShellFullscreen();
|
|
});
|
|
|
|
specialPanel?.querySelectorAll('.rd-special-key-btn').forEach(function(btn) {
|
|
btn.addEventListener('click', function() {
|
|
var session = getActiveSession();
|
|
if (!session || !session.client || !session.client.input) return;
|
|
var input = session.client.input;
|
|
var map = {
|
|
Meta: 'MetaLeft',
|
|
'Alt+Tab': 'Tab',
|
|
PrintScreen: 'PrintScreen',
|
|
Escape: 'Escape'
|
|
};
|
|
var code = map[btn.dataset.special];
|
|
if (!code) return;
|
|
var down = new KeyboardEvent('keydown', { code: code, bubbles: true, cancelable: true });
|
|
if (btn.dataset.special === 'Alt+Tab') {
|
|
down = new KeyboardEvent('keydown', { code: 'Tab', altKey: true, bubbles: true, cancelable: true });
|
|
}
|
|
input._handleKeyDown(down);
|
|
var up = new KeyboardEvent('keyup', { code: code, bubbles: true, cancelable: true });
|
|
if (btn.dataset.special === 'Alt+Tab') {
|
|
up = new KeyboardEvent('keyup', { code: 'Tab', altKey: true, bubbles: true, cancelable: true });
|
|
}
|
|
input._handleKeyUp(up);
|
|
specialPanel.classList.remove('open');
|
|
});
|
|
});
|
|
|
|
if (kbBridge) {
|
|
kbBridge.addEventListener('keydown', function(e) {
|
|
var session = getActiveSession();
|
|
if (!session || !session.client || !session.client.input) return;
|
|
session.client.input._handleKeyDown(e);
|
|
});
|
|
kbBridge.addEventListener('keyup', function(e) {
|
|
var session = getActiveSession();
|
|
if (!session || !session.client || !session.client.input) return;
|
|
session.client.input._handleKeyUp(e);
|
|
});
|
|
}
|
|
|
|
if (window.RdClientMobile) {
|
|
window.RdClientMobile.initVisualViewport(resizeViewerForViewport);
|
|
}
|
|
}
|
|
|
|
var viewerInitialized = false;
|
|
var mobileBindingsDone = false;
|
|
|
|
function startViewerInit() {
|
|
if (viewerInitialized) return;
|
|
viewerInitialized = true;
|
|
|
|
applyTransportCapabilities();
|
|
if (!mobileBindingsDone) {
|
|
initMobileViewerBindings();
|
|
mobileBindingsDone = true;
|
|
}
|
|
|
|
const deviceId = window.__initialDeviceId;
|
|
const deviceName = window.__initialDeviceName || '';
|
|
const panelPref = new URLSearchParams(window.location.search).get('panel');
|
|
if (panelPref === 'files') {
|
|
window.__openFilePanelOnConnect = true;
|
|
}
|
|
if (deviceId) {
|
|
createSession(deviceId, deviceName);
|
|
}
|
|
|
|
// Support opening additional sessions via URL hash: #add=DEVICE_ID
|
|
if (window.location.hash) {
|
|
const match = window.location.hash.match(/add=([A-Za-z0-9_-]+)/);
|
|
if (match && match[1] && match[1] !== deviceId) {
|
|
createSession(match[1], '');
|
|
}
|
|
}
|
|
|
|
// ---- BroadcastChannel for cross-tab session adding ----
|
|
// Devices page can send {type:'add-session', deviceId, deviceName}
|
|
// to add a new tab here without opening a new browser tab.
|
|
try {
|
|
const bc = new BroadcastChannel('betterdesk-remote');
|
|
bc.onmessage = (ev) => {
|
|
const msg = ev.data;
|
|
if (!msg || typeof msg !== 'object') return;
|
|
if (msg.type === 'add-session' && msg.deviceId) {
|
|
// Validate deviceId format (alphanumeric, hyphens, underscores)
|
|
if (!/^[A-Za-z0-9_-]+$/.test(msg.deviceId)) return;
|
|
createSession(msg.deviceId, msg.deviceName || '');
|
|
// Acknowledge so the sender knows we handled it
|
|
bc.postMessage({ type: 'session-added', deviceId: msg.deviceId });
|
|
// Bring this window to front
|
|
window.focus();
|
|
} else if (msg.type === 'ping') {
|
|
bc.postMessage({ type: 'pong' });
|
|
}
|
|
};
|
|
} catch (_) {
|
|
// BroadcastChannel not supported — cross-tab disabled
|
|
}
|
|
}
|
|
|
|
function bootViewer() {
|
|
installLifecycleHandlers();
|
|
if (window.RdClientMobile && window.RdClientMobile.watchPhoneGate) {
|
|
window.RdClientMobile.watchPhoneGate(startViewerInit);
|
|
} else {
|
|
startViewerInit();
|
|
}
|
|
}
|
|
|
|
// ── Tab / window lifecycle: auto-disconnect on tab close ─────────────
|
|
//
|
|
// Without this hook the remote peer keeps streaming video/audio until
|
|
// the relay notices the WebSocket is gone (seconds, sometimes longer
|
|
// when the OS pauses the page). An explicit `pagehide` / `beforeunload`
|
|
// triggers a clean `disconnect()` on every active session so the peer
|
|
// tears down immediately — saves bandwidth and CPU on the remote end.
|
|
function installLifecycleHandlers() {
|
|
const teardown = () => {
|
|
for (const session of sessions.values()) {
|
|
try {
|
|
if (session.client) session.client.disconnect();
|
|
} catch { /* ignore */ }
|
|
if (session.mediaRecorder && session.mediaRecorder.state === 'recording') {
|
|
try { session.mediaRecorder.stop(); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
};
|
|
// pagehide fires on tab close, navigation, and bfcache eviction —
|
|
// the most reliable modern hook.
|
|
window.addEventListener('pagehide', teardown, { capture: true });
|
|
// beforeunload is a secondary fallback for older browsers.
|
|
window.addEventListener('beforeunload', teardown, { capture: true });
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', bootViewer);
|
|
} else {
|
|
bootViewer();
|
|
}
|
|
})();
|