web UI and web client improvements.
build / build-linux-amd64 (push) Successful in 2m0s

This commit is contained in:
2026-05-07 22:19:23 +02:00
parent 8ad3f43d21
commit dc2839086d
6 changed files with 303 additions and 34 deletions
+19 -8
View File
@@ -57,8 +57,7 @@
</div> </div>
</aside> </aside>
<main id="main" class="flex-1 p-6 overflow-x-hidden" <main id="main" class="flex-1 p-6 overflow-x-hidden">
hx-get="/admin/pages/users" hx-trigger="load">
<div class="text-slate-500 text-sm">Loading…</div> <div class="text-slate-500 text-sm">Loading…</div>
</main> </main>
</div> </div>
@@ -67,18 +66,30 @@
<div id="toast" <div id="toast"
class="fixed bottom-4 right-4 max-w-sm space-y-2 pointer-events-none"></div> class="fixed bottom-4 right-4 max-w-sm space-y-2 pointer-events-none"></div>
<!-- Highlight active link based on hash --> <!-- Load fragment + highlight active link based on the URL hash. -->
<script> <script>
function refreshActive() { function linkForHash() {
const hash = location.hash || '#users'; const hash = location.hash || '#users';
return document.querySelector('.nav-link[hx-push-url="' + hash + '"]')
|| document.querySelector('.nav-link[hx-push-url="#users"]');
}
function refreshActive() {
const active = linkForHash();
document.querySelectorAll('.nav-link').forEach(a => { document.querySelectorAll('.nav-link').forEach(a => {
const ahash = a.getAttribute('hx-push-url'); a.classList.toggle('active', a === active);
a.classList.toggle('active', ahash === hash);
}); });
} }
window.addEventListener('hashchange', refreshActive); function loadFromHash() {
document.body.addEventListener('htmx:afterSwap', refreshActive); const link = linkForHash();
if (link) {
htmx.ajax('GET', link.getAttribute('hx-get'),
{ target: '#main', swap: 'innerHTML' });
}
refreshActive(); refreshActive();
}
window.addEventListener('hashchange', loadFromHash);
document.body.addEventListener('htmx:afterSwap', refreshActive);
loadFromHash();
// Bounce to login if any HTMX request comes back 401. // Bounce to login if any HTMX request comes back 401.
document.body.addEventListener('htmx:responseError', (evt) => { document.body.addEventListener('htmx:responseError', (evt) => {
+101 -3
View File
@@ -13,6 +13,11 @@ use std::sync::Arc;
const PAGE_SIZE: i64 = 100; const PAGE_SIZE: i64 = 100;
/// Devices that have heartbeated within this many seconds are considered
/// online. Clients heartbeat every 15s (hbb_common::config::REG_INTERVAL),
/// so 45s allows up to two missed beats before we flip the dot to red.
const ONLINE_THRESHOLD_SECS: i64 = 45;
pub async fn index( pub async fn index(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser, admin: AuthedUser,
@@ -96,12 +101,30 @@ pub async fn delete(
// ---------- helpers ---------- // ---------- helpers ----------
/// Compute online/offline state from a SQLite `current_timestamp` string
/// ("YYYY-MM-DD HH:MM:SS" in UTC). Returns `(is_online, age_seconds)` —
/// the age is also useful for the tooltip text. On parse failure we fall
/// back to "offline" with `i64::MAX`, which is the safe direction (better
/// to show a stale row as offline than fake online).
fn online_state(last_heartbeat_at: &str, now: chrono::DateTime<chrono::Utc>) -> (bool, i64) {
let parsed = chrono::NaiveDateTime::parse_from_str(last_heartbeat_at, "%Y-%m-%d %H:%M:%S");
match parsed {
Ok(naive) => {
let last = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive, chrono::Utc);
let age = (now - last).num_seconds().max(0);
(age <= ONLINE_THRESHOLD_SECS, age)
}
Err(_) => (false, i64::MAX),
}
}
async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> { async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
let (total, devices) = state let (total, devices) = state
.db .db
.devices_list_all(0, PAGE_SIZE) .devices_list_all(0, PAGE_SIZE)
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
let now = chrono::Utc::now();
let mut s = String::new(); let mut s = String::new();
// No `overflow-hidden` on the table wrapper: the per-row action menu is // No `overflow-hidden` on the table wrapper: the per-row action menu is
// an absolutely-positioned `<details>` popover inside a <td>, and the // an absolutely-positioned `<details>` popover inside a <td>, and the
@@ -112,10 +135,12 @@ async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950"> <thead class="text-xs uppercase text-slate-500 bg-slate-950">
<tr> <tr>
<th class="text-left font-medium px-3 py-2 w-1">Status</th>
<th class="text-left font-medium px-3 py-2">Peer ID</th> <th class="text-left font-medium px-3 py-2">Peer ID</th>
<th class="text-left font-medium px-3 py-2">Owner</th> <th class="text-left font-medium px-3 py-2">Owner</th>
<th class="text-left font-medium px-3 py-2">Hostname</th> <th class="text-left font-medium px-3 py-2">Hostname</th>
<th class="text-left font-medium px-3 py-2">OS</th> <th class="text-left font-medium px-3 py-2">OS</th>
<th class="text-left font-medium px-3 py-2">Version</th>
<th class="text-left font-medium px-3 py-2">Last heartbeat</th> <th class="text-left font-medium px-3 py-2">Last heartbeat</th>
<th class="text-left font-medium px-3 py-2">Conns</th> <th class="text-left font-medium px-3 py-2">Conns</th>
<th class="text-right font-medium px-3 py-2 w-1">Actions</th> <th class="text-right font-medium px-3 py-2 w-1">Actions</th>
@@ -125,11 +150,11 @@ async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
); );
if devices.is_empty() { if devices.is_empty() {
s.push_str( s.push_str(
r##"<tr><td colspan="7" class="px-3 py-4 text-slate-500 text-center text-xs">No devices have heartbeated yet.</td></tr>"##, r##"<tr><td colspan="9" class="px-3 py-4 text-slate-500 text-center text-xs">No devices have heartbeated yet.</td></tr>"##,
); );
} }
for d in &devices { for d in &devices {
render_device_row(&mut s, d); render_device_row(&mut s, d, now);
} }
let _ = write!( let _ = write!(
s, s,
@@ -141,7 +166,7 @@ async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
Ok(s) Ok(s)
} }
fn render_device_row(s: &mut String, d: &DashboardDeviceRow) { fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTime<chrono::Utc>) {
let parsed: serde_json::Value = let parsed: serde_json::Value =
serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null); serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null);
let pick = |k: &str| -> String { let pick = |k: &str| -> String {
@@ -153,16 +178,72 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow) {
}; };
let hostname = pick("hostname"); let hostname = pick("hostname");
let os = pick("os"); let os = pick("os");
// Version label. The sysinfo upload always carries `version` (the
// embedded rustdesk core version, e.g. "1.4.6"). Rebrands like
// hello-agent additionally stamp `agent_name` + `agent_version` —
// when present we surface those instead so the admin sees
// "Hello Agent 0.1.0" rather than the embedded core version.
// Fallback "RustDesk <ver>" is the right default for vanilla
// installs (they don't override the agent fields).
let version_label = {
let core_ver = pick("version");
let agent_name = pick("agent_name");
let agent_ver = pick("agent_version");
if !agent_name.is_empty() {
if !agent_ver.is_empty() {
format!("{agent_name} {agent_ver}")
} else {
agent_name
}
} else if !core_ver.is_empty() {
format!("RustDesk {core_ver}")
} else {
"".to_string()
}
};
let conn_count = serde_json::from_str::<Vec<i64>>(&d.conns_json) let conn_count = serde_json::from_str::<Vec<i64>>(&d.conns_json)
.map(|v| v.len()) .map(|v| v.len())
.unwrap_or(0); .unwrap_or(0);
let (is_online, age_secs) = online_state(&d.last_heartbeat_at, now);
let (dot_class, label, tooltip) = if is_online {
(
"bg-emerald-400",
"Online",
format!("Online — last heartbeat {}s ago", age_secs),
)
} else if age_secs == i64::MAX {
(
"bg-slate-500",
"Unknown",
"No heartbeat recorded".to_string(),
)
} else {
(
"bg-rose-500",
"Offline",
format!("Offline — last heartbeat {} ago", fmt_age(age_secs)),
)
};
let status_cell = format!(
r##"<td class="px-3 py-2 whitespace-nowrap" title="{tt}">
<span class="inline-flex items-center gap-1.5 text-xs">
<span class="inline-block w-2 h-2 rounded-full {dot}"></span>
<span class="text-slate-400">{lbl}</span>
</span>
</td>"##,
tt = html_escape(&tooltip),
dot = dot_class,
lbl = label,
);
let _ = write!( let _ = write!(
s, s,
r##"<tr class="hover:bg-slate-800/40"> r##"<tr class="hover:bg-slate-800/40">
{status}
<td class="px-3 py-2 font-mono text-slate-200">{id}</td> <td class="px-3 py-2 font-mono text-slate-200">{id}</td>
<td class="px-3 py-2 text-slate-300">{owner}</td> <td class="px-3 py-2 text-slate-300">{owner}</td>
<td class="px-3 py-2 text-slate-400">{host}</td> <td class="px-3 py-2 text-slate-400">{host}</td>
<td class="px-3 py-2 text-slate-400">{os}</td> <td class="px-3 py-2 text-slate-400">{os}</td>
<td class="px-3 py-2 text-slate-400 whitespace-nowrap">{ver}</td>
<td class="px-3 py-2 text-slate-500 text-xs">{last}</td> <td class="px-3 py-2 text-slate-500 text-xs">{last}</td>
<td class="px-3 py-2 text-slate-400">{n}</td> <td class="px-3 py-2 text-slate-400">{n}</td>
<td class="px-3 py-2"> <td class="px-3 py-2">
@@ -196,15 +277,32 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow) {
</details> </details>
</td> </td>
</tr>"##, </tr>"##,
status = status_cell,
id = html_escape(&d.id), id = html_escape(&d.id),
owner = html_escape(&d.owner_username), owner = html_escape(&d.owner_username),
host = html_escape(&hostname), host = html_escape(&hostname),
os = html_escape(&os), os = html_escape(&os),
ver = html_escape(&version_label),
last = html_escape(&d.last_heartbeat_at), last = html_escape(&d.last_heartbeat_at),
n = conn_count n = conn_count
); );
} }
/// Render an elapsed-seconds count as a short "Xs / Xm / Xh / Xd" string
/// for the offline tooltip. The exact heartbeat timestamp is already
/// shown in the table cell — this is just for the friendly tooltip.
fn fmt_age(secs: i64) -> String {
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86_400 {
format!("{}h", secs / 3600)
} else {
format!("{}d", secs / 86_400)
}
}
async fn notice_then_table( async fn notice_then_table(
state: &Arc<AppState>, state: &Arc<AppState>,
kind: &str, kind: &str,
+19 -5
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long
+106 -14
View File
@@ -14,7 +14,7 @@ import {
relayUrl, relayUrl,
} from "./transport/rendezvous.js"; } from "./transport/rendezvous.js";
import { Relay } from "./transport/relay.js"; import { Relay } from "./transport/relay.js";
import { Session } from "./transport/session.js"; import { Session, ConnectionDeclinedError } from "./transport/session.js";
import { VideoPipeline } from "./decode/video.js"; import { VideoPipeline } from "./decode/video.js";
import { AudioPipeline } from "./decode/audio.js"; import { AudioPipeline } from "./decode/audio.js";
import { CanvasView } from "./ui/canvas.js"; import { CanvasView } from "./ui/canvas.js";
@@ -63,8 +63,9 @@ function utf8Bytes(s: string): Uint8Array {
/** /**
* Render a password prompt and return the entered string when the user * Render a password prompt and return the entered string when the user
* submits. The prompt re-renders with `errMsg` when set (e.g. "Wrong * submits. Only used as a fallback when the host signals a password is
* Password" from a previous attempt). * required (Wrong/Empty Password) — click-approve hosts (e.g. hello-agent
* with `approve-mode = click`) skip this entirely.
*/ */
function askPassword(cfg: CustomConfig, errMsg?: string): Promise<string> { function askPassword(cfg: CustomConfig, errMsg?: string): Promise<string> {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -79,10 +80,10 @@ function askPassword(cfg: CustomConfig, errMsg?: string): Promise<string> {
root.innerHTML = ` root.innerHTML = `
<div class="placeholder"> <div class="placeholder">
<h1>Connect to <code>${escapeHtml(cfg.peer_id)}</code></h1> <h1>Connect to <code>${escapeHtml(cfg.peer_id)}</code></h1>
<p class="muted">If the host requires a password, enter it below. Leave blank for "accept without password" hosts.</p> <p class="muted">The host requires a password to connect.</p>
${errBlock} ${errBlock}
<form id="pw-form" class="pw-form"> <form id="pw-form" class="pw-form">
<input id="pw-input" type="password" autocomplete="current-password" placeholder="Password (optional)" /> <input id="pw-input" type="password" autocomplete="current-password" placeholder="Password" />
<button type="submit">Connect</button> <button type="submit">Connect</button>
</form> </form>
</div>`; </div>`;
@@ -96,6 +97,53 @@ function askPassword(cfg: CustomConfig, errMsg?: string): Promise<string> {
}); });
} }
/**
* Render the "waiting for approval" placeholder shown after the host
* emits LoginResponse{error:"No Password Access"}. The Cancel button
* reloads the page to abort cleanly — the websocket closes on unload,
* which the host treats as a normal client disconnect.
*/
function renderAwaitingApproval(cfg: CustomConfig): void {
status(`
<h1>Waiting for approval</h1>
<p>The user on <code>${escapeHtml(cfg.peer_id)}</code> is being asked to allow this connection.</p>
<p class="muted">This window will continue automatically once they accept.</p>
<form class="pw-form" id="cancel-form">
<button type="submit">Cancel</button>
</form>
`);
const form = document.getElementById("cancel-form") as HTMLFormElement | null;
form?.addEventListener("submit", (ev) => {
ev.preventDefault();
window.location.reload();
});
}
/**
* Render the "host declined" placeholder. Resolves when the user clicks
* Try Again, so the caller can re-enter its connect loop.
*/
function renderDeclined(cfg: CustomConfig, reason: string): Promise<void> {
return new Promise((resolve) => {
const detail = reason && reason !== "Closed manually by the peer"
? `<p class="error-inline">${escapeHtml(reason)}</p>`
: "";
status(`
<h1>Connection declined</h1>
<p>The user on <code>${escapeHtml(cfg.peer_id)}</code> did not accept the connection request.</p>
${detail}
<form class="pw-form" id="retry-form">
<button type="submit">Try again</button>
</form>
`);
const form = document.getElementById("retry-form") as HTMLFormElement | null;
form?.addEventListener("submit", (ev) => {
ev.preventDefault();
resolve();
});
});
}
async function main(): Promise<void> { async function main(): Promise<void> {
const cfg = readConfig(); const cfg = readConfig();
await sodiumReady; await sodiumReady;
@@ -104,21 +152,52 @@ async function main(): Promise<void> {
const serverPk = base64Decode(cfg.key); const serverPk = base64Decode(cfg.key);
if (serverPk.length !== 32) throw new Error(`server pk wrong length ${serverPk.length}`); if (serverPk.length !== 32) throw new Error(`server pk wrong length ${serverPk.length}`);
// Retry loop: prompt for password, attempt connect, if "Wrong Password" // Connect loop. We start with an empty password and let the host drive
// re-prompt and try again. Other errors break out and surface as fatal. // the next state:
let lastErr: string | undefined; // - Click-approve hosts (e.g. hello-agent): emit "No Password Access"
// → we render the waiting-for-approval placeholder and keep reading
// on the same encrypted channel until the real LoginResponse or a
// close_reason arrives.
// - Password-required hosts: emit "Wrong Password" / "Empty Password"
// → we drop into the password prompt and retry with the user's
// input. (No password prompt is shown unless the host asks for one.)
// - User clicks No on the host's popup: surfaces as
// ConnectionDeclinedError → "declined" placeholder with Try Again.
let password = "";
let pwErr: string | undefined;
while (true) { while (true) {
const password = await askPassword(cfg, lastErr); if (pwErr !== undefined) {
// Host has signalled it requires a password — collect one.
password = await askPassword(cfg, pwErr);
}
try { try {
const ready = await connectOnce(cfg, serverPk, password, /*statusUI=*/true); const ready = await connectOnce(
cfg,
serverPk,
password,
/*statusUI=*/true,
() => renderAwaitingApproval(cfg),
);
// First connect succeeded — hand off to the session loop, which // First connect succeeded — hand off to the session loop, which
// owns reconnect. // owns reconnect.
await runSession(cfg, serverPk, password, ready); await runSession(cfg, serverPk, password, ready);
return; return;
} catch (e) { } catch (e) {
if (e instanceof ConnectionDeclinedError) {
await renderDeclined(cfg, e.message);
// Reset to empty-password attempt for the retry — the host's
// approve mode hasn't changed, just the operator's mind.
password = "";
pwErr = undefined;
continue;
}
const msg = String(e); const msg = String(e);
if (msg.includes("Wrong Password") || msg.includes("Bad password") || msg.toLowerCase().includes("password")) { if (msg.includes("Wrong Password")) {
lastErr = "Wrong password — try again."; pwErr = "Wrong password — try again.";
continue;
}
if (msg.includes("Empty Password")) {
pwErr = "This host requires a password.";
continue; continue;
} }
console.error("[rustdesk-web] fatal:", e); console.error("[rustdesk-web] fatal:", e);
@@ -148,6 +227,7 @@ async function connectOnce(
serverPk: Uint8Array, serverPk: Uint8Array,
password: string, password: string,
statusUI: boolean, statusUI: boolean,
onAwaitingApproval?: () => void,
): Promise<ConnectResult> { ): Promise<ConnectResult> {
const setStatus = (html: string): void => { if (statusUI) status(html); }; const setStatus = (html: string): void => { if (statusUI) status(html); };
@@ -200,6 +280,7 @@ async function connectOnce(
password: utf8Bytes(password), password: utf8Bytes(password),
clientVersion: CLIENT_VERSION, clientVersion: CLIENT_VERSION,
sessionId: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER), sessionId: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER),
onAwaitingApproval,
}); });
(window as unknown as { __rdw: unknown }).__rdw = { relay, session: ready.session, peerInfo: ready.peerInfo, cfg }; (window as unknown as { __rdw: unknown }).__rdw = { relay, session: ready.session, peerInfo: ready.peerInfo, cfg };
return ready; return ready;
@@ -582,13 +663,24 @@ async function runSession(
for (attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { for (attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
showOverlay(`Reconnecting (${attempt}/${MAX_ATTEMPTS})…`); showOverlay(`Reconnecting (${attempt}/${MAX_ATTEMPTS})…`);
try { try {
reconnected = await connectOnce(cfg, serverPk, password, /*statusUI=*/false); reconnected = await connectOnce(
cfg, serverPk, password, /*statusUI=*/false,
// On click-approve hosts, every reconnect re-triggers the
// approval popup on the host's screen. Surface that in the
// overlay so the user knows why the reconnect is taking time.
() => showOverlay(`Reconnecting — waiting for the user on ${cfg.peer_id} to accept…`),
);
break; break;
} catch (e) { } catch (e) {
if (e instanceof ConnectionDeclinedError) {
showOverlay(`Disconnected. The host declined the reconnect request.`);
audio.close();
return;
}
const msg = String(e); const msg = String(e);
// Auth errors won't fix on retry — the peer changed something // Auth errors won't fix on retry — the peer changed something
// (password rotated, key changed). Bail with a clear message. // (password rotated, key changed). Bail with a clear message.
if (msg.toLowerCase().includes("password") || if (msg.includes("Wrong Password") || msg.includes("Empty Password") ||
msg.toLowerCase().includes("signature verify")) { msg.toLowerCase().includes("signature verify")) {
showOverlay(`Cannot reconnect: ${msg.slice(0, 100)}`); showOverlay(`Cannot reconnect: ${msg.slice(0, 100)}`);
audio.close(); audio.close();
+55 -1
View File
@@ -18,6 +18,15 @@
// - empty password = SHA256(SHA256("" || salt) || challenge) on the desktop // - empty password = SHA256(SHA256("" || salt) || challenge) on the desktop
// Peer -> Us: Message{login_response: {peer_info or error}} // Peer -> Us: Message{login_response: {peer_info or error}}
// //
// Click-approve flow (host configured with `approve-mode = click`, e.g.
// hello-agent): the host's connection.rs:2465-2476 path sends an interim
// LoginResponse{error: "No Password Access"} BUT keeps the connection
// open while it shows the approval popup on the user's desktop. On
// approve, the host sends a SECOND LoginResponse with peer_info; on
// deny, the host sends Misc{close_reason: "Closed manually by the peer"}
// and closes the connection. We surface the wait via `onAwaitingApproval`
// and translate denial into ConnectionDeclinedError.
//
// Sequence-counter rule (the bit that bites every reimplementation — // Sequence-counter rule (the bit that bites every reimplementation —
// see libs/hbb_common/src/tcp.rs:296-344): SEND and RECV each maintain // see libs/hbb_common/src/tcp.rs:296-344): SEND and RECV each maintain
// their OWN counter starting at 0, only the secretbox-encrypted frames // their OWN counter starting at 0, only the secretbox-encrypted frames
@@ -50,6 +59,27 @@ export interface SessionOptions {
clientVersion: string; clientVersion: string;
/** Random session id (uint64). Number value (≤ 53 bits) — opaque to peer. */ /** Random session id (uint64). Number value (≤ 53 bits) — opaque to peer. */
sessionId: number; sessionId: number;
/**
* Fired once if the host responds to LoginRequest with the interim
* "No Password Access" error — meaning it's now showing an approval
* popup to the user on its end and we should keep waiting for the
* real LoginResponse. Use this to swap the UI to "waiting for
* approval" without aborting the connection.
*/
onAwaitingApproval?: () => void;
}
/**
* Thrown when the remote user explicitly declines the connection during
* the click-approve flow (host emits Misc{close_reason} after the user
* clicks No on the approval popup). Distinct from network/auth failures
* so the caller can render a "denied" UI rather than a generic error.
*/
export class ConnectionDeclinedError extends Error {
constructor(reason: string) {
super(reason || "The remote user declined the connection.");
this.name = "ConnectionDeclinedError";
}
} }
export interface SessionReady { export interface SessionReady {
@@ -188,6 +218,7 @@ export class Session {
// every non-trivial message into preloginExtras so the caller can // every non-trivial message into preloginExtras so the caller can
// replay them into its main receive dispatch. // replay them into its main receive dispatch.
const preloginExtras: hbb.Message[] = []; const preloginExtras: hbb.Message[] = [];
let approvalNotified = false;
while (true) { while (true) {
const respMsg = await session.recv(); const respMsg = await session.recv();
if (respMsg.test_delay) { if (respMsg.test_delay) {
@@ -204,11 +235,34 @@ export class Session {
} }
if (respMsg.login_response) { if (respMsg.login_response) {
const lr = respMsg.login_response; const lr = respMsg.login_response;
if (lr.error) throw new Error(`session: login refused: ${lr.error}`); if (lr.error) {
// "No Password Access" is the host's signal that approve-mode
// is Click (or Both without a valid password) — it has spawned
// the approval popup and will send a second LoginResponse once
// the user clicks Yes. Don't treat this as fatal; keep
// receiving on the same encrypted channel.
// See /Users/sn0/Desktop/rustdesk/src/server/connection.rs:2465-2476
// and src/client.rs:118 (LOGIN_MSG_NO_PASSWORD_ACCESS).
if (lr.error === "No Password Access") {
if (!approvalNotified) {
approvalNotified = true;
try { opts.onAwaitingApproval?.(); } catch { /* swallow */ }
}
continue;
}
throw new Error(`session: login refused: ${lr.error}`);
}
if (!lr.peer_info) throw new Error("session: login_response missing peer_info"); if (!lr.peer_info) throw new Error("session: login_response missing peer_info");
console.log(`[rustdesk-web] session: login OK, peer=${lr.peer_info.hostname}/${lr.peer_info.platform} v${lr.peer_info.version}`); console.log(`[rustdesk-web] session: login OK, peer=${lr.peer_info.hostname}/${lr.peer_info.platform} v${lr.peer_info.version}`);
return { session, peerInfo: lr.peer_info, preloginExtras }; return { session, peerInfo: lr.peer_info, preloginExtras };
} }
if (respMsg.misc?.close_reason != null) {
// The host sends close_reason when the operator clicks No on the
// approval popup (CM → Authorize/Close → send_close_reason_no_retry).
// Surface it as a typed error so the UI can show a "declined"
// state rather than a generic connect failure.
throw new ConnectionDeclinedError(respMsg.misc.close_reason);
}
// Stash the message for the caller to replay post-login. // Stash the message for the caller to replay post-login.
preloginExtras.push(respMsg); preloginExtras.push(respMsg);
} }