This commit is contained in:
+19
-8
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -115,7 +138,9 @@ async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
|
|||||||
<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">User</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 {
|
||||||
@@ -152,17 +177,72 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow) {
|
|||||||
.to_string()
|
.to_string()
|
||||||
};
|
};
|
||||||
let hostname = pick("hostname");
|
let hostname = pick("hostname");
|
||||||
|
// `username` is the active console user reported by the agent's
|
||||||
|
// sysinfo. The agent suppresses the field when nobody is logged in
|
||||||
|
// (or when it's literally "SYSTEM" on Windows), so an empty value
|
||||||
|
// here means "no interactive user" — render that as a dash.
|
||||||
|
let active_user = pick("username");
|
||||||
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, tooltip) = if is_online {
|
||||||
|
(
|
||||||
|
"bg-emerald-400",
|
||||||
|
format!("Online — last heartbeat {}s ago", age_secs),
|
||||||
|
)
|
||||||
|
} else if age_secs == i64::MAX {
|
||||||
|
("bg-slate-500", "No heartbeat recorded".to_string())
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
"bg-rose-500",
|
||||||
|
format!("Offline — last heartbeat {} ago", fmt_age(age_secs)),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let id_cell = format!(
|
||||||
|
r##"<td class="px-3 py-2 font-mono text-slate-200 whitespace-nowrap">
|
||||||
|
<span class="inline-flex items-center gap-2" title="{tt}">
|
||||||
|
<span class="inline-block w-2 h-2 rounded-full {dot}"></span>
|
||||||
|
<span>{id}</span>
|
||||||
|
</span>
|
||||||
|
</td>"##,
|
||||||
|
tt = html_escape(&tooltip),
|
||||||
|
dot = dot_class,
|
||||||
|
id = html_escape(&d.id),
|
||||||
|
);
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
s,
|
s,
|
||||||
r##"<tr class="hover:bg-slate-800/40">
|
r##"<tr class="hover:bg-slate-800/40">
|
||||||
<td class="px-3 py-2 font-mono text-slate-200">{id}</td>
|
{id_cell}
|
||||||
<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-300">{user}</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 +276,37 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow) {
|
|||||||
</details>
|
</details>
|
||||||
</td>
|
</td>
|
||||||
</tr>"##,
|
</tr>"##,
|
||||||
|
id_cell = id_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),
|
||||||
|
user = if active_user.is_empty() {
|
||||||
|
"—".to_string()
|
||||||
|
} else {
|
||||||
|
html_escape(&active_user)
|
||||||
|
},
|
||||||
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,
|
||||||
|
|||||||
Vendored
+19
-5
File diff suppressed because one or more lines are too long
Vendored
+3
-3
File diff suppressed because one or more lines are too long
+106
-14
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user