feat(audit): align with RustDesk 1.4.8 and update language files

- Conducted an audit against upstream RustDesk tag 1.4.8, confirming no breaking changes to the wire format.
- Implemented various fixes including client version string, supported encoding handshake, and multi-session handling.
- Updated language files for Arabic, Czech, Danish, German, English, and Spanish to reflect new UI elements and messages.
- Enhanced clipboard and file transfer functionalities to match upstream behavior.
This commit is contained in:
UNITRONIX
2026-07-06 21:15:43 +02:00
parent b313a1a996
commit eb8a0732d5
35 changed files with 110327 additions and 110029 deletions
+29
View File
@@ -86,3 +86,32 @@ This does **not** replace the RustDesk relay-based pipeline — it provides a *f
- `betterdesk-agent-client/src-tauri/src/bd_signal.rs` — agent signal/control channel
- `betterdesk-agent-client/src-tauri/src/cdap_client.rs` — CDAP gateway client
- Upstream RustDesk: <https://github.com/rustdesk/rustdesk>
## RustDesk 1.4.8 alignment (2026-07-06)
Audit against upstream tag **1.4.8** (June 2026). No breaking wire-format changes vs our `message.proto` / `rendezvous.proto` baseline — proto fields `supported_encoding`, `selected_sid`, `windows_sessions`, and `PeerInfo.encoding` were already present locally.
| RustDesk 1.4.8 item | RdClient status | Files | Notes |
|---|---|---|---|
| Client version string | **Fixed**`BetterDesk-Web/1.4.8` | `protocol.js` | Peers see current baseline |
| `Misc.supported_encoding` handshake | **Fixed** — advertise on session start; parse inbound | `protocol.js`, `client.js` | H265 ability now probed when WebCodecs available |
| `windows_sessions` / `selected_sid` | **Fixed** — parse + Actions menu UI | `client.js`, `remote.js`, `lang/*.json` | Session picker when peer reports ≥2 sessions |
| Monitor quick-switch toolbar (#15342) | **Fixed**`.toolbar-monitor-strip` for 24 displays | `remote.ejs`, `remote.css`, `remote.js` | Dropdown kept for virtual displays / >4 monitors |
| View-only clipboard disabled (#15224) | **Fixed** — guards on send + inbound apply + sync | `client.js`, `remote.js` | Matches upstream behaviour |
| Pause key Windows (#15351) | **Fixed**`Pause``0xE046`; Linux `Delete``Pause` | `keyboard-scancode.js`, tests | |
| Privacy mode multi-monitor (#15321) | **Partial** — toggle via `togglePrivacyMode` | `client.js` | Peer-side 1.4.8 refactor; no display param in web client |
| Clipboard DIB alpha (#15296) | **Partial** — RGBA path via `ImageData` | `clipboard.js` | PNG inbound preferred; alpha preserved on canvas path |
| Windows ARM64 host | n/a | — | Native peer feature, not rdclient |
| Hole-punch / mDNS | n/a | — | Browser limitation |
| Microphone → peer | **Missing** | `audio.js` | Phase 67 |
| File transfer resume | **Missing** | `filetransfer.js` | Phase 67 |
| Terminal PTY | **Missing** | — | bd-signal one-shot only |
### Verify with RustDesk 1.4.8 peer
1. Panel update → open `/remote/:id` — notch toolbar attached to tab bar; hover shows fullscreen + expand.
2. Password overlay — remember checkbox aligned under password field.
3. Multi-monitor peer — numbered strip in expanded notch; dropdown for virtual displays.
4. Windows multi-session host — Actions → Windows sessions.
5. View-only — clipboard paste and auto-sync blocked.
6. HTTPS + WebCodecs — H265 negotiates when peer supports it.
+4222 -4221
View File
File diff suppressed because it is too large Load Diff
+4215 -4214
View File
File diff suppressed because it is too large Load Diff
+4221 -4220
View File
File diff suppressed because it is too large Load Diff
+4215 -4214
View File
File diff suppressed because it is too large Load Diff
+4219 -4218
View File
File diff suppressed because it is too large Load Diff
+4215 -4214
View File
File diff suppressed because it is too large Load Diff
+4221 -4220
View File
File diff suppressed because it is too large Load Diff
+4221 -4220
View File
File diff suppressed because it is too large Load Diff
+4221 -4220
View File
File diff suppressed because it is too large Load Diff
+4221 -4220
View File
File diff suppressed because it is too large Load Diff
+4221 -4220
View File
File diff suppressed because it is too large Load Diff
+4215 -4214
View File
File diff suppressed because it is too large Load Diff
+4215 -4214
View File
File diff suppressed because it is too large Load Diff
+4215 -4214
View File
File diff suppressed because it is too large Load Diff
+4221 -4220
View File
File diff suppressed because it is too large Load Diff
+4215 -4214
View File
File diff suppressed because it is too large Load Diff
+4217 -4216
View File
File diff suppressed because it is too large Load Diff
+4215 -4214
View File
File diff suppressed because it is too large Load Diff
+4221 -4220
View File
File diff suppressed because it is too large Load Diff
+4221 -4220
View File
File diff suppressed because it is too large Load Diff
+4221 -4220
View File
File diff suppressed because it is too large Load Diff
+4221 -4220
View File
File diff suppressed because it is too large Load Diff
+4221 -4220
View File
File diff suppressed because it is too large Load Diff
+4221 -4220
View File
File diff suppressed because it is too large Load Diff
+4221 -4220
View File
File diff suppressed because it is too large Load Diff
+4217 -4216
View File
File diff suppressed because it is too large Load Diff
+171 -70
View File
@@ -272,85 +272,176 @@
text-align: center;
}
/* ---- Toolbar (floating RustDesk-style collapsible pill) ---- */
.viewer-toolbar {
position: fixed;
top: 44px;
.password-form-footer {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 4px;
text-align: left;
}
.session-remember-peer {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0;
font-size: 0.8125rem;
line-height: 1.4;
color: var(--text-secondary, #8b949e);
cursor: pointer;
user-select: none;
}
.session-remember-peer-checkbox {
width: 16px;
height: 16px;
margin-top: 2px;
flex-shrink: 0;
accent-color: var(--accent-blue, #388bfd);
cursor: pointer;
}
.session-remember-peer span {
flex: 1;
}
.password-form-footer .session-btn-authenticate {
margin-top: 0;
}
/* ---- Toolbar (notch integrated in session tab bar) ---- */
.toolbar-notch-slot {
position: absolute;
left: 50%;
top: 0;
transform: translateX(-50%);
z-index: 230;
pointer-events: none;
-webkit-app-region: no-drag;
}
.viewer-toolbar {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 0;
background: transparent;
border: none;
z-index: 50;
pointer-events: auto;
}
/* Expanded action pill — only visible when the toolbar is expanded */
/* Expanded action bar — hangs from tab bar as a longer notch */
.toolbar-expanded {
display: none;
position: relative;
/* Sit above the compact handle so dropdown menus (which extend downward
into the handle's region) are not covered by it. */
z-index: 2;
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
z-index: 3;
align-items: center;
flex-wrap: wrap;
flex-wrap: nowrap;
justify-content: center;
gap: 4px;
max-width: calc(100vw - 24px);
padding: 6px 10px;
background: linear-gradient(180deg, rgba(22, 27, 34, 0.97) 0%, rgba(22, 27, 34, 0.92) 100%);
gap: 6px;
max-width: calc(100vw - 120px);
padding: 5px 12px 8px;
background: linear-gradient(180deg, var(--bg-secondary, #161b22) 0%, rgba(22, 27, 34, 0.97) 100%);
backdrop-filter: blur(10px);
border: 1px solid var(--border-primary, #30363d);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
/* overflow must stay visible so dropdown menus (top:100%) can escape the pill */
border-top: none;
border-radius: 0 0 14px 14px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
overflow: visible;
}
.viewer-toolbar.expanded .toolbar-expanded {
.viewer-toolbar.expanded .toolbar-expanded,
.viewer-toolbar.pinned .toolbar-expanded {
display: flex;
animation: toolbar-pop 0.18s ease;
animation: notch-expand 0.2s ease;
}
@keyframes toolbar-pop {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
@keyframes notch-expand {
from { opacity: 0; transform: translateX(-50%) translateY(-4px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* Compact handle — always visible */
/* Compact notch handle — droplet attached to tab bar bottom edge */
.toolbar-handle {
display: flex;
position: relative;
z-index: 1;
align-items: center;
gap: 2px;
padding: 3px 5px;
background: linear-gradient(180deg, rgba(22, 27, 34, 0.95) 0%, rgba(22, 27, 34, 0.88) 100%);
backdrop-filter: blur(10px);
justify-content: center;
gap: 0;
min-width: 36px;
height: 14px;
padding: 0 4px 2px;
background: var(--bg-secondary, #161b22);
border: 1px solid var(--border-primary, #30363d);
border-radius: 999px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
opacity: 0.6;
transition: opacity 0.15s;
border-top: none;
border-radius: 0 0 12px 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
opacity: 0.55;
overflow: hidden;
transition: opacity 0.18s ease, min-width 0.2s ease, height 0.2s ease, padding 0.2s ease, gap 0.2s ease;
}
.toolbar-handle:hover,
.toolbar-handle .handle-btn {
width: 0;
min-width: 0;
height: 0;
padding: 0;
margin: 0;
opacity: 0;
overflow: hidden;
pointer-events: none;
transition: width 0.18s ease, min-width 0.18s ease, height 0.18s ease, opacity 0.15s ease;
}
.viewer-toolbar.hover-preview .toolbar-handle,
.viewer-toolbar.expanded .toolbar-handle,
.viewer-toolbar.dragging .toolbar-handle {
.viewer-toolbar.pinned .toolbar-handle,
.toolbar-handle:hover,
.toolbar-handle:focus-within {
opacity: 1;
min-width: 72px;
height: 26px;
padding: 2px 6px 4px;
gap: 2px;
}
.viewer-toolbar.hover-preview .toolbar-handle .handle-btn,
.viewer-toolbar.expanded .toolbar-handle .handle-btn,
.viewer-toolbar.pinned .toolbar-handle .handle-btn,
.toolbar-handle:hover .handle-btn,
.toolbar-handle:focus-within .handle-btn {
width: 28px;
min-width: 28px;
height: 22px;
opacity: 1;
pointer-events: auto;
}
.viewer-toolbar.expanded .toolbar-handle,
.viewer-toolbar.pinned .toolbar-handle {
border-radius: 0;
border-bottom: none;
box-shadow: none;
min-width: 48px;
height: 4px;
padding: 0;
gap: 0;
}
.viewer-toolbar.expanded .toolbar-handle .handle-btn,
.viewer-toolbar.pinned .toolbar-handle .handle-btn {
display: none;
}
.handle-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 28px;
border: none;
border-radius: 999px;
border-radius: 6px;
background: transparent;
color: var(--text-secondary, #8b949e);
cursor: pointer;
@@ -363,15 +454,7 @@
}
.handle-btn .material-icons {
font-size: 18px;
}
.handle-drag {
cursor: grab;
}
.viewer-toolbar.dragging .handle-drag {
cursor: grabbing;
font-size: 16px;
}
.handle-expand.active {
@@ -379,15 +462,52 @@
}
.toolbar-left,
.toolbar-center,
.toolbar-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.toolbar-center {
.toolbar-monitor-strip {
display: flex;
align-items: center;
gap: 2px;
padding: 0 4px;
border-left: 1px solid var(--border-primary, #30363d);
border-right: 1px solid var(--border-primary, #30363d);
margin: 0 2px;
}
.toolbar-monitor-strip[hidden] {
display: none !important;
}
.toolbar-monitor-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 0 6px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-secondary, #8b949e);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s, color 0.15s;
}
.toolbar-monitor-btn:hover {
background: var(--bg-tertiary, #21262d);
color: var(--text-primary, #e6edf3);
}
.toolbar-monitor-btn.active {
background: rgba(88, 166, 255, 0.15);
color: var(--accent-blue, #58a6ff);
}
/* Toolbar button */
@@ -444,13 +564,6 @@
color: var(--text-secondary, #8b949e);
}
/* Stats in toolbar center */
.toolbar-stats {
font-size: 12px;
color: var(--text-secondary, #8b949e);
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
}
/* ---- Toolbar Dropdowns ---- */
.toolbar-dropdown {
position: relative;
@@ -613,10 +726,6 @@
50% { opacity: 0.5; }
}
/* Pinned toolbar — keeps the expanded pill open (no auto-collapse) */
.viewer-toolbar.pinned .toolbar-expanded {
display: flex;
}
/* ---- Chat Panel ---- */
.chat-panel {
@@ -1354,18 +1463,10 @@
/* ---- Responsive ---- */
@media (max-width: 768px) {
.toolbar-center {
display: none;
}
.toolbar-device-info {
display: none;
}
.viewer-toolbar {
top: 40px;
}
.toolbar-expanded {
max-width: calc(100vw - 16px);
}
+64
View File
@@ -64,6 +64,8 @@ class RDClient {
// Codec / quality control
this._codecAbilities = null; // probed VideoDecoder support map
this._peerEncoding = null; // peer SupportedEncoding from handshake
this._windowsSessions = { sessions: [], currentSid: 0 };
this._preferCodec = opts.preferCodec || 'Auto';
this._adaptivePaused = false; // true once the user picks codec/quality manually
this._codecFallbackDone = false; // one automatic downgrade per session
@@ -1064,6 +1066,7 @@ class RDClient {
}
async _applyRemoteClipboard(clipboards) {
if (this._viewOnly) return;
const list = clipboards || [];
if (!list.length) return;
@@ -1137,6 +1140,11 @@ class RDClient {
this._emit('switch_display', misc.switchDisplay);
return;
}
if (misc.supportedEncoding || misc.supported_encoding) {
this._peerEncoding = misc.supportedEncoding || misc.supported_encoding;
this._emit('peer_encoding', this._peerEncoding);
return;
}
}
// ---- Session Management ----
@@ -1208,6 +1216,8 @@ class RDClient {
// immediately even if we joined an already-running stream on a delta.
this._sendPeerMessage(this.proto.buildMisc('refreshVideo', true));
this._advertiseSupportedEncoding();
// Start ping interval
this._pingInterval = setInterval(() => {
if (this._state === 'streaming') {
@@ -1445,6 +1455,7 @@ class RDClient {
*/
sendClipboard(text) {
if (this._state !== 'streaming') return;
if (this._viewOnly) return;
void this._sendClipboard(text);
}
@@ -1663,11 +1674,64 @@ class RDClient {
this._currentDisplay = 0;
}
this._virtualDisplay = this._parseVirtualDisplaySupport(info);
if (info.encoding) {
this._peerEncoding = info.encoding;
}
this._windowsSessions = this._parseWindowsSessions(info);
if (this.input && info.platform) {
this.input.setPeerPlatform(info.platform);
}
}
/**
* @param {Object} info
* @returns {{sessions: Array<{sid:number,name:string}>, currentSid: number}}
*/
_parseWindowsSessions(info) {
const raw = info.windowsSessions || info.windows_sessions;
if (!raw) return { sessions: [], currentSid: 0 };
const list = Array.isArray(raw.sessions) ? raw.sessions : [];
const sessions = list.map(function (s) {
return {
sid: s.sid,
name: s.name || ('Session ' + s.sid)
};
});
const currentSid = raw.currentSid != null ? raw.currentSid : (raw.current_sid || 0);
return { sessions: sessions, currentSid: currentSid };
}
/**
* Windows session list reported by the peer (multi-session hosts).
* @returns {{sessions: Array<{sid:number,name:string}>, currentSid: number}}
*/
getWindowsSessions() {
return this._windowsSessions || { sessions: [], currentSid: 0 };
}
/**
* Switch the controlled Windows session (RustDesk selected_sid).
* @param {number} sid
*/
selectWindowsSession(sid) {
if (this._state !== 'streaming') return;
const n = Number(sid);
if (!Number.isFinite(n) || n < 0) return;
this._sendPeerMessage(this.proto.buildMisc('selectedSid', n));
if (this._windowsSessions) this._windowsSessions.currentSid = n;
this._emit('windows_session_selected', n);
}
/**
* Advertise operator decode capabilities to the peer (Misc.supported_encoding).
* @private
*/
_advertiseSupportedEncoding() {
if (!this.proto || typeof this.proto.buildSupportedEncoding !== 'function') return;
const enc = this.proto.buildSupportedEncoding(this._codecAbilities);
this._sendPeerMessage(this.proto.buildMisc('supportedEncoding', enc));
}
/**
* Release stuck remote modifiers / keys (toolbar recovery).
*/
@@ -81,7 +81,7 @@ const CODE_TO_LINUX_SCAN = {
Home: 110, ArrowUp: 111, PageUp: 112,
ArrowLeft: 113, ArrowRight: 114,
End: 115, ArrowDown: 116, PageDown: 117,
Insert: 118, Delete: 119,
Insert: 118, Delete: 111,
MetaLeft: 133, MetaRight: 134, ContextMenu: 135,
NumpadEnter: 104, NumpadDivide: 106, PrintScreen: 107, Pause: 119,
};
+31 -5
View File
@@ -153,7 +153,8 @@ class RDProtocol {
return this.types.Message.decode(protoBytes);
}
// ---- Protocol message builders ----
// Client version advertised to RustDesk peers (keep in sync with product baseline)
static CLIENT_VERSION = 'BetterDesk-Web/1.4.8';
/**
* Build PunchHoleRequest for connecting to a device
@@ -169,7 +170,7 @@ class RDProtocol {
licenceKey: serverKey || '',
connType: ct,
token: '',
version: 'BetterDesk-Web/1.3.9',
version: RDProtocol.CLIENT_VERSION,
forceRelay: true // Browser must use relay
}
};
@@ -238,7 +239,7 @@ class RDProtocol {
myId: opts.myId || 'web-client-ft',
myName: opts.myName || 'BetterDesk Web',
myPlatform: 'Web',
version: 'BetterDesk-Web/1.3.9',
version: RDProtocol.CLIENT_VERSION,
sessionId: Date.now(),
fileTransfer: {
dir: opts.dir != null ? opts.dir : '',
@@ -263,7 +264,7 @@ class RDProtocol {
myId: opts.myId || 'web-client',
myName: opts.myName || 'BetterDesk Web',
myPlatform: 'Web',
version: 'BetterDesk-Web/1.3.9',
version: RDProtocol.CLIENT_VERSION,
sessionId: Date.now(),
option: {
imageQuality: this.enums.ImageQuality.values[quality] || this.enums.ImageQuality.values.Best,
@@ -312,13 +313,38 @@ class RDProtocol {
return {
abilityVp9: can('vp9', hasWebCodecs),
abilityH264: can('h264', hasWebCodecs || hasJMuxer),
abilityH265: can('h265', false),
abilityH265: can('h265', hasWebCodecs),
abilityAv1: can('av1', hasWebCodecs),
abilityVp8: can('vp8', hasWebCodecs),
prefer: this.preferCodecValue(o.prefer || (hasWebCodecs ? 'Auto' : 'H264'))
};
}
/**
* Build SupportedEncoding for Misc.supported_encoding (RustDesk 1.4.x negotiation).
* @param {Object} [abilities] - probed { h264, h265, vp8, av1 } booleans
* @returns {Object}
*/
buildSupportedEncoding(abilities) {
const a = abilities || {};
const has = (key, fallback) => (a[key] != null ? !!a[key] : !!fallback);
const hasWebCodecs = typeof VideoDecoder !== 'undefined';
const hasJMuxer = typeof JMuxer !== 'undefined';
return {
h264: has('h264', hasWebCodecs || hasJMuxer),
h265: has('h265', hasWebCodecs),
vp8: has('vp8', hasWebCodecs),
av1: has('av1', hasWebCodecs),
i444: {
vp8: false,
vp9: false,
av1: false,
h264: false,
h265: false
}
};
}
/**
* Build MouseEvent message
*/
+115 -93
View File
@@ -177,17 +177,18 @@
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 toolbarStatus = document.getElementById('toolbar-status');
const toolbarStats = document.getElementById('toolbar-stats');
const toolbarDeviceId = document.getElementById('toolbar-device-id');
const tabBar = document.getElementById('session-tabs');
// ---- Floating toolbar (collapsible RustDesk-style pill) ----
// The compact handle is always visible; the action pill expands only on an
// explicit click of the expand button. There is NO hover-to-open behaviour.
// ---- Notch toolbar (integrated in session tab bar) ----
// Compact droplet is always visible; hover reveals handle icons; expand
// button opens the full action bar as a longer notch.
let toolbarPinned = false;
function expandToolbar() {
toolbar.classList.remove('hover-preview');
toolbar.classList.add('expanded');
const exp = document.getElementById('btn-toolbar-expand');
if (exp) {
@@ -201,6 +202,7 @@
if (toolbarPinned) return;
if (document.querySelector('.toolbar-dropdown-menu.open')) return;
toolbar.classList.remove('expanded');
toolbar.classList.remove('hover-preview');
const exp = document.getElementById('btn-toolbar-expand');
if (exp) {
exp.classList.remove('active');
@@ -211,8 +213,9 @@
function toggleToolbar() {
if (toolbar.classList.contains('expanded')) {
// Force-collapse (ignore pin) when the user explicitly clicks.
toolbar.classList.remove('expanded');
toolbarPinned = false;
document.getElementById('btn-pin')?.classList.remove('active');
toolbar.classList.remove('expanded', 'hover-preview');
const exp = document.getElementById('btn-toolbar-expand');
if (exp) {
exp.classList.remove('active');
@@ -224,6 +227,29 @@
}
}
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');
}
});
}
// 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.
@@ -289,6 +315,7 @@
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();
@@ -719,7 +746,6 @@
c.on('stats', (stats) => {
session.lastStats = stats;
if (isActive(session)) updateStats(stats, session.latency);
});
c.on('latency', (rtt) => { session.latency = rtt; });
@@ -742,6 +768,7 @@
c.on('peer_info', () => {
if (isActive(session)) {
try { refreshMonitorButton(session); } catch { /* not ready */ }
try { updateWindowsSessionMenu(session); } catch { /* not ready */ }
}
});
c.on('display_switched', () => {
@@ -929,12 +956,6 @@
}
document.querySelector('.toolbar-status-sep')?.style.setProperty('display', isStreaming ? 'none' : '');
if (session.lastStats) {
updateStats(session.lastStats, session.latency);
} else {
toolbarStats.textContent = '';
}
// Audio icon
const audioBtn = document.getElementById('btn-audio');
if (audioBtn) {
@@ -967,25 +988,6 @@
});
}
// ---- Stats display ----
function updateStats(stats, latency) {
if (!stats) return;
const parts = [];
if (stats.video) {
const fps = stats.video.videoFps || 0;
parts.push(fps + ' FPS');
}
if (stats.video && stats.video.displayWidth && stats.video.displayHeight) {
parts.push(stats.video.displayWidth + 'x' + stats.video.displayHeight);
} else if (stats.renderer && stats.renderer.remoteWidth && stats.renderer.remoteHeight) {
parts.push(stats.renderer.remoteWidth + 'x' + stats.renderer.remoteHeight);
}
if (latency > 0) parts.push(latency + 'ms');
if (stats.video && stats.video.codec) parts.push(stats.video.codec.toUpperCase());
toolbarStats.textContent = parts.join(' | ');
}
// ---- Autoplay blocked overlay ----
function showAutoplayOverlay(session) {
@@ -1076,11 +1078,6 @@
withClient(c => c.disconnect());
});
// Fullscreen
document.getElementById('btn-fullscreen')?.addEventListener('click', () => {
toggleViewerShellFullscreen();
});
// Audio toggle
document.getElementById('btn-audio')?.addEventListener('click', function () {
const session = getActiveSession();
@@ -1134,6 +1131,11 @@
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) {
@@ -1260,9 +1262,81 @@
? session.client.getVirtualDisplaySupport()
: { supported: false };
} catch { vd = { supported: false }; }
const visible = (monitors.length > 1) || (vd && vd.supported);
btn.style.display = visible ? '' : 'none';
if (visible) updateMonitorMenu();
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() {
@@ -1303,6 +1377,7 @@
session.client.switchMonitor(m.idx);
menu.querySelectorAll('.monitor-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
refreshMonitorButton(session);
});
menu.appendChild(item);
});
@@ -1478,57 +1553,6 @@
toggleViewerShellFullscreen();
});
// ---- Compact handle: drag the floating toolbar horizontally ----
(function setupToolbarDrag() {
const dragBtn = document.getElementById('btn-toolbar-drag');
if (!dragBtn) return;
let dragging = false;
let startX = 0;
let startLeft = 0;
function clampLeft(px) {
const w = toolbar.offsetWidth || 200;
const min = 8 + w / 2;
const max = window.innerWidth - 8 - w / 2;
return Math.max(min, Math.min(max, px));
}
function onMove(ev) {
if (!dragging) return;
ev.preventDefault();
const clientX = ev.touches ? ev.touches[0].clientX : ev.clientX;
const next = clampLeft(startLeft + (clientX - startX));
toolbar.style.left = next + 'px';
toolbar.style.transform = 'translateX(-50%)';
}
function onUp() {
if (!dragging) return;
dragging = false;
toolbar.classList.remove('dragging');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.removeEventListener('touchmove', onMove);
document.removeEventListener('touchend', onUp);
}
function onDown(ev) {
ev.preventDefault();
dragging = true;
toolbar.classList.add('dragging');
const rect = toolbar.getBoundingClientRect();
startLeft = rect.left + rect.width / 2;
startX = ev.touches ? ev.touches[0].clientX : ev.clientX;
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('touchend', onUp);
}
dragBtn.addEventListener('mousedown', onDown);
dragBtn.addEventListener('touchstart', onDown, { passive: false });
})();
// ---- Back to devices — close all sessions first ----
document.getElementById('btn-back-devices')?.addEventListener('click', (e) => {
e.preventDefault();
@@ -1566,8 +1590,6 @@
// Fullscreen handler
document.addEventListener('fullscreenchange', () => {
const fsIcon = document.fullscreenElement ? 'fullscreen_exit' : 'fullscreen';
const icon = document.getElementById('btn-fullscreen')?.querySelector('.material-icons');
if (icon) icon.textContent = fsIcon;
const handleIcon = document.getElementById('btn-handle-fullscreen')?.querySelector('.material-icons');
if (handleIcon) handleIcon.textContent = fsIcon;
setTimeout(() => {
+20
View File
@@ -597,4 +597,24 @@ describe('RDInput keyboard parity (RustDesk contract)', () => {
expect(sent[0].keyEvent.modifiers).toContain(3);
expect(sent[0].keyEvent.modifiers).not.toContain(29);
});
it('maps Pause to Windows extended scancode 0xE046', () => {
const sent = [];
const input = makeInput((msg) => sent.push(msg));
input.setPeerPlatform('Windows');
input.setKeyboardMode('Map');
input.start();
input._handleKeyDown(keyEvt({ code: 'Pause', key: 'Pause' }));
expect(sent[0].keyEvent.mode).toBe('Map');
expect(sent[0].keyEvent.chr).toBe(0xE046);
});
it('maps Delete and Pause to distinct Linux scancodes', () => {
const sc = loadBrowserScripts(['public/js/rdclient/keyboard-scancode.js']);
const codeToScancode = sc.RDKeyboardScancode.codeToScancode;
expect(codeToScancode('Delete', 'Linux')).toBe(111);
expect(codeToScancode('Pause', 'Linux')).toBe(119);
});
});
@@ -284,3 +284,42 @@ describe('RDClient KeyEvent protobuf encoding', () => {
expect(decoded.keyEvent.controlKey).toBe('Unknown');
});
});
describe('RDClient SupportedEncoding protobuf encoding', () => {
let Message;
let SupportedEncoding;
beforeAll(async () => {
const root = await protobuf.load([
path.join(__dirname, '../protos/message.proto')
]);
Message = root.lookupType('hbb.Message');
SupportedEncoding = root.lookupType('hbb.SupportedEncoding');
});
it('encodes Misc.supported_encoding with codec flags', () => {
const enc = SupportedEncoding.fromObject({
h264: true,
h265: true,
vp8: false,
av1: true,
i444: { h264: false, h265: false, vp8: false, vp9: false, av1: false }
});
const buf = Message.encode(Message.fromObject({
misc: { supportedEncoding: enc }
})).finish();
expect(buf.length).toBeGreaterThan(0);
const decoded = Message.decode(buf).toJSON();
expect(decoded.misc.supportedEncoding.h264).toBe(true);
expect(decoded.misc.supportedEncoding.h265).toBe(true);
expect(decoded.misc.supportedEncoding.av1).toBe(true);
});
it('encodes Misc.selected_sid for Windows session switch', () => {
const buf = Message.encode(Message.fromObject({
misc: { selectedSid: 2 }
})).finish();
const decoded = Message.decode(buf).toJSON();
expect(decoded.misc.selectedSid).toBe(2);
});
});
+168 -197
View File
@@ -27,6 +27,164 @@
<span class="material-icons">arrow_back</span>
</button>
<div class="session-tabs" id="session-tabs"></div>
<div class="toolbar-notch-slot" id="toolbar-notch-slot">
<div class="viewer-toolbar" id="viewer-toolbar">
<div class="toolbar-expanded" id="toolbar-expanded">
<div class="toolbar-left">
<span class="toolbar-device-info">
<span class="toolbar-device-id" id="toolbar-device-id"></span>
<span class="toolbar-separator toolbar-status-sep">|</span>
<span class="toolbar-status" id="toolbar-status">${_('remote.connecting')}</span>
</span>
</div>
<div class="toolbar-monitor-strip" id="toolbar-monitor-strip" hidden></div>
<div class="toolbar-right">
<div class="toolbar-dropdown">
<button class="toolbar-btn" id="btn-actions" title="${_('remote.actions')}">
<span class="material-icons">flash_on</span>
</button>
<div class="toolbar-dropdown-menu" id="actions-menu">
<button class="dropdown-item" id="btn-cad">
<span class="material-icons">security</span> Ctrl+Alt+Del
</button>
<button class="dropdown-item" id="btn-lock">
<span class="material-icons">lock</span> ${_('remote.lock_screen')}
</button>
<button class="dropdown-item" id="btn-restart-remote">
<span class="material-icons">restart_alt</span> ${_('remote.restart_remote')}
</button>
<div class="dropdown-separator"></div>
<button class="dropdown-item" id="btn-refresh-screen">
<span class="material-icons">refresh</span> ${_('remote.refresh_screen')}
</button>
<button class="dropdown-item" id="btn-clipboard-paste">
<span class="material-icons">content_paste</span> ${_('remote.clipboard_paste')}
</button>
<button class="dropdown-item" id="btn-reset-keyboard">
<span class="material-icons">keyboard</span> ${_('remote.reset_keyboard')}
</button>
<div class="dropdown-separator"></div>
<button class="dropdown-item toggle-item" id="btn-block-input" data-active="false">
<span class="material-icons">block</span> ${_('remote.block_input')}
</button>
</div>
</div>
<div class="toolbar-dropdown">
<button class="toolbar-btn" id="btn-display" title="${_('remote.display_settings')}">
<span class="material-icons">tune</span>
</button>
<div class="toolbar-dropdown-menu toolbar-dropdown-wide" id="display-menu">
<div class="dropdown-label">${_('remote.image_quality')}</div>
<button class="dropdown-item quality-item active" data-quality="Best">
<span class="material-icons">hd</span> ${_('remote.quality_best')}
</button>
<button class="dropdown-item quality-item" data-quality="Balanced">
<span class="material-icons">tune</span> ${_('remote.quality_balanced')}
</button>
<button class="dropdown-item quality-item" data-quality="Low">
<span class="material-icons">speed</span> ${_('remote.quality_low')}
</button>
<div class="dropdown-separator"></div>
<div class="dropdown-label">${_('remote.codec')}</div>
<button class="dropdown-item codec-item active" data-codec="Auto">
<span class="material-icons">auto_awesome</span> ${_('remote.codec_auto')}
</button>
<button class="dropdown-item codec-item" data-codec="VP9">
<span class="material-icons">memory</span> VP9
</button>
<button class="dropdown-item codec-item" data-codec="AV1">
<span class="material-icons">memory</span> AV1
</button>
<button class="dropdown-item codec-item" data-codec="H264">
<span class="material-icons">memory</span> H264
</button>
<button class="dropdown-item codec-item" data-codec="VP8">
<span class="material-icons">memory</span> VP8
</button>
<button class="dropdown-item codec-item" data-codec="H265">
<span class="material-icons">memory</span> H265
</button>
<div class="dropdown-separator"></div>
<div class="dropdown-label">${_('remote.keyboard_mode')}</div>
<button class="dropdown-item keyboard-mode-item active" data-keyboard-mode="Auto">
<span class="material-icons">auto_awesome</span> ${_('remote.keyboard_mode_auto')}
</button>
<button class="dropdown-item keyboard-mode-item" data-keyboard-mode="Legacy">
<span class="material-icons">translate</span> ${_('remote.keyboard_mode_legacy')}
</button>
<button class="dropdown-item keyboard-mode-item" data-keyboard-mode="Map">
<span class="material-icons">keyboard</span> ${_('remote.keyboard_mode_map')}
</button>
<div class="dropdown-separator"></div>
<div class="dropdown-label">${_('remote.scale_mode')}</div>
<button class="dropdown-item scale-item active" data-scale="fit">
<span class="material-icons">fit_screen</span> ${_('remote.scale_fit')}
</button>
<button class="dropdown-item scale-item" data-scale="fill">
<span class="material-icons">crop_free</span> ${_('remote.scale_fill')}
</button>
<button class="dropdown-item scale-item" data-scale="1:1">
<span class="material-icons">filter_1</span> ${_('remote.scale_original')}
</button>
<button class="dropdown-item scale-item" data-scale="stretch">
<span class="material-icons">open_in_full</span> ${_('remote.scale_stretch')}
</button>
<div class="dropdown-separator"></div>
<button class="dropdown-item toggle-item" id="btn-show-cursor" data-active="true">
<span class="material-icons">mouse</span> ${_('remote.show_cursor')}
</button>
<button class="dropdown-item toggle-item" id="btn-lock-session" data-active="false">
<span class="material-icons">lock_clock</span> ${_('remote.lock_after_session')}
</button>
<button class="dropdown-item toggle-item" id="btn-privacy-mode" data-active="false">
<span class="material-icons">visibility_off</span> ${_('remote.privacy_mode')}
</button>
<button class="dropdown-item toggle-item" id="btn-disable-clipboard" data-active="false">
<span class="material-icons">content_paste_off</span> ${_('remote.disable_clipboard')}
</button>
</div>
</div>
<button class="toolbar-btn" id="btn-audio" title="${_('remote.toggle_audio')}">
<span class="material-icons">volume_up</span>
</button>
<div class="toolbar-dropdown">
<button class="toolbar-btn" id="btn-monitors" title="${_('remote.monitors') || 'Monitors'}" style="display:none">
<span class="material-icons">desktop_windows</span>
</button>
<div class="toolbar-dropdown-menu" id="monitors-menu">
<div class="dropdown-label">${_('remote.select_monitor') || 'Select Monitor'}</div>
</div>
</div>
<button class="toolbar-btn" id="btn-chat" title="${_('remote.chat')}">
<span class="material-icons">chat</span>
</button>
<button class="toolbar-btn" id="btn-file-transfer" title="${_('remote.file_transfer') || 'File Transfer'}">
<span class="material-icons">folder_open</span>
</button>
<button class="toolbar-btn" id="btn-record" title="${_('remote.record')}">
<span class="material-icons">fiber_manual_record</span>
</button>
<button class="toolbar-btn" id="btn-viewonly" title="${_('remote.view_only')}">
<span class="material-icons">visibility</span>
</button>
<button class="toolbar-btn" id="btn-pin" title="${_('remote.pin_toolbar')}">
<span class="material-icons">push_pin</span>
</button>
<button class="toolbar-btn toolbar-btn-danger" id="btn-disconnect" title="${_('remote.disconnect')}">
<span class="material-icons">power_settings_new</span>
</button>
</div>
</div>
<div class="toolbar-handle" id="toolbar-handle">
<button type="button" class="handle-btn" id="btn-handle-fullscreen" title="${_('remote.fullscreen')}">
<span class="material-icons">fullscreen</span>
</button>
<button type="button" class="handle-btn handle-expand" id="btn-toolbar-expand" title="${_('remote.toggle_menu')}">
<span class="material-icons">expand_more</span>
</button>
</div>
</div>
</div>
<button type="button" class="tab-bar-add" id="btn-add-session" title="${_('remote.new_connection') || 'New Connection'}">
<span class="material-icons">add</span>
</button>
@@ -139,195 +297,6 @@
<!-- Main Viewer Container (session panels are inserted here) -->
<div class="viewer-container" id="viewer-container"></div>
<!-- Shared floating toolbar (collapsible RustDesk-style pill, applies to active session) -->
<div class="viewer-toolbar" id="viewer-toolbar">
<!-- Expanded action pill (shown only when the toolbar is expanded) -->
<div class="toolbar-expanded" id="toolbar-expanded">
<div class="toolbar-left">
<span class="toolbar-device-info">
<span class="toolbar-device-id" id="toolbar-device-id"></span>
<span class="toolbar-separator toolbar-status-sep">|</span>
<span class="toolbar-status" id="toolbar-status">${_('remote.connecting')}</span>
</span>
</div>
<div class="toolbar-center">
<span class="toolbar-stats" id="toolbar-stats"></span>
</div>
<div class="toolbar-right">
<!-- Actions Menu -->
<div class="toolbar-dropdown">
<button class="toolbar-btn" id="btn-actions" title="${_('remote.actions')}">
<span class="material-icons">flash_on</span>
</button>
<div class="toolbar-dropdown-menu" id="actions-menu">
<button class="dropdown-item" id="btn-cad">
<span class="material-icons">security</span> Ctrl+Alt+Del
</button>
<button class="dropdown-item" id="btn-lock">
<span class="material-icons">lock</span> ${_('remote.lock_screen')}
</button>
<button class="dropdown-item" id="btn-restart-remote">
<span class="material-icons">restart_alt</span> ${_('remote.restart_remote')}
</button>
<div class="dropdown-separator"></div>
<button class="dropdown-item" id="btn-refresh-screen">
<span class="material-icons">refresh</span> ${_('remote.refresh_screen')}
</button>
<button class="dropdown-item" id="btn-clipboard-paste">
<span class="material-icons">content_paste</span> ${_('remote.clipboard_paste')}
</button>
<button class="dropdown-item" id="btn-reset-keyboard">
<span class="material-icons">keyboard</span> ${_('remote.reset_keyboard')}
</button>
<div class="dropdown-separator"></div>
<button class="dropdown-item toggle-item" id="btn-block-input" data-active="false">
<span class="material-icons">block</span> ${_('remote.block_input')}
</button>
</div>
</div>
<!-- Display Menu -->
<div class="toolbar-dropdown">
<button class="toolbar-btn" id="btn-display" title="${_('remote.display_settings')}">
<span class="material-icons">tune</span>
</button>
<div class="toolbar-dropdown-menu toolbar-dropdown-wide" id="display-menu">
<div class="dropdown-label">${_('remote.image_quality')}</div>
<button class="dropdown-item quality-item active" data-quality="Best">
<span class="material-icons">hd</span> ${_('remote.quality_best')}
</button>
<button class="dropdown-item quality-item" data-quality="Balanced">
<span class="material-icons">tune</span> ${_('remote.quality_balanced')}
</button>
<button class="dropdown-item quality-item" data-quality="Low">
<span class="material-icons">speed</span> ${_('remote.quality_low')}
</button>
<div class="dropdown-separator"></div>
<div class="dropdown-label">${_('remote.codec')}</div>
<button class="dropdown-item codec-item active" data-codec="Auto">
<span class="material-icons">auto_awesome</span> ${_('remote.codec_auto')}
</button>
<button class="dropdown-item codec-item" data-codec="VP9">
<span class="material-icons">memory</span> VP9
</button>
<button class="dropdown-item codec-item" data-codec="AV1">
<span class="material-icons">memory</span> AV1
</button>
<button class="dropdown-item codec-item" data-codec="H264">
<span class="material-icons">memory</span> H264
</button>
<button class="dropdown-item codec-item" data-codec="VP8">
<span class="material-icons">memory</span> VP8
</button>
<button class="dropdown-item codec-item" data-codec="H265">
<span class="material-icons">memory</span> H265
</button>
<div class="dropdown-separator"></div>
<div class="dropdown-label">${_('remote.keyboard_mode')}</div>
<button class="dropdown-item keyboard-mode-item active" data-keyboard-mode="Auto">
<span class="material-icons">auto_awesome</span> ${_('remote.keyboard_mode_auto')}
</button>
<button class="dropdown-item keyboard-mode-item" data-keyboard-mode="Legacy">
<span class="material-icons">translate</span> ${_('remote.keyboard_mode_legacy')}
</button>
<button class="dropdown-item keyboard-mode-item" data-keyboard-mode="Map">
<span class="material-icons">keyboard</span> ${_('remote.keyboard_mode_map')}
</button>
<div class="dropdown-separator"></div>
<div class="dropdown-label">${_('remote.scale_mode')}</div>
<button class="dropdown-item scale-item active" data-scale="fit">
<span class="material-icons">fit_screen</span> ${_('remote.scale_fit')}
</button>
<button class="dropdown-item scale-item" data-scale="fill">
<span class="material-icons">crop_free</span> ${_('remote.scale_fill')}
</button>
<button class="dropdown-item scale-item" data-scale="1:1">
<span class="material-icons">filter_1</span> ${_('remote.scale_original')}
</button>
<button class="dropdown-item scale-item" data-scale="stretch">
<span class="material-icons">open_in_full</span> ${_('remote.scale_stretch')}
</button>
<div class="dropdown-separator"></div>
<button class="dropdown-item toggle-item" id="btn-show-cursor" data-active="true">
<span class="material-icons">mouse</span> ${_('remote.show_cursor')}
</button>
<button class="dropdown-item toggle-item" id="btn-lock-session" data-active="false">
<span class="material-icons">lock_clock</span> ${_('remote.lock_after_session')}
</button>
<button class="dropdown-item toggle-item" id="btn-privacy-mode" data-active="false">
<span class="material-icons">visibility_off</span> ${_('remote.privacy_mode')}
</button>
<button class="dropdown-item toggle-item" id="btn-disable-clipboard" data-active="false">
<span class="material-icons">content_paste_off</span> ${_('remote.disable_clipboard')}
</button>
</div>
</div>
<!-- Audio -->
<button class="toolbar-btn" id="btn-audio" title="${_('remote.toggle_audio')}">
<span class="material-icons">volume_up</span>
</button>
<!-- Monitor Selector -->
<div class="toolbar-dropdown">
<button class="toolbar-btn" id="btn-monitors" title="${_('remote.monitors') || 'Monitors'}" style="display:none">
<span class="material-icons">desktop_windows</span>
</button>
<div class="toolbar-dropdown-menu" id="monitors-menu">
<div class="dropdown-label">${_('remote.select_monitor') || 'Select Monitor'}</div>
</div>
</div>
<!-- Chat -->
<button class="toolbar-btn" id="btn-chat" title="${_('remote.chat')}">
<span class="material-icons">chat</span>
</button>
<!-- File Transfer -->
<button class="toolbar-btn" id="btn-file-transfer" title="${_('remote.file_transfer') || 'File Transfer'}">
<span class="material-icons">folder_open</span>
</button>
<!-- Recording -->
<button class="toolbar-btn" id="btn-record" title="${_('remote.record')}">
<span class="material-icons">fiber_manual_record</span>
</button>
<!-- View Only -->
<button class="toolbar-btn" id="btn-viewonly" title="${_('remote.view_only')}">
<span class="material-icons">visibility</span>
</button>
<!-- Pin Toolbar -->
<button class="toolbar-btn" id="btn-pin" title="${_('remote.pin_toolbar')}">
<span class="material-icons">push_pin</span>
</button>
<!-- Fullscreen -->
<button class="toolbar-btn" id="btn-fullscreen" title="${_('remote.fullscreen')}">
<span class="material-icons">fullscreen</span>
</button>
<!-- Disconnect -->
<button class="toolbar-btn toolbar-btn-danger" id="btn-disconnect" title="${_('remote.disconnect')}">
<span class="material-icons">power_settings_new</span>
</button>
</div>
</div>
<!-- Always-visible compact handle: [move] [fullscreen] [expand/collapse] -->
<div class="toolbar-handle" id="toolbar-handle">
<button type="button" class="handle-btn handle-drag" id="btn-toolbar-drag" title="${_('remote.move_toolbar')}">
<span class="material-icons">drag_indicator</span>
</button>
<button type="button" class="handle-btn" id="btn-handle-fullscreen" title="${_('remote.fullscreen')}">
<span class="material-icons">fullscreen</span>
</button>
<button type="button" class="handle-btn handle-expand" id="btn-toolbar-expand" title="${_('remote.toggle_menu')}">
<span class="material-icons">expand_more</span>
</button>
</div>
</div>
</div><!-- /#rd-viewer-shell -->
<nav class="rd-mobile-toolbar" id="rd-mobile-toolbar" aria-label="${_('remote.mobile_toolbar') || 'Remote controls'}">
<button type="button" class="rd-mobile-toolbar-btn active" id="rd-mob-input-touch" data-mode="direct" title="${_('remote.input_mode_touch')}">
@@ -399,14 +368,16 @@
placeholder="${_('remote.password_placeholder')}" autocomplete="off">
</div>
<p class="login-error session-login-error" style="display:none;"></p>
<label class="form-check session-remember-peer" style="display:flex;align-items:center;gap:8px;margin:10px 0 0;font-size:0.875rem;">
<input type="checkbox" class="session-remember-peer-checkbox">
<span>${_('remote.remember_peer_password') || 'Remember device password on this device'}</span>
</label>
<button class="btn btn-primary btn-full session-btn-authenticate">
<span class="material-icons">login</span>
${_('remote.connect')}
</button>
<div class="password-form-footer">
<label class="session-remember-peer">
<input type="checkbox" class="session-remember-peer-checkbox">
<span>${_('remote.remember_peer_password') || 'Remember device password on this device'}</span>
</label>
<button class="btn btn-primary btn-full session-btn-authenticate">
<span class="material-icons">login</span>
${_('remote.connect')}
</button>
</div>
</div>
</div>
</div>